mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 16:58:24 +00:00
Fixes four defects in the Soulseek (slskd) A peer names its own files, and Shelfmark works out where slskd stored one from the last folder and the file name. `remote_directory_name` returned the whole directory when its last segment was empty. So `../../../etc//passwd.epub` or `/abs/path//book.epub` resolved outside the downloads folder, and Shelfmark imported whatever was there. For a multi-file release it moved those files into staging (`STAGE_MOVE`). - New `safe_remote_segments` refuses a name with an empty, `.` or `..` segment (which covers absolute and UNC paths), a NUL, or a drive anywhere but a Windows share root (`C:\Books\…`, which never reaches a local path). - Search (`iter_peer_files`, so `build_releases`) drops such files. - `download` refuses them before anything is queued, in both modes. The spec arrives with the queued release payload, so search filtering alone is not enough. - `local_path_for` also refuses a path that resolves outside the root (`resolve()` + `is_relative_to`), for example through a symlinked folder. `_poll`, used when isolation is off and as the fallback for slskd < 0.26, never asked for an activity grace. A file "Queued, Remotely" sent the same status and progress on every poll, so the orchestrator cancelled it after 300s and `SLSKD_QUEUE_TIMEOUT_MINUTES` never applied. It now requests, renews and releases the grace the same way isolated mode does. `search` caught every exception per query, so slskd being down or rejecting the API key showed as "no releases". When no query gets an answer it now raises `SourceUnavailableError`, as Prowlarr does, and the error reaches the user. One failed query among answered ones is still not an outage. A retry reuses the task's folder, and slskd renames a new file when one is already there. Files from a failed attempt therefore meant `_folder_complete`'s exact count could never match again. Now: - a failed or cancelled attempt removes its folder; - a new attempt removes any finished files an earlier one left (for example after a restart) before it queues the batch. `tests/slskd/conftest.py` stubbed `flask_socketio` whenever it had not been imported yet, even though it is installed. Running `pytest tests/slskd tests/` therefore broke the Flask app tests in the same worker (285 errors, also on10710ab). It now stubs only when the module is missing. - 25 new regression tests. All of them fail on10710ab. - `make python-checks` passes. - `uv run pytest tests/slskd tests/ -m "not integration and not e2e"` passes: 4047 tests one83bab9.
683 lines
27 KiB
Python
683 lines
27 KiB
Python
"""Unit tests for the slskd release source."""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
|
|
from shelfmark.metadata_providers import BookMetadata
|
|
from shelfmark.release_sources import ReleaseProtocol, SourceUnavailableError, get_source
|
|
from shelfmark.release_sources.slskd.api import SlskdAuthError
|
|
from shelfmark.release_sources.slskd.source import (
|
|
SlskdSource,
|
|
_plan_queries,
|
|
build_releases,
|
|
file_extension,
|
|
get_supported_formats,
|
|
iter_peer_files,
|
|
make_source_id,
|
|
rank_key,
|
|
remote_directory_name,
|
|
split_remote_path,
|
|
)
|
|
|
|
# ── fixtures / helpers ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_book(**kwargs) -> BookMetadata:
|
|
defaults = {
|
|
"provider": "hardcover",
|
|
"provider_id": "1",
|
|
"title": "Dune",
|
|
"authors": ["Frank Herbert"],
|
|
}
|
|
defaults.update(kwargs)
|
|
return BookMetadata(**defaults)
|
|
|
|
|
|
def _make_plan(
|
|
*,
|
|
variants: list[tuple[str, str]] | None = None,
|
|
manual_query: str | None = None,
|
|
) -> ReleaseSearchPlan:
|
|
variants = variants if variants is not None else [("Dune", "Frank Herbert")]
|
|
title_variants = [ReleaseSearchVariant(title=t, author=a) for t, a in variants]
|
|
return ReleaseSearchPlan(
|
|
languages=["en"],
|
|
isbn_candidates=[],
|
|
author="Frank Herbert",
|
|
title_variants=title_variants,
|
|
grouped_title_variants=title_variants,
|
|
manual_query=manual_query,
|
|
)
|
|
|
|
|
|
def _file(filename: str, size: int = 1000, **extra) -> dict:
|
|
return {"filename": filename, "size": size, "isLocked": False, **extra}
|
|
|
|
|
|
def _response(username: str = "peer", files: list[dict] | None = None, **extra) -> dict:
|
|
base = {
|
|
"username": username,
|
|
"hasFreeUploadSlot": True,
|
|
"queueLength": 0,
|
|
"uploadSpeed": 500000,
|
|
"fileCount": len(files or []),
|
|
"files": files or [],
|
|
}
|
|
base.update(extra)
|
|
return base
|
|
|
|
|
|
EBOOK_FORMATS = {"epub", "mobi", "azw3", "pdf"}
|
|
AUDIO_FORMATS = {"mp3", "m4b", "flac", "zip"}
|
|
|
|
|
|
# ── path helpers ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPathHelpers:
|
|
def test_split_backslash_path(self):
|
|
assert split_remote_path("@@a\\Books\\Author\\Dune.epub") == (
|
|
"@@a\\Books\\Author",
|
|
"Dune.epub",
|
|
)
|
|
|
|
def test_split_forward_slash_path(self):
|
|
assert split_remote_path("share/Books/Dune.epub") == ("share/Books", "Dune.epub")
|
|
|
|
def test_split_bare_filename(self):
|
|
assert split_remote_path("Dune.epub") == ("", "Dune.epub")
|
|
|
|
def test_split_mixed_separators_uses_last(self):
|
|
assert split_remote_path("a\\b/c\\d.epub") == ("a\\b/c", "d.epub")
|
|
|
|
def test_remote_directory_name(self):
|
|
assert remote_directory_name("@@a\\Books\\Author") == "Author"
|
|
assert remote_directory_name("Author") == "Author"
|
|
assert remote_directory_name("") == ""
|
|
|
|
def test_remote_directory_name_skips_an_empty_last_segment(self):
|
|
assert remote_directory_name("../../../etc/") == "etc"
|
|
assert remote_directory_name("@@a\\Books\\Author\\") == "Author"
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "ext"),
|
|
[
|
|
("Dune.EPUB", "epub"),
|
|
("Dune.part1.mp3", "mp3"),
|
|
("no-extension", ""),
|
|
("trailing.", ""),
|
|
],
|
|
)
|
|
def test_file_extension(self, name, ext):
|
|
assert file_extension(name) == ext
|
|
|
|
def test_source_id_is_stable_and_namespaced(self):
|
|
a = make_source_id("peer", "x\\y.epub")
|
|
assert a == make_source_id("peer", "x\\y.epub")
|
|
assert a.startswith("slskd:")
|
|
assert a != make_source_id("other", "x\\y.epub")
|
|
|
|
|
|
# ── iter_peer_files ────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestIterPeerFiles:
|
|
def test_extracts_files(self):
|
|
response = _response(files=[_file("@@a\\Books\\Dune.epub", 2048)])
|
|
(pf,) = iter_peer_files(response)
|
|
assert pf.username == "peer"
|
|
assert pf.filename == "@@a\\Books\\Dune.epub"
|
|
assert pf.directory == "@@a\\Books"
|
|
assert pf.basename == "Dune.epub"
|
|
assert pf.extension == "epub"
|
|
assert pf.size == 2048
|
|
|
|
def test_skips_locked_files(self):
|
|
response = _response(files=[_file("a\\locked.epub", isLocked=True), _file("a\\ok.epub")])
|
|
assert [pf.basename for pf in iter_peer_files(response)] == ["ok.epub"]
|
|
|
|
def test_skips_garbage(self):
|
|
response = _response(files=[None, {"filename": ""}, {"filename": "dir\\"}, "junk"])
|
|
assert iter_peer_files(response) == []
|
|
|
|
def test_skips_file_names_that_could_leave_the_downloads_folder(self):
|
|
evil = [
|
|
"../../../etc//passwd.epub",
|
|
"/abs/path//book.epub",
|
|
"/abs/path/book.epub",
|
|
"a\\..\\book.epub",
|
|
"C:\\book.epub",
|
|
"a\\D:\\book.epub",
|
|
]
|
|
response = _response(files=[*(_file(name) for name in evil), _file("C:\\Books\\ok.epub")])
|
|
assert [pf.filename for pf in iter_peer_files(response)] == ["C:\\Books\\ok.epub"]
|
|
releases = build_releases([response], content_type="ebook", formats=EBOOK_FORMATS)
|
|
assert [r.extra["files"][0]["filename"] for r in releases] == ["C:\\Books\\ok.epub"]
|
|
|
|
def test_missing_username_yields_nothing(self):
|
|
assert iter_peer_files({"files": [_file("a.epub")]}) == []
|
|
|
|
def test_size_coercion(self):
|
|
response = _response(files=[_file("a.epub", size="12"), _file("b.epub", size=None)])
|
|
assert [pf.size for pf in iter_peer_files(response)] == [12, 0]
|
|
|
|
|
|
# ── build_releases ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildReleases:
|
|
def test_ebook_one_release_per_file(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Books\\Dune.epub", 1000),
|
|
_file("@@a\\Books\\Dune.mobi", 2000),
|
|
_file("@@a\\Books\\cover.jpg", 50),
|
|
]
|
|
)
|
|
releases = build_releases([response], content_type="ebook", formats=EBOOK_FORMATS)
|
|
assert [r.title for r in releases] == ["Dune", "Dune"]
|
|
assert [r.format for r in releases] == ["epub", "mobi"]
|
|
assert releases[0].source == "slskd"
|
|
assert releases[0].protocol == ReleaseProtocol.SOULSEEK
|
|
assert releases[0].indexer == "peer"
|
|
assert releases[0].content_type == "ebook"
|
|
assert releases[0].size_bytes == 1000
|
|
assert releases[0].size == "1000 B"
|
|
assert releases[0].extra["username"] == "peer"
|
|
assert releases[0].extra["files"] == [{"filename": "@@a\\Books\\Dune.epub", "size": 1000}]
|
|
assert releases[0].extra["directory"] == "@@a\\Books"
|
|
assert releases[0].extra["directory_name"] == "Books"
|
|
assert releases[0].extra["availability"] == "Free slot"
|
|
assert releases[0].extra["formats"] == ["epub"]
|
|
|
|
def test_format_filter_respected(self):
|
|
response = _response(files=[_file("a\\x.pdf"), _file("a\\x.epub")])
|
|
releases = build_releases([response], content_type="ebook", formats={"epub"})
|
|
assert [r.format for r in releases] == ["epub"]
|
|
|
|
def test_bare_files_with_no_folder_are_not_grouped_into_one_audiobook(self):
|
|
response = _response(
|
|
files=[_file("01 Intro.mp3", 100), _file("Other Book.mp3", 200), _file("c.mp3", 300)]
|
|
)
|
|
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert sorted(r.extra["file_count"] for r in releases) == [1, 1, 1]
|
|
|
|
def test_audiobook_groups_directory(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Audio\\Dune\\01.mp3", 100),
|
|
_file("@@a\\Audio\\Dune\\02.mp3", 200),
|
|
_file("@@a\\Audio\\Dune\\03.mp3", 300),
|
|
_file("@@a\\Audio\\Dune\\cover.jpg", 5),
|
|
_file("@@a\\Audio\\Other\\single.m4b", 999),
|
|
]
|
|
)
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
assert len(releases) == 2
|
|
grouped = next(r for r in releases if r.extra["file_count"] == 3)
|
|
single = next(r for r in releases if r.extra["file_count"] == 1)
|
|
assert grouped.title == "Dune (3 files)"
|
|
assert grouped.format == "mp3"
|
|
assert grouped.size_bytes == 600
|
|
assert grouped.content_type == "audiobook"
|
|
assert [f["filename"] for f in grouped.extra["files"]] == [
|
|
"@@a\\Audio\\Dune\\01.mp3",
|
|
"@@a\\Audio\\Dune\\02.mp3",
|
|
"@@a\\Audio\\Dune\\03.mp3",
|
|
]
|
|
assert single.title == "single"
|
|
assert single.format == "m4b"
|
|
|
|
def test_audiobook_archives_stay_separate(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Dune\\Dune.zip", 5000),
|
|
_file("@@a\\Dune\\01.mp3", 100),
|
|
_file("@@a\\Dune\\02.mp3", 100),
|
|
]
|
|
)
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
formats = sorted((r.format, r.extra["file_count"]) for r in releases)
|
|
assert formats == [("mp3", 2), ("zip", 1)]
|
|
|
|
def test_dominant_format_wins_for_group(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Dune\\01.flac"),
|
|
_file("@@a\\Dune\\02.mp3"),
|
|
_file("@@a\\Dune\\03.mp3"),
|
|
]
|
|
)
|
|
(release,) = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
assert release.format == "mp3"
|
|
assert release.extra["formats"] == ["mp3", "flac"]
|
|
|
|
def test_dedupes_identical_files_across_responses(self):
|
|
response = _response(files=[_file("a\\x.epub")])
|
|
releases = build_releases([response, response], content_type="ebook", formats=EBOOK_FORMATS)
|
|
assert len(releases) == 1
|
|
|
|
def test_same_file_from_two_peers_is_two_releases(self):
|
|
a = _response(username="alice", files=[_file("a\\x.epub")])
|
|
b = _response(username="bob", files=[_file("a\\x.epub")])
|
|
releases = build_releases([a, b], content_type="ebook", formats=EBOOK_FORMATS)
|
|
assert {r.indexer for r in releases} == {"alice", "bob"}
|
|
assert len({r.source_id for r in releases}) == 2
|
|
|
|
def test_availability_labels(self):
|
|
busy = _response(hasFreeUploadSlot=False, queueLength=0, files=[_file("a\\x.epub")])
|
|
queued = _response(hasFreeUploadSlot=False, queueLength=4, files=[_file("a\\x.epub")])
|
|
(busy_release,) = build_releases([busy], content_type="ebook", formats=EBOOK_FORMATS)
|
|
(queued_release,) = build_releases([queued], content_type="ebook", formats=EBOOK_FORMATS)
|
|
assert busy_release.extra["availability"] == "Busy"
|
|
assert queued_release.extra["availability"] == "Queue 4"
|
|
|
|
|
|
# ── ranking ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestRanking:
|
|
def test_free_slot_then_queue_then_speed(self):
|
|
responses = [
|
|
_response(username="slow-free", uploadSpeed=10, files=[_file("a\\x.epub")]),
|
|
_response(
|
|
username="fast-queued",
|
|
hasFreeUploadSlot=False,
|
|
queueLength=2,
|
|
uploadSpeed=10**7,
|
|
files=[_file("a\\x.epub")],
|
|
),
|
|
_response(username="fast-free", uploadSpeed=10**6, files=[_file("a\\x.epub")]),
|
|
_response(
|
|
username="short-queue",
|
|
hasFreeUploadSlot=False,
|
|
queueLength=1,
|
|
uploadSpeed=1,
|
|
files=[_file("a\\x.epub")],
|
|
),
|
|
]
|
|
releases = build_releases(responses, content_type="ebook", formats=EBOOK_FORMATS)
|
|
releases.sort(key=rank_key)
|
|
assert [r.indexer for r in releases] == [
|
|
"fast-free",
|
|
"slow-free",
|
|
"short-queue",
|
|
"fast-queued",
|
|
]
|
|
|
|
|
|
# ── supported formats ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSupportedFormats:
|
|
def test_reads_ebook_formats(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(
|
|
mod.config,
|
|
"get",
|
|
lambda k, d=None: ["EPUB", ".mobi"] if k == "SUPPORTED_FORMATS" else d,
|
|
)
|
|
assert get_supported_formats("ebook") == {"epub", "mobi"}
|
|
|
|
def test_reads_audiobook_formats(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(
|
|
mod.config,
|
|
"get",
|
|
lambda k, d=None: ["m4b"] if k == "SUPPORTED_AUDIOBOOK_FORMATS" else d,
|
|
)
|
|
assert get_supported_formats("audiobook") == {"m4b"}
|
|
|
|
def test_falls_back_to_defaults(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", lambda k, d=None: d)
|
|
assert "epub" in get_supported_formats("ebook")
|
|
assert "mp3" in get_supported_formats("audiobook")
|
|
assert "zip" in get_supported_formats("audiobook")
|
|
|
|
def test_accepts_comma_string(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", lambda k, d=None: "epub, pdf")
|
|
assert get_supported_formats("ebook") == {"epub", "pdf"}
|
|
|
|
|
|
# ── query planning ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestPlanQueries:
|
|
def test_title_author_then_bare_title(self):
|
|
assert _plan_queries(_make_plan()) == ["Dune Frank Herbert", "Dune"]
|
|
|
|
def test_manual_query_first(self):
|
|
plan = _make_plan(manual_query="dune messiah herbert")
|
|
assert _plan_queries(plan)[0] == "dune messiah herbert"
|
|
|
|
def test_dedupes_and_caps(self):
|
|
plan = _make_plan(
|
|
variants=[("Dune", "Frank Herbert"), ("dune", "frank herbert"), ("Dune Messiah", "X")]
|
|
)
|
|
queries = _plan_queries(plan)
|
|
assert queries == ["Dune Frank Herbert", "Dune", "Dune Messiah X"]
|
|
|
|
def test_empty_plan(self):
|
|
assert _plan_queries(_make_plan(variants=[])) == []
|
|
|
|
|
|
# ── SlskdSource ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSlskdSource:
|
|
def _config(self, **overrides):
|
|
values = {"SLSKD_ENABLED": True, "SLSKD_URL": "http://slskd:5030", "SLSKD_API_KEY": "k"}
|
|
values.update(overrides)
|
|
return lambda k, default=None, **_kwargs: values.get(k, default)
|
|
|
|
def test_registered(self):
|
|
assert isinstance(get_source("slskd"), SlskdSource)
|
|
|
|
def test_available_when_enabled_and_url_set(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
assert SlskdSource().is_available() is True
|
|
|
|
def test_unavailable_when_disabled(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config(SLSKD_ENABLED=False))
|
|
assert SlskdSource().is_available() is False
|
|
|
|
def test_unavailable_without_url(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config(SLSKD_URL=""))
|
|
assert SlskdSource().is_available() is False
|
|
|
|
def test_column_config_serializes(self):
|
|
from shelfmark.release_sources import serialize_column_config
|
|
|
|
data = serialize_column_config(SlskdSource().get_column_config())
|
|
assert [c["key"] for c in data["columns"]] == [
|
|
"extra.username",
|
|
"extra.availability",
|
|
"format",
|
|
"size",
|
|
]
|
|
assert data["supported_filters"] == ["format"]
|
|
assert data["leading_cell"]["type"] == "none"
|
|
assert {o["sort_key"] for o in data["extra_sort_options"]} == {
|
|
"extra.upload_speed",
|
|
"extra.queue_length",
|
|
}
|
|
|
|
def test_search_without_client_returns_empty(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config(SLSKD_URL=""))
|
|
assert SlskdSource().search(_make_book(), _make_plan()) == []
|
|
|
|
def test_search_stops_after_first_query_with_results(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.return_value = [_response(files=[_file("a\\Dune.epub")])]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
releases = SlskdSource().search(_make_book(), _make_plan())
|
|
|
|
assert len(releases) == 1
|
|
client.search.assert_called_once()
|
|
assert client.search.call_args.args == ("Dune Frank Herbert",)
|
|
kwargs = client.search.call_args.kwargs
|
|
assert kwargs["wait_seconds"] == 15
|
|
assert kwargs["response_limit"] == 100
|
|
|
|
def test_search_falls_back_to_bare_title(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = [[], [_response(files=[_file("a\\Dune.epub")])]]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
releases = SlskdSource().search(_make_book(), _make_plan())
|
|
|
|
assert len(releases) == 1
|
|
assert [c.args[0] for c in client.search.call_args_list] == ["Dune Frank Herbert", "Dune"]
|
|
|
|
def test_expand_search_runs_every_query(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = [
|
|
[_response(files=[_file("a\\Dune.epub")])],
|
|
[_response(username="other", files=[_file("b\\Dune.epub")])],
|
|
]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
releases = SlskdSource().search(_make_book(), _make_plan(), expand_search=True)
|
|
|
|
assert len(releases) == 2
|
|
assert client.search.call_count == 2
|
|
|
|
def test_search_survives_client_errors(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = [RuntimeError("boom"), [_response(files=[_file("a\\x.epub")])]]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
assert len(SlskdSource().search(_make_book(), _make_plan())) == 1
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "expected"),
|
|
[
|
|
(requests.ConnectionError("Connection refused"), "Connection refused"),
|
|
(SlskdAuthError("slskd rejected the API key"), "API key"),
|
|
],
|
|
)
|
|
def test_search_raises_when_every_query_fails(self, monkeypatch, error, expected):
|
|
"""slskd being down or rejecting the key must not read as "no releases found"."""
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = error
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
with pytest.raises(SourceUnavailableError, match=expected):
|
|
SlskdSource().search(_make_book(), _make_plan())
|
|
assert client.search.call_count == 2 # every query was tried
|
|
|
|
def test_search_failure_is_reported_to_the_user(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
from shelfmark.core.release_search import search_source_releases
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = requests.ConnectionError("Connection refused")
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
_, releases, error = search_source_releases("slskd", _make_book())
|
|
|
|
assert releases == []
|
|
assert error is not None
|
|
assert "Connection refused" in error
|
|
|
|
def test_one_failed_query_among_answered_ones_is_not_an_outage(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = [RuntimeError("boom"), []]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
assert SlskdSource().search(_make_book(), _make_plan()) == []
|
|
|
|
def test_search_uses_configured_limits(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(
|
|
mod.config, "get", self._config(SLSKD_SEARCH_TIMEOUT="30", SLSKD_RESPONSE_LIMIT=5000)
|
|
)
|
|
client = MagicMock()
|
|
client.search.return_value = []
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
SlskdSource().search(_make_book(), _make_plan())
|
|
|
|
kwargs = client.search.call_args_list[0].kwargs
|
|
assert kwargs["wait_seconds"] == 30
|
|
assert kwargs["response_limit"] == 1000 # clamped
|
|
|
|
def test_search_audiobook_uses_audio_formats(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", self._config())
|
|
client = MagicMock()
|
|
client.search.return_value = [
|
|
_response(
|
|
files=[_file("a\\Dune\\01.mp3"), _file("a\\Dune\\02.mp3"), _file("a\\Dune.epub")]
|
|
)
|
|
]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
releases = SlskdSource().search(_make_book(), _make_plan(), content_type="audiobook")
|
|
|
|
assert len(releases) == 1
|
|
assert releases[0].extra["file_count"] == 2
|
|
|
|
|
|
# ── fork additions: containers, merged variants, strict-match signals ───────────
|
|
|
|
|
|
class TestWholeBookContainers:
|
|
def test_an_m4b_next_to_loose_chapters_is_its_own_release(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Dune\\Dune.m4b", 900),
|
|
_file("@@a\\Dune\\01.mp3", 100),
|
|
_file("@@a\\Dune\\02.mp3", 100),
|
|
_file("@@a\\Dune\\03.mp3", 100),
|
|
]
|
|
)
|
|
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert sorted((r.format, r.extra["file_count"]) for r in releases) == [
|
|
("m4b", 1),
|
|
("mp3", 3),
|
|
]
|
|
|
|
def test_two_m4b_files_in_one_folder_are_two_releases(self):
|
|
response = _response(files=[_file("@@a\\Dune\\A.m4b", 5), _file("@@a\\Dune\\B.m4b", 6)])
|
|
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert sorted(r.extra["file_count"] for r in releases) == [1, 1]
|
|
|
|
def test_mixed_chapter_formats_stay_one_release(self):
|
|
response = _response(files=[_file("@@a\\Dune\\01.flac"), _file("@@a\\Dune\\02.mp3")])
|
|
|
|
releases = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert len(releases) == 1
|
|
|
|
|
|
class TestMergedQueryVariants:
|
|
def test_chapters_found_by_different_queries_are_one_release(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", TestSlskdSource()._config())
|
|
client = MagicMock()
|
|
client.search.side_effect = [
|
|
[_response(files=[_file("@@a\\Dune\\01.mp3"), _file("@@a\\Dune\\02.mp3")])],
|
|
[_response(files=[_file("@@a\\Dune\\02.mp3"), _file("@@a\\Dune\\03.mp3")])],
|
|
]
|
|
monkeypatch.setattr(SlskdSource, "_get_client", lambda self: client)
|
|
|
|
releases = SlskdSource().search(
|
|
_make_book(), _make_plan(), expand_search=True, content_type="audiobook"
|
|
)
|
|
|
|
assert len(releases) == 1
|
|
assert [f["filename"] for f in releases[0].extra["files"]] == [
|
|
"@@a\\Dune\\01.mp3",
|
|
"@@a\\Dune\\02.mp3",
|
|
"@@a\\Dune\\03.mp3",
|
|
]
|
|
|
|
def test_merging_keeps_each_peers_own_slot_details(self):
|
|
from shelfmark.release_sources.slskd.source import merge_responses
|
|
|
|
first = _response(username="a", files=[_file("x\\1.mp3")], hasFreeUploadSlot=True)
|
|
second = _response(
|
|
username="a", files=[_file("x\\2.mp3")], hasFreeUploadSlot=False, queueLength=9
|
|
)
|
|
other = _response(username="b", files=[_file("y\\1.mp3")])
|
|
|
|
merged = merge_responses([[first], [second, other]])
|
|
|
|
assert sorted(r["username"] for r in merged) == ["a", "b"]
|
|
a = next(r for r in merged if r["username"] == "a")
|
|
assert sorted(f["filename"] for f in a["files"]) == ["x\\1.mp3", "x\\2.mp3"]
|
|
assert a["hasFreeUploadSlot"] is True # the first answer's details win
|
|
|
|
|
|
class TestStrictMatchSignals:
|
|
"""Auto-download looks for the author in the title, the peer name and extra['author'], and
|
|
for bundle names in extra['title_raw']. A Soulseek result has the author only in its folder."""
|
|
|
|
def test_the_folder_path_is_offered_as_the_author_text(self):
|
|
response = _response(files=[_file("@@a\\Books\\Frank Herbert\\Dune\\Dune.epub")])
|
|
|
|
(release,) = build_releases([response], content_type="ebook", formats=EBOOK_FORMATS)
|
|
|
|
assert "Frank Herbert" in release.extra["author"]
|
|
assert "Dune" in release.extra["author"]
|
|
|
|
def test_a_folder_release_also_offers_its_path_as_the_author_text(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Audio\\Frank Herbert\\Dune\\01.mp3"),
|
|
_file("@@a\\Audio\\Frank Herbert\\Dune\\02.mp3"),
|
|
]
|
|
)
|
|
|
|
(release,) = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert "Frank Herbert" in release.extra["author"]
|
|
|
|
def test_the_folder_name_is_kept_for_bundle_detection(self):
|
|
response = _response(
|
|
files=[
|
|
_file("@@a\\Herbert\\Dune Complete Series\\01.mp3"),
|
|
_file("@@a\\Herbert\\Dune Complete Series\\02.mp3"),
|
|
]
|
|
)
|
|
|
|
(release,) = build_releases([response], content_type="audiobook", formats=AUDIO_FORMATS)
|
|
|
|
assert "Complete Series" in release.extra["title_raw"]
|
|
|
|
|
|
class TestAvailability:
|
|
def test_unavailable_without_an_api_key(self, monkeypatch):
|
|
import shelfmark.release_sources.slskd.source as mod
|
|
|
|
monkeypatch.setattr(mod.config, "get", TestSlskdSource()._config(SLSKD_API_KEY=""))
|
|
|
|
assert SlskdSource().is_available() is False
|