chore: sprint cleanup — language unification, parser unification, fossils removal
Several weeks of work accumulated without being committed. Grouped here for clarity; see CHANGELOG.md [Unreleased] for the user-facing summary. Highlights ---------- P1 #2 — ISO 639-2/B canonical migration - New Language VO + LanguageRegistry (alfred/domain/shared/knowledge/). - iso_languages.yaml as single source of truth for language codes. - SubtitleKnowledgeBase now delegates lookup to LanguageRegistry; subtitles.yaml only declares subtitle-specific tokens (vostfr, vf, vff, …). - SubtitlePreferences default → ["fre", "eng"]; subtitle filenames written as {iso639_2b}.srt (legacy fr.srt still read via alias). - Scanner: dropped _LANG_KEYWORDS / _SDH_TOKENS / _FORCED_TOKENS / SUBTITLE_EXTENSIONS hardcoded dicts. - Fixed: 'hi' token no longer marks SDH (conflicted with Hindi alias). - Added settings.min_movie_size_bytes (was a module constant). P1 #3 — Release parser unification + data-driven tokenizer - parse_release() is now the single source of truth for release-name parsing. - alfred/knowledge/release/separators.yaml declares the token separators used by the tokenizer (., space, [, ], (, ), _). New conventions can be added without code changes. - Tokenizer now splits on any configured separator instead of name.split('.'). Releases like 'The Father (2020) [1080p] [WEBRip] [5.1] [YTS.MX]' parse via the direct path without sanitization fallback. - Site-tag extraction always runs first; well-formedness only rejects truly forbidden chars. - _parse_season_episode() extended with NxNN / NxNNxNN alt forms. - Removed dead helpers: _sanitize, _normalize. Domain cleanup - Deleted fossil services with zero production callers: alfred/domain/movies/services.py alfred/domain/tv_shows/services.py alfred/domain/subtitles/services.py (replaced by subtitles/services/ package) alfred/domain/subtitles/repositories.py - Split monolithic subtitle services into a package (identifier, matcher, placer, pattern_detector, utils) + dedicated knowledge/ package. - MediaInfo split into dedicated package (alfred/domain/shared/media/: audio, video, subtitle, info, matching). Persistence cleanup - Removed dead JSON repositories (movie/subtitle/tvshow_repository.py). Tests - Major expansion of the test suite organized to mirror the source tree. - Removed obsolete *_edge_cases test files superseded by structured tests. - Suite: 990 passed, 8 skipped. Misc - .gitignore: exclude env_backup/ and *.bak. - Adjustments across agent/llm, app.py, application/filesystem, and infrastructure/filesystem to align with the new domain layout.
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"""Tests for ``alfred.infrastructure.api.knaben.client.KnabenClient``.
|
||||
|
||||
- ``TestInit`` — explicit args override settings; no API key required.
|
||||
- ``TestMakeRequest`` — error translation: timeout, 404, 429 (rate limit),
|
||||
generic 5xx, and ``RequestException``.
|
||||
- ``TestSearch`` — query validation, success path, empty hits, request
|
||||
parameter wiring (search_field/order_by/etc.), 404 → empty list,
|
||||
per-result parse failures are swallowed (best-effort parsing).
|
||||
- ``TestParseTorrent`` — coverage of optional/missing fields and
|
||||
``int(... or 0)`` coercion for null seeders/leechers.
|
||||
|
||||
All HTTP is mocked at ``alfred.infrastructure.api.knaben.client.requests``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from alfred.infrastructure.api.knaben.client import KnabenClient
|
||||
from alfred.infrastructure.api.knaben.exceptions import (
|
||||
KnabenAPIError,
|
||||
KnabenNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(json_body):
|
||||
r = MagicMock()
|
||||
r.status_code = 200
|
||||
r.json.return_value = json_body
|
||||
r.raise_for_status.return_value = None
|
||||
return r
|
||||
|
||||
|
||||
def _http_error_response(status_code):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
err = HTTPError(f"{status_code}")
|
||||
err.response = r
|
||||
r.raise_for_status.side_effect = err
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return KnabenClient(base_url="https://api.knaben.test/v1", timeout=5)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Init #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_default_base_url(self):
|
||||
c = KnabenClient()
|
||||
assert c.base_url == "https://api.knaben.org/v1"
|
||||
|
||||
def test_explicit_override(self):
|
||||
c = KnabenClient(base_url="https://x", timeout=99)
|
||||
assert c.base_url == "https://x"
|
||||
assert c.timeout == 99
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _make_request #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMakeRequest:
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_timeout(self, mock_post, client):
|
||||
mock_post.side_effect = Timeout("slow")
|
||||
with pytest.raises(KnabenAPIError, match="timeout"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_404(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(404)
|
||||
with pytest.raises(KnabenNotFoundError):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_429_rate_limit(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(429)
|
||||
with pytest.raises(KnabenAPIError, match="Rate limit"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_http_500(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(500)
|
||||
with pytest.raises(KnabenAPIError, match="500"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_request_exception(self, mock_post, client):
|
||||
mock_post.side_effect = RequestException("net")
|
||||
with pytest.raises(KnabenAPIError, match="connect"):
|
||||
client._make_request({"q": "x"})
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_posts_json_body(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client._make_request({"q": "x"})
|
||||
call = mock_post.call_args
|
||||
# KnabenClient sends params as JSON body, not query string
|
||||
assert call.kwargs["json"] == {"q": "x"}
|
||||
assert call.kwargs["timeout"] == 5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# search #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSearch:
|
||||
@pytest.mark.parametrize("bad", ["", None, 42])
|
||||
def test_invalid_query(self, client, bad):
|
||||
with pytest.raises(ValueError):
|
||||
client.search(bad)
|
||||
|
||||
def test_query_too_long(self, client):
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
client.search("a" * 501)
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_success(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response(
|
||||
{
|
||||
"hits": [
|
||||
{
|
||||
"title": "Inception.2010.1080p",
|
||||
"size": "10 GB",
|
||||
"seeders": 500,
|
||||
"leechers": 50,
|
||||
"magnetUrl": "magnet:?xt=...",
|
||||
"hash": "abc",
|
||||
"tracker": "rarbg",
|
||||
"date": "2020-01-01",
|
||||
"category": "movie",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
results = client.search("Inception")
|
||||
assert len(results) == 1
|
||||
r = results[0]
|
||||
assert r.title == "Inception.2010.1080p"
|
||||
assert r.seeders == 500
|
||||
assert r.magnet.startswith("magnet:")
|
||||
assert r.info_hash == "abc"
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_empty_hits_returns_empty_list(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
assert client.search("nothing") == []
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_404_returns_empty_list(self, mock_post, client):
|
||||
mock_post.return_value = _http_error_response(404)
|
||||
assert client.search("nothing") == []
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_request_parameters(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client.search("Inception", limit=25)
|
||||
params = mock_post.call_args.kwargs["json"]
|
||||
assert params["query"] == "Inception"
|
||||
assert params["search_field"] == "title"
|
||||
assert params["order_by"] == "peers"
|
||||
assert params["order_direction"] == "desc"
|
||||
assert params["size"] == 25
|
||||
assert params["hide_unsafe"] is True
|
||||
assert params["hide_xxx"] is True
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_default_limit(self, mock_post, client):
|
||||
mock_post.return_value = _ok_response({"hits": []})
|
||||
client.search("x")
|
||||
assert mock_post.call_args.kwargs["json"]["size"] == 10
|
||||
|
||||
@patch("alfred.infrastructure.api.knaben.client.requests.post")
|
||||
def test_unexpected_exception_propagates(self, mock_post, client):
|
||||
# Anything other than KnabenNotFoundError bubbles up.
|
||||
mock_post.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(RuntimeError):
|
||||
client.search("x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _parse_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParseTorrent:
|
||||
def test_minimal(self, client):
|
||||
r = client._parse_torrent({})
|
||||
assert r.title == "Unknown"
|
||||
assert r.size == "Unknown"
|
||||
assert r.seeders == 0
|
||||
assert r.leechers == 0
|
||||
assert r.magnet == ""
|
||||
|
||||
def test_null_seeders_coerced_to_zero(self, client):
|
||||
r = client._parse_torrent({"seeders": None, "leechers": None})
|
||||
assert r.seeders == 0
|
||||
assert r.leechers == 0
|
||||
|
||||
def test_optional_fields_propagated(self, client):
|
||||
r = client._parse_torrent(
|
||||
{
|
||||
"title": "X",
|
||||
"size": "1 GB",
|
||||
"seeders": 10,
|
||||
"leechers": 2,
|
||||
"magnetUrl": "magnet:?",
|
||||
"hash": "h",
|
||||
"tracker": "t",
|
||||
"date": "d",
|
||||
"category": "c",
|
||||
}
|
||||
)
|
||||
assert r.info_hash == "h"
|
||||
assert r.tracker == "t"
|
||||
assert r.upload_date == "d"
|
||||
assert r.category == "c"
|
||||
@@ -0,0 +1,421 @@
|
||||
"""Tests for ``alfred.infrastructure.api.qbittorrent.client.QBittorrentClient``.
|
||||
|
||||
Exercises every public method against a ``MagicMock`` ``requests.Session``
|
||||
attached to the client. Auth state (``self._authenticated``) is asserted
|
||||
explicitly so the implicit auto-login behavior of mutation methods is
|
||||
covered.
|
||||
|
||||
Scope:
|
||||
|
||||
- ``TestInit`` — host/credentials wiring + Session attached.
|
||||
- ``TestMakeRequest`` — verb dispatch (GET/POST), JSON vs text fallback,
|
||||
error translation for timeout/403/5xx/RequestException, invalid verb.
|
||||
- ``TestLogin`` — happy path, non-"Ok." rejection, propagation from
|
||||
underlying API error.
|
||||
- ``TestGetTorrents`` — auto-login, non-list payload safety, per-item parse
|
||||
failures.
|
||||
- ``TestAddTorrent`` — magnet payload wiring, optional category/save_path,
|
||||
paused flag, unexpected response.
|
||||
- ``TestMutations`` — pause/resume/delete/recheck/set_location all wire the
|
||||
hash and propagate errors.
|
||||
- ``TestFindByName`` — exact match, case-insensitive match, save_path fallback,
|
||||
no match.
|
||||
- ``TestParseTorrent`` — progress percentage conversion, defaults.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from alfred.infrastructure.api.qbittorrent.client import QBittorrentClient
|
||||
from alfred.infrastructure.api.qbittorrent.dto import TorrentInfo
|
||||
from alfred.infrastructure.api.qbittorrent.exceptions import (
|
||||
QBittorrentAPIError,
|
||||
QBittorrentAuthError,
|
||||
)
|
||||
|
||||
|
||||
def _resp(body, *, status=200, json_decodable=True):
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.raise_for_status.return_value = None
|
||||
if json_decodable:
|
||||
r.json.return_value = body
|
||||
else:
|
||||
r.json.side_effect = ValueError("not json")
|
||||
r.text = body
|
||||
return r
|
||||
|
||||
|
||||
def _http_error(status_code):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
err = HTTPError(f"{status_code}")
|
||||
err.response = r
|
||||
r.raise_for_status.side_effect = err
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
c = QBittorrentClient(
|
||||
host="http://qbit.test:8080",
|
||||
username="admin",
|
||||
password="secret",
|
||||
timeout=5,
|
||||
)
|
||||
# Replace requests.Session with a MagicMock so we control responses
|
||||
c.session = MagicMock()
|
||||
return c
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Init #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_explicit_args(self):
|
||||
c = QBittorrentClient(
|
||||
host="http://x:1", username="u", password="p", timeout=99
|
||||
)
|
||||
assert c.host == "http://x:1"
|
||||
assert c.username == "u"
|
||||
assert c.password == "p"
|
||||
assert c.timeout == 99
|
||||
assert c._authenticated is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _make_request #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMakeRequest:
|
||||
def test_invalid_verb(self, client):
|
||||
with pytest.raises(ValueError, match="HTTP"):
|
||||
client._make_request("PATCH", "/api/v2/foo")
|
||||
|
||||
def test_get_returns_json(self, client):
|
||||
client.session.get.return_value = _resp({"k": "v"})
|
||||
out = client._make_request("GET", "/x", data={"a": 1})
|
||||
assert out == {"k": "v"}
|
||||
client.session.get.assert_called_once()
|
||||
|
||||
def test_post_returns_text_when_not_json(self, client):
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
out = client._make_request("POST", "/x", data={"a": 1})
|
||||
assert out == "Ok."
|
||||
|
||||
def test_timeout(self, client):
|
||||
client.session.get.side_effect = Timeout("slow")
|
||||
with pytest.raises(QBittorrentAPIError, match="timeout"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
def test_http_403_auth_error(self, client):
|
||||
client.session.post.return_value = _http_error(403)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client._make_request("POST", "/x")
|
||||
|
||||
def test_http_500_generic(self, client):
|
||||
client.session.get.return_value = _http_error(500)
|
||||
with pytest.raises(QBittorrentAPIError, match="500"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
def test_request_exception(self, client):
|
||||
client.session.get.side_effect = RequestException("net down")
|
||||
with pytest.raises(QBittorrentAPIError, match="connect"):
|
||||
client._make_request("GET", "/x")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Login #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLogin:
|
||||
def test_login_success(self, client):
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
assert client.login() is True
|
||||
assert client._authenticated is True
|
||||
|
||||
def test_login_wrong_credentials(self, client):
|
||||
client.session.post.return_value = _resp("Fails.", json_decodable=False)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client.login()
|
||||
assert client._authenticated is False
|
||||
|
||||
def test_login_api_error_translated_to_auth_error(self, client):
|
||||
client.session.post.return_value = _http_error(403)
|
||||
with pytest.raises(QBittorrentAuthError):
|
||||
client.login()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_torrents (auto-login behavior) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestGetTorrents:
|
||||
def test_auto_logs_in_then_fetches(self, client):
|
||||
# Order: 1) login POST, 2) torrents/info GET
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.session.get.return_value = _resp(
|
||||
[
|
||||
{
|
||||
"hash": "h1",
|
||||
"name": "Foo",
|
||||
"size": 100,
|
||||
"progress": 0.5,
|
||||
"state": "downloading",
|
||||
"dlspeed": 1024,
|
||||
"upspeed": 512,
|
||||
"eta": 60,
|
||||
"num_seeds": 5,
|
||||
"num_leechs": 1,
|
||||
"ratio": 0.1,
|
||||
"category": "movies",
|
||||
"save_path": "/dl",
|
||||
}
|
||||
]
|
||||
)
|
||||
torrents = client.get_torrents()
|
||||
assert len(torrents) == 1
|
||||
assert torrents[0].name == "Foo"
|
||||
assert torrents[0].progress == 50.0 # 0.5 → 50%
|
||||
assert client._authenticated is True
|
||||
|
||||
def test_non_list_returns_empty(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp({"oops": "bad"})
|
||||
assert client.get_torrents() == []
|
||||
|
||||
def test_filter_and_category_propagated(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp([])
|
||||
client.get_torrents(filter="completed", category="movies")
|
||||
params = client.session.get.call_args.kwargs["params"]
|
||||
assert params == {"filter": "completed", "category": "movies"}
|
||||
|
||||
def test_skips_unparseable_torrents(self, client):
|
||||
client._authenticated = True
|
||||
# _parse_torrent uses .get on every field with sensible defaults, so
|
||||
# malformed dicts almost never raise — patch the parser to force it.
|
||||
client.session.get.return_value = _resp([{"good": True}])
|
||||
with patch.object(client, "_parse_torrent", side_effect=Exception("nope")):
|
||||
assert client.get_torrents() == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# add_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAddTorrent:
|
||||
def test_add_success(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
assert client.add_torrent("magnet:?xt=foo") is True
|
||||
|
||||
def test_add_unexpected_response(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Fails.", json_decodable=False)
|
||||
assert client.add_torrent("magnet:?xt=foo") is False
|
||||
|
||||
def test_add_payload(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.add_torrent(
|
||||
"magnet:?xt=foo", category="movies", save_path="/dl", paused=True
|
||||
)
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["urls"] == "magnet:?xt=foo"
|
||||
assert payload["paused"] == "true"
|
||||
assert payload["category"] == "movies"
|
||||
assert payload["savepath"] == "/dl"
|
||||
|
||||
def test_paused_false_serialized(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
client.add_torrent("magnet:?xt=foo")
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["paused"] == "false"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Mutations (delete, pause, resume, recheck, set_location) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMutations:
|
||||
def _ok(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("Ok.", json_decodable=False)
|
||||
|
||||
def test_delete_success(self, client):
|
||||
self._ok(client)
|
||||
assert client.delete_torrent("hash1", delete_files=True) is True
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload["hashes"] == "hash1"
|
||||
assert payload["deleteFiles"] == "true"
|
||||
|
||||
def test_delete_no_files_default(self, client):
|
||||
self._ok(client)
|
||||
client.delete_torrent("hash1")
|
||||
assert (
|
||||
client.session.post.call_args.kwargs["data"]["deleteFiles"] == "false"
|
||||
)
|
||||
|
||||
def test_pause(self, client):
|
||||
self._ok(client)
|
||||
assert client.pause_torrent("hash1") is True
|
||||
|
||||
def test_resume(self, client):
|
||||
self._ok(client)
|
||||
assert client.resume_torrent("hash1") is True
|
||||
|
||||
def test_recheck(self, client):
|
||||
self._ok(client)
|
||||
assert client.recheck("hash1") is True
|
||||
|
||||
def test_set_location(self, client):
|
||||
self._ok(client)
|
||||
assert client.set_location("hash1", "/new/path") is True
|
||||
payload = client.session.post.call_args.kwargs["data"]
|
||||
assert payload == {"hashes": "hash1", "location": "/new/path"}
|
||||
|
||||
def test_mutation_propagates_api_error(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _http_error(500)
|
||||
with pytest.raises(QBittorrentAPIError):
|
||||
client.delete_torrent("hash1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# find_by_name #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _torrent_dict(name, save_path=None):
|
||||
return {
|
||||
"hash": "h",
|
||||
"name": name,
|
||||
"size": 1,
|
||||
"progress": 0.0,
|
||||
"state": "x",
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
"eta": 0,
|
||||
"num_seeds": 0,
|
||||
"num_leechs": 0,
|
||||
"ratio": 0.0,
|
||||
"save_path": save_path,
|
||||
}
|
||||
|
||||
|
||||
class TestFindByName:
|
||||
def test_exact_match(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("Foundation.S01"), _torrent_dict("Other")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert isinstance(result, TorrentInfo)
|
||||
assert result.name == "Foundation.S01"
|
||||
|
||||
def test_case_insensitive_match(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("foundation.s01")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert result is not None
|
||||
assert result.name == "foundation.s01"
|
||||
|
||||
def test_save_path_fallback(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp(
|
||||
[_torrent_dict("Different", save_path="/dl/Foundation.S01")]
|
||||
)
|
||||
result = client.find_by_name("Foundation.S01")
|
||||
assert result is not None
|
||||
assert result.save_path.endswith("Foundation.S01")
|
||||
|
||||
def test_no_match_returns_none(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp([_torrent_dict("nope")])
|
||||
assert client.find_by_name("Foundation.S01") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _parse_torrent #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestParseTorrent:
|
||||
def test_defaults(self, client):
|
||||
t = client._parse_torrent({})
|
||||
assert t.hash == ""
|
||||
assert t.name == "Unknown"
|
||||
assert t.progress == 0.0
|
||||
assert t.state == "unknown"
|
||||
|
||||
def test_progress_converted_to_percentage(self, client):
|
||||
t = client._parse_torrent({"progress": 0.75})
|
||||
assert t.progress == 75.0
|
||||
|
||||
def test_full_payload(self, client):
|
||||
t = client._parse_torrent(
|
||||
{
|
||||
"hash": "h",
|
||||
"name": "n",
|
||||
"size": 1024,
|
||||
"progress": 1.0,
|
||||
"state": "uploading",
|
||||
"dlspeed": 100,
|
||||
"upspeed": 50,
|
||||
"eta": 0,
|
||||
"num_seeds": 10,
|
||||
"num_leechs": 2,
|
||||
"ratio": 2.5,
|
||||
"category": "movies",
|
||||
"save_path": "/dl",
|
||||
}
|
||||
)
|
||||
assert t.progress == 100.0
|
||||
assert t.ratio == 2.5
|
||||
assert t.category == "movies"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# logout #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLogout:
|
||||
def test_logout_success(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.return_value = _resp("", json_decodable=False)
|
||||
assert client.logout() is True
|
||||
assert client._authenticated is False
|
||||
|
||||
def test_logout_swallows_errors(self, client):
|
||||
client._authenticated = True
|
||||
client.session.post.side_effect = RuntimeError("boom")
|
||||
# Per implementation, logout returns False instead of raising.
|
||||
assert client.logout() is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_torrent_properties #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestGetTorrentProperties:
|
||||
def test_properties_returned(self, client):
|
||||
client._authenticated = True
|
||||
client.session.get.return_value = _resp({"piece_size": 16384})
|
||||
assert client.get_torrent_properties("h")["piece_size"] == 16384
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Tests for ``alfred.infrastructure.api.tmdb.client.TMDBClient``.
|
||||
|
||||
Exercises the public surface without any real HTTP traffic:
|
||||
|
||||
- ``TestInit`` — configuration via constructor args vs. ``Settings``;
|
||||
enforcement of the ``api_key``/``base_url`` invariants.
|
||||
- ``TestMakeRequest`` — error translation for timeouts, HTTP 401/404/5xx,
|
||||
and generic ``RequestException``.
|
||||
- ``TestSearchMulti`` — query validation, success path, empty-results →
|
||||
``TMDBNotFoundError``.
|
||||
- ``TestGetExternalIds`` — ``media_type`` whitelist enforcement.
|
||||
- ``TestSearchMedia`` — happy path (movie/tv), media_type fallthrough to
|
||||
the next result, structural-validation error, and the case where
|
||||
external-ID resolution fails but the search still succeeds.
|
||||
- ``TestDetailsEndpoints`` — ``get_movie_details`` / ``get_tv_details``.
|
||||
- ``TestIsConfigured`` — reports ``True`` only when both api_key & url set.
|
||||
|
||||
All HTTP is mocked at ``alfred.infrastructure.api.tmdb.client.requests``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import HTTPError, RequestException, Timeout
|
||||
|
||||
from alfred.infrastructure.api.tmdb.client import TMDBClient
|
||||
from alfred.infrastructure.api.tmdb.dto import MediaResult
|
||||
from alfred.infrastructure.api.tmdb.exceptions import (
|
||||
TMDBAPIError,
|
||||
TMDBConfigurationError,
|
||||
TMDBNotFoundError,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ok_response(json_body):
|
||||
"""Return a Mock that mimics a successful requests.Response."""
|
||||
r = MagicMock()
|
||||
r.status_code = 200
|
||||
r.json.return_value = json_body
|
||||
r.raise_for_status.return_value = None
|
||||
return r
|
||||
|
||||
|
||||
def _http_error_response(status_code):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
err = HTTPError(f"{status_code}")
|
||||
err.response = r
|
||||
r.raise_for_status.side_effect = err
|
||||
return r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TMDBClient(
|
||||
api_key="fake-key",
|
||||
base_url="https://api.example.com/3",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Init / configuration #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestInit:
|
||||
def test_explicit_args_win_over_settings(self):
|
||||
c = TMDBClient(api_key="explicit", base_url="https://x", timeout=99)
|
||||
assert c.api_key == "explicit"
|
||||
assert c.base_url == "https://x"
|
||||
assert c.timeout == 99
|
||||
|
||||
def test_missing_api_key_raises(self):
|
||||
from alfred.settings import Settings
|
||||
|
||||
cfg = Settings(tmdb_api_key="", tmdb_base_url="https://x")
|
||||
with pytest.raises(TMDBConfigurationError, match="API key"):
|
||||
TMDBClient(api_key="", config=cfg)
|
||||
|
||||
def test_missing_base_url_raises(self):
|
||||
# Pass api_key but force empty base_url. Need a config with empty URL too.
|
||||
from alfred.settings import Settings
|
||||
|
||||
cfg = Settings(tmdb_api_key="fake", tmdb_base_url="")
|
||||
with pytest.raises(TMDBConfigurationError, match="base URL"):
|
||||
TMDBClient(config=cfg, base_url="")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _make_request — error translation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestMakeRequest:
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_timeout_translated(self, mock_get, client):
|
||||
mock_get.side_effect = Timeout("slow")
|
||||
with pytest.raises(TMDBAPIError, match="timeout"):
|
||||
client._make_request("/x")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_http_401_invalid_key(self, mock_get, client):
|
||||
mock_get.return_value = _http_error_response(401)
|
||||
with pytest.raises(TMDBAPIError, match="Invalid"):
|
||||
client._make_request("/x")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_http_404_not_found(self, mock_get, client):
|
||||
mock_get.return_value = _http_error_response(404)
|
||||
with pytest.raises(TMDBNotFoundError):
|
||||
client._make_request("/x")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_http_500_generic(self, mock_get, client):
|
||||
mock_get.return_value = _http_error_response(500)
|
||||
with pytest.raises(TMDBAPIError, match="500"):
|
||||
client._make_request("/x")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_request_exception_translated(self, mock_get, client):
|
||||
mock_get.side_effect = RequestException("network down")
|
||||
with pytest.raises(TMDBAPIError, match="connect"):
|
||||
client._make_request("/x")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_api_key_added_to_params(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"ok": True})
|
||||
client._make_request("/path", {"q": "foo"})
|
||||
called_kwargs = mock_get.call_args.kwargs
|
||||
assert called_kwargs["params"]["api_key"] == "fake-key"
|
||||
assert called_kwargs["params"]["q"] == "foo"
|
||||
assert called_kwargs["timeout"] == 5
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# search_multi #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSearchMulti:
|
||||
@pytest.mark.parametrize("bad", ["", None, 123])
|
||||
def test_invalid_query_raises_value_error(self, client, bad):
|
||||
with pytest.raises(ValueError):
|
||||
client.search_multi(bad)
|
||||
|
||||
def test_query_too_long(self, client):
|
||||
with pytest.raises(ValueError, match="too long"):
|
||||
client.search_multi("a" * 501)
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_success(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response(
|
||||
{"results": [{"id": 1, "media_type": "movie"}]}
|
||||
)
|
||||
results = client.search_multi("Inception")
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == 1
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_empty_results_raise_not_found(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"results": []})
|
||||
with pytest.raises(TMDBNotFoundError):
|
||||
client.search_multi("nothing")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# get_external_ids #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestGetExternalIds:
|
||||
def test_invalid_media_type(self, client):
|
||||
with pytest.raises(ValueError, match="media_type"):
|
||||
client.get_external_ids("game", 42)
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_movie(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"imdb_id": "tt1375666"})
|
||||
result = client.get_external_ids("movie", 27205)
|
||||
assert result["imdb_id"] == "tt1375666"
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_tv(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"imdb_id": "tt0903747"})
|
||||
result = client.get_external_ids("tv", 1396)
|
||||
assert result["imdb_id"] == "tt0903747"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# search_media (composite) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSearchMedia:
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_happy_path_movie(self, mock_get, client):
|
||||
# First call → /search/multi ; second → /movie/X/external_ids
|
||||
mock_get.side_effect = [
|
||||
_ok_response(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": 27205,
|
||||
"media_type": "movie",
|
||||
"title": "Inception",
|
||||
"overview": "...",
|
||||
"release_date": "2010-07-15",
|
||||
"poster_path": "/x.jpg",
|
||||
"vote_average": 8.4,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
_ok_response({"imdb_id": "tt1375666"}),
|
||||
]
|
||||
result = client.search_media("Inception")
|
||||
assert isinstance(result, MediaResult)
|
||||
assert result.title == "Inception"
|
||||
assert result.imdb_id == "tt1375666"
|
||||
assert result.media_type == "movie"
|
||||
assert result.vote_average == 8.4
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_tv_uses_name_field(self, mock_get, client):
|
||||
mock_get.side_effect = [
|
||||
_ok_response(
|
||||
{
|
||||
"results": [
|
||||
{"id": 1396, "media_type": "tv", "name": "Breaking Bad"}
|
||||
]
|
||||
}
|
||||
),
|
||||
_ok_response({"imdb_id": "tt0903747"}),
|
||||
]
|
||||
result = client.search_media("Breaking Bad")
|
||||
assert result.title == "Breaking Bad"
|
||||
assert result.media_type == "tv"
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_person_result_skipped_uses_next(self, mock_get, client):
|
||||
# First result is a person → falls through to second result.
|
||||
mock_get.side_effect = [
|
||||
_ok_response(
|
||||
{
|
||||
"results": [
|
||||
{"id": 1, "media_type": "person", "name": "X"},
|
||||
{"id": 2, "media_type": "movie", "title": "Y"},
|
||||
]
|
||||
}
|
||||
),
|
||||
_ok_response({"imdb_id": "tt7654321"}),
|
||||
]
|
||||
result = client.search_media("Y")
|
||||
assert result.title == "Y"
|
||||
assert result.media_type == "movie"
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_only_person_result_raises_not_found(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response(
|
||||
{"results": [{"id": 1, "media_type": "person", "name": "X"}]}
|
||||
)
|
||||
with pytest.raises(TMDBNotFoundError):
|
||||
client.search_media("X")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_malformed_top_result_raises(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response(
|
||||
{"results": [{"title": "no id or media_type"}]}
|
||||
)
|
||||
with pytest.raises(TMDBAPIError, match="Invalid"):
|
||||
client.search_media("X")
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_external_ids_failure_returns_result_without_imdb(self, mock_get, client):
|
||||
# Second call (external IDs) fails — the search should still succeed.
|
||||
mock_get.side_effect = [
|
||||
_ok_response(
|
||||
{"results": [{"id": 1, "media_type": "movie", "title": "X"}]}
|
||||
),
|
||||
Timeout("slow"),
|
||||
]
|
||||
result = client.search_media("X")
|
||||
assert result.imdb_id is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Details endpoints #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestDetailsEndpoints:
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_movie_details(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"id": 27205, "runtime": 148})
|
||||
result = client.get_movie_details(27205)
|
||||
assert result["runtime"] == 148
|
||||
|
||||
@patch("alfred.infrastructure.api.tmdb.client.requests.get")
|
||||
def test_tv_details(self, mock_get, client):
|
||||
mock_get.return_value = _ok_response({"id": 1396, "number_of_seasons": 5})
|
||||
result = client.get_tv_details(1396)
|
||||
assert result["number_of_seasons"] == 5
|
||||
|
||||
|
||||
class TestIsConfigured:
|
||||
def test_true_when_complete(self, client):
|
||||
assert client.is_configured() is True
|
||||
@@ -0,0 +1,384 @@
|
||||
"""Tests for the smaller ``alfred.infrastructure.filesystem`` helpers.
|
||||
|
||||
Covers four siblings of ``FileManager`` that had near-zero coverage:
|
||||
|
||||
- ``ffprobe.probe`` — wraps ``ffprobe`` JSON output into a ``MediaInfo``.
|
||||
- ``filesystem_operations.create_folder`` / ``move`` — thin
|
||||
``mkdir`` / ``mv`` wrappers returning dict-shaped responses.
|
||||
- ``organizer.MediaOrganizer`` — computes destination paths for movies
|
||||
and TV episodes; creates folders for them.
|
||||
- ``find_video.find_video_file`` — first-video lookup in a folder.
|
||||
|
||||
External commands (``ffprobe`` / ``mv``) are patched via ``subprocess.run``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from alfred.domain.movies.entities import Movie
|
||||
from alfred.domain.movies.value_objects import MovieTitle, Quality, ReleaseYear
|
||||
from alfred.domain.shared.value_objects import ImdbId
|
||||
from alfred.domain.tv_shows.entities import Episode, TVShow
|
||||
from alfred.domain.tv_shows.value_objects import (
|
||||
EpisodeNumber,
|
||||
SeasonNumber,
|
||||
ShowStatus,
|
||||
)
|
||||
from alfred.infrastructure.filesystem import ffprobe
|
||||
from alfred.infrastructure.filesystem.filesystem_operations import (
|
||||
create_folder,
|
||||
move,
|
||||
)
|
||||
from alfred.infrastructure.filesystem.find_video import find_video_file
|
||||
from alfred.infrastructure.filesystem.organizer import MediaOrganizer
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ffprobe.probe #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _ffprobe_result(returncode=0, stdout="{}", stderr="") -> MagicMock:
|
||||
return MagicMock(returncode=returncode, stdout=stdout, stderr=stderr)
|
||||
|
||||
|
||||
class TestFfprobe:
|
||||
def test_timeout_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="ffprobe", timeout=30),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_nonzero_returncode_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(returncode=1, stderr="not a media file"),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_invalid_json_returns_none(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout="not json {"),
|
||||
):
|
||||
assert ffprobe.probe(f) is None
|
||||
|
||||
def test_parses_format_duration_and_bitrate(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {"duration": "1234.5", "bit_rate": "5000000"},
|
||||
"streams": [],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info is not None
|
||||
assert info.duration_seconds == 1234.5
|
||||
assert info.bitrate_kbps == 5000 # bit_rate // 1000
|
||||
|
||||
def test_invalid_numeric_format_fields_skipped(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {"duration": "garbage", "bit_rate": "also-bad"},
|
||||
"streams": [],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info is not None
|
||||
assert info.duration_seconds is None
|
||||
assert info.bitrate_kbps is None
|
||||
|
||||
def test_parses_streams(self, tmp_path):
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {},
|
||||
"streams": [
|
||||
{
|
||||
"index": 0,
|
||||
"codec_type": "video",
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "ac3",
|
||||
"channels": 6,
|
||||
"channel_layout": "5.1",
|
||||
"tags": {"language": "eng"},
|
||||
"disposition": {"default": 1},
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"codec_type": "audio",
|
||||
"codec_name": "aac",
|
||||
"channels": 2,
|
||||
"tags": {"language": "fra"},
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"codec_type": "subtitle",
|
||||
"codec_name": "subrip",
|
||||
"tags": {"language": "fra"},
|
||||
"disposition": {"forced": 1},
|
||||
},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info.video_codec == "h264"
|
||||
assert info.width == 1920 and info.height == 1080
|
||||
assert len(info.audio_tracks) == 2
|
||||
eng = info.audio_tracks[0]
|
||||
assert eng.language == "eng"
|
||||
assert eng.is_default is True
|
||||
assert info.audio_tracks[1].is_default is False
|
||||
assert len(info.subtitle_tracks) == 1
|
||||
assert info.subtitle_tracks[0].is_forced is True
|
||||
|
||||
def test_first_video_stream_wins(self, tmp_path):
|
||||
# The implementation only fills video_codec on the FIRST video stream.
|
||||
f = tmp_path / "x.mkv"
|
||||
f.write_bytes(b"")
|
||||
payload = {
|
||||
"format": {},
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "width": 1920},
|
||||
{"codec_type": "video", "codec_name": "hevc", "width": 3840},
|
||||
],
|
||||
}
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.ffprobe.subprocess.run",
|
||||
return_value=_ffprobe_result(stdout=json.dumps(payload)),
|
||||
):
|
||||
info = ffprobe.probe(f)
|
||||
assert info.video_codec == "h264"
|
||||
assert info.width == 1920
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# filesystem_operations #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestCreateFolder:
|
||||
def test_creates_nested(self, tmp_path):
|
||||
target = tmp_path / "a" / "b" / "c"
|
||||
out = create_folder(str(target))
|
||||
assert out == {"status": "ok", "path": str(target)}
|
||||
assert target.is_dir()
|
||||
|
||||
def test_existing_is_ok(self, tmp_path):
|
||||
out = create_folder(str(tmp_path))
|
||||
assert out["status"] == "ok"
|
||||
|
||||
def test_os_error_wrapped(self, tmp_path):
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.filesystem_operations.Path.mkdir",
|
||||
side_effect=OSError("readonly fs"),
|
||||
):
|
||||
out = create_folder(str(tmp_path / "x"))
|
||||
assert out == {
|
||||
"status": "error",
|
||||
"error": "mkdir_failed",
|
||||
"message": "readonly fs",
|
||||
}
|
||||
|
||||
|
||||
class TestMove:
|
||||
def test_source_not_found(self, tmp_path):
|
||||
out = move(str(tmp_path / "ghost"), str(tmp_path / "dst"))
|
||||
assert out["status"] == "error"
|
||||
assert out["error"] == "source_not_found"
|
||||
|
||||
def test_destination_exists(self, tmp_path):
|
||||
src = tmp_path / "src"
|
||||
src.write_text("x")
|
||||
dst = tmp_path / "dst"
|
||||
dst.write_text("y")
|
||||
out = move(str(src), str(dst))
|
||||
assert out["error"] == "destination_exists"
|
||||
|
||||
def test_happy_path_returns_ok(self, tmp_path):
|
||||
src = tmp_path / "src"
|
||||
src.write_text("x")
|
||||
dst = tmp_path / "dst"
|
||||
# Patch subprocess so we don't actually shell out; pretend success.
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.filesystem_operations.subprocess.run",
|
||||
return_value=MagicMock(returncode=0, stderr=""),
|
||||
):
|
||||
out = move(str(src), str(dst))
|
||||
assert out == {"status": "ok", "source": str(src), "destination": str(dst)}
|
||||
|
||||
def test_mv_failure_wrapped(self, tmp_path):
|
||||
src = tmp_path / "src"
|
||||
src.write_text("x")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.filesystem_operations.subprocess.run",
|
||||
return_value=MagicMock(returncode=1, stderr="cross-device link\n"),
|
||||
):
|
||||
out = move(str(src), str(tmp_path / "dst"))
|
||||
assert out["error"] == "move_failed"
|
||||
assert out["message"] == "cross-device link"
|
||||
|
||||
def test_os_error_wrapped(self, tmp_path):
|
||||
src = tmp_path / "src"
|
||||
src.write_text("x")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.filesystem_operations.subprocess.run",
|
||||
side_effect=OSError("ENOSPC"),
|
||||
):
|
||||
out = move(str(src), str(tmp_path / "dst"))
|
||||
assert out["error"] == "move_failed"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# find_video #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFindVideo:
|
||||
def test_returns_file_directly_when_video(self, tmp_path):
|
||||
f = tmp_path / "Movie.mkv"
|
||||
f.write_bytes(b"")
|
||||
assert find_video_file(f) == f
|
||||
|
||||
def test_returns_none_when_file_is_not_video(self, tmp_path):
|
||||
f = tmp_path / "notes.txt"
|
||||
f.write_text("x")
|
||||
assert find_video_file(f) is None
|
||||
|
||||
def test_returns_none_when_folder_has_no_video(self, tmp_path):
|
||||
(tmp_path / "a.txt").write_text("x")
|
||||
assert find_video_file(tmp_path) is None
|
||||
|
||||
def test_returns_first_sorted_video(self, tmp_path):
|
||||
(tmp_path / "B.mkv").write_bytes(b"")
|
||||
(tmp_path / "A.mkv").write_bytes(b"")
|
||||
(tmp_path / "C.mkv").write_bytes(b"")
|
||||
found = find_video_file(tmp_path)
|
||||
assert found.name == "A.mkv"
|
||||
|
||||
def test_recurses_into_subfolders(self, tmp_path):
|
||||
sub = tmp_path / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "X.mkv").write_bytes(b"")
|
||||
found = find_video_file(tmp_path)
|
||||
assert found is not None and found.name == "X.mkv"
|
||||
|
||||
def test_case_insensitive_extension(self, tmp_path):
|
||||
f = tmp_path / "Movie.MKV"
|
||||
f.write_bytes(b"")
|
||||
assert find_video_file(f) == f
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MediaOrganizer #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _movie() -> Movie:
|
||||
return Movie(
|
||||
imdb_id=ImdbId("tt1375666"),
|
||||
title=MovieTitle("Inception"),
|
||||
release_year=ReleaseYear(2010),
|
||||
quality=Quality.HD,
|
||||
)
|
||||
|
||||
|
||||
def _show() -> TVShow:
|
||||
return TVShow(
|
||||
imdb_id=ImdbId("tt0773262"),
|
||||
title="Dexter",
|
||||
expected_seasons=8,
|
||||
status=ShowStatus.ENDED,
|
||||
)
|
||||
|
||||
|
||||
def _episode() -> Episode:
|
||||
return Episode(
|
||||
season_number=SeasonNumber(1),
|
||||
episode_number=EpisodeNumber(1),
|
||||
title="Dexter",
|
||||
)
|
||||
|
||||
|
||||
class TestMediaOrganizer:
|
||||
def test_get_movie_destination(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
out = org.get_movie_destination(_movie(), "source.mkv")
|
||||
# Path: /movies/<folder>/<filename>.mkv
|
||||
assert out.suffix == ".mkv"
|
||||
assert out.parent.name == _movie().get_folder_name()
|
||||
assert out.parent.parent == tmp_path / "movies"
|
||||
|
||||
def test_get_movie_destination_preserves_extension(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
out = org.get_movie_destination(_movie(), "source.MP4")
|
||||
assert out.suffix == ".MP4"
|
||||
|
||||
def test_get_episode_destination(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
out = org.get_episode_destination(_show(), _episode(), "raw.mkv")
|
||||
# Path: /tv/<show>/<season>/<episode>.mkv
|
||||
assert out.suffix == ".mkv"
|
||||
assert out.parent.parent.parent == tmp_path / "tv"
|
||||
assert out.parent.parent.name == _show().get_folder_name()
|
||||
|
||||
def test_create_movie_directory_creates_folder(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
assert org.create_movie_directory(_movie()) is True
|
||||
assert (tmp_path / "movies" / _movie().get_folder_name()).is_dir()
|
||||
|
||||
def test_create_movie_directory_already_exists_ok(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
org.create_movie_directory(_movie())
|
||||
# Second call is also fine (parents=True, exist_ok=True).
|
||||
assert org.create_movie_directory(_movie()) is True
|
||||
|
||||
def test_create_movie_directory_failure_returns_false(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.organizer.Path.mkdir",
|
||||
side_effect=PermissionError("denied"),
|
||||
):
|
||||
assert org.create_movie_directory(_movie()) is False
|
||||
|
||||
def test_create_episode_directory_creates_season_folder(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
assert org.create_episode_directory(_show(), 1) is True
|
||||
# /tv/<show>/<season> exists
|
||||
show_dir = tmp_path / "tv" / _show().get_folder_name()
|
||||
assert show_dir.is_dir()
|
||||
# At least one child (the season folder) was created.
|
||||
assert any(show_dir.iterdir())
|
||||
|
||||
def test_create_episode_directory_failure_returns_false(self, tmp_path):
|
||||
org = MediaOrganizer(tmp_path / "movies", tmp_path / "tv")
|
||||
with patch(
|
||||
"alfred.infrastructure.filesystem.organizer.Path.mkdir",
|
||||
side_effect=OSError("readonly"),
|
||||
):
|
||||
assert org.create_episode_directory(_show(), 1) is False
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Tests for ``alfred.infrastructure.metadata.store.MetadataStore``.
|
||||
|
||||
The store manages ``<release_root>/.alfred/metadata.yaml`` — a per-release
|
||||
sidecar with parse, probe, TMDB, pattern, and subtitle-history sections.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestIdentityAndExists`` — accessors + ``exists()``.
|
||||
- ``TestLoad`` — empty/missing/corrupt YAML returns ``{}``.
|
||||
- ``TestSave`` — atomic write creates ``.alfred/`` + temp file is gone.
|
||||
- ``TestUpdateSection`` — replaces the section + adds ``_updated_at``.
|
||||
- ``TestUpdateParse/Probe/Tmdb`` — strips ``status`` from payload;
|
||||
TMDB promotes ``imdb_id`` / ``tmdb_id`` / ``media_type`` / ``title``
|
||||
to the top level.
|
||||
- ``TestPattern`` — ``confirmed_pattern`` returns the id only when flag
|
||||
is set; ``mark_pattern_confirmed`` preserves pre-existing keys.
|
||||
- ``TestSubtitleHistory`` — append + release-group dedup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from alfred.infrastructure.metadata.store import MetadataStore
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Identity / exists #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestIdentityAndExists:
|
||||
def test_paths(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
assert s.release_root == tmp_path
|
||||
assert s.metadata_path == tmp_path / ".alfred" / "metadata.yaml"
|
||||
|
||||
def test_exists_false_initially(self, tmp_path):
|
||||
assert MetadataStore(tmp_path).exists() is False
|
||||
|
||||
def test_exists_after_save(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"a": 1})
|
||||
assert s.exists() is True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Load #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert MetadataStore(tmp_path).load() == {}
|
||||
|
||||
def test_empty_yaml_returns_empty(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
(tmp_path / ".alfred").mkdir()
|
||||
(tmp_path / ".alfred" / "metadata.yaml").write_text("")
|
||||
assert s.load() == {}
|
||||
|
||||
def test_corrupt_yaml_returns_empty(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
(tmp_path / ".alfred").mkdir()
|
||||
(tmp_path / ".alfred" / "metadata.yaml").write_text("not: : valid: yaml: [")
|
||||
# Logged warning, but never raises.
|
||||
assert s.load() == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Save #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSave:
|
||||
def test_creates_alfred_dir(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"a": 1})
|
||||
assert (tmp_path / ".alfred").is_dir()
|
||||
assert (tmp_path / ".alfred" / "metadata.yaml").is_file()
|
||||
|
||||
def test_yaml_roundtrip(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
data = {"a": 1, "b": ["x", "y"], "c": {"nested": True}}
|
||||
s.save(data)
|
||||
loaded = yaml.safe_load((tmp_path / ".alfred" / "metadata.yaml").read_text())
|
||||
assert loaded == data
|
||||
# And via the store API.
|
||||
assert s.load() == data
|
||||
|
||||
def test_temp_file_cleaned_up(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"a": 1})
|
||||
# No stale .tmp left around.
|
||||
assert not (tmp_path / ".alfred" / "metadata.yaml.tmp").exists()
|
||||
|
||||
def test_unicode_preserved(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"title": "Amélie"})
|
||||
assert s.load() == {"title": "Amélie"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# update_section #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestUpdateSection:
|
||||
def test_adds_section_with_timestamp(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_section("parse", {"title": "X"})
|
||||
data = s.load()
|
||||
assert data["parse"]["title"] == "X"
|
||||
assert "_updated_at" in data["parse"]
|
||||
# ISO-8601 with TZ offset
|
||||
assert "T" in data["parse"]["_updated_at"]
|
||||
|
||||
def test_section_replaced_wholesale(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_section("parse", {"a": 1, "b": 2})
|
||||
s.update_section("parse", {"c": 3})
|
||||
data = s.load()
|
||||
assert "a" not in data["parse"]
|
||||
assert data["parse"]["c"] == 3
|
||||
|
||||
def test_preserves_other_sections(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_section("parse", {"a": 1})
|
||||
s.update_section("probe", {"b": 2})
|
||||
data = s.load()
|
||||
assert data["parse"]["a"] == 1
|
||||
assert data["probe"]["b"] == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# update_parse / update_probe #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestUpdateParseAndProbe:
|
||||
def test_update_parse_strips_status(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_parse({"status": "ok", "title": "X", "year": 2020})
|
||||
data = s.load()
|
||||
assert "status" not in data["parse"]
|
||||
assert data["parse"]["title"] == "X"
|
||||
assert data["parse"]["year"] == 2020
|
||||
|
||||
def test_update_probe_strips_status(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_probe({"status": "ok", "resolution": "1080p"})
|
||||
assert s.load()["probe"]["resolution"] == "1080p"
|
||||
assert "status" not in s.load()["probe"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# update_tmdb #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestUpdateTmdb:
|
||||
def test_promotes_identity_to_top_level(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_tmdb({
|
||||
"status": "ok",
|
||||
"imdb_id": "tt1375666",
|
||||
"tmdb_id": 27205,
|
||||
"media_type": "movie",
|
||||
"title": "Inception",
|
||||
})
|
||||
data = s.load()
|
||||
assert data["imdb_id"] == "tt1375666"
|
||||
assert data["tmdb_id"] == 27205
|
||||
assert data["media_type"] == "movie"
|
||||
assert data["title"] == "Inception"
|
||||
# And the full block is still under tmdb
|
||||
assert data["tmdb"]["imdb_id"] == "tt1375666"
|
||||
|
||||
def test_does_not_overwrite_existing_title(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
# Pre-existing title (e.g. from earlier confirmation).
|
||||
s.save({"title": "Old Title"})
|
||||
s.update_tmdb({"title": "New Title", "imdb_id": "tt1"})
|
||||
data = s.load()
|
||||
# setdefault means the existing title wins.
|
||||
assert data["title"] == "Old Title"
|
||||
assert data["imdb_id"] == "tt1"
|
||||
|
||||
def test_none_values_not_promoted(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.update_tmdb({"imdb_id": None, "tmdb_id": 27205, "media_type": None})
|
||||
data = s.load()
|
||||
assert "imdb_id" not in data
|
||||
assert data["tmdb_id"] == 27205
|
||||
assert "media_type" not in data
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pattern #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestPattern:
|
||||
def test_confirmed_pattern_empty_when_missing(self, tmp_path):
|
||||
assert MetadataStore(tmp_path).confirmed_pattern() is None
|
||||
|
||||
def test_confirmed_pattern_only_when_flag_true(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"detected_pattern": "adjacent", "pattern_confirmed": False})
|
||||
assert s.confirmed_pattern() is None
|
||||
s.save({"detected_pattern": "adjacent", "pattern_confirmed": True})
|
||||
assert s.confirmed_pattern() == "adjacent"
|
||||
|
||||
def test_mark_pattern_confirmed_sets_flag(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.mark_pattern_confirmed("subs_flat")
|
||||
data = s.load()
|
||||
assert data["detected_pattern"] == "subs_flat"
|
||||
assert data["pattern_confirmed"] is True
|
||||
|
||||
def test_mark_pattern_preserves_media_info(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.mark_pattern_confirmed(
|
||||
"adjacent",
|
||||
media_info={
|
||||
"media_type": "movie",
|
||||
"imdb_id": "tt1",
|
||||
"title": "Foo",
|
||||
},
|
||||
)
|
||||
data = s.load()
|
||||
assert data["media_type"] == "movie"
|
||||
assert data["imdb_id"] == "tt1"
|
||||
assert data["title"] == "Foo"
|
||||
|
||||
def test_mark_pattern_does_not_overwrite_existing_identity(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.save({"title": "Existing", "imdb_id": "tt_old"})
|
||||
s.mark_pattern_confirmed(
|
||||
"adjacent",
|
||||
media_info={"imdb_id": "tt_new", "title": "New"},
|
||||
)
|
||||
data = s.load()
|
||||
# setdefault on existing keys → old values win.
|
||||
assert data["title"] == "Existing"
|
||||
assert data["imdb_id"] == "tt_old"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Subtitle history #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSubtitleHistory:
|
||||
def test_initially_empty(self, tmp_path):
|
||||
assert MetadataStore(tmp_path).subtitle_history() == []
|
||||
|
||||
def test_append_one(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.append_subtitle_history_entry({"tracks": 2, "release_group": "GRP"})
|
||||
hist = s.subtitle_history()
|
||||
assert len(hist) == 1
|
||||
assert hist[0]["tracks"] == 2
|
||||
|
||||
def test_release_group_recorded_once(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.append_subtitle_history_entry({"release_group": "GRP"})
|
||||
s.append_subtitle_history_entry({"release_group": "GRP"})
|
||||
s.append_subtitle_history_entry({"release_group": "OTHER"})
|
||||
groups = s.load()["release_groups"]
|
||||
assert groups == ["GRP", "OTHER"]
|
||||
|
||||
def test_no_release_group_does_not_create_groups_list(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
s.append_subtitle_history_entry({"tracks": 0})
|
||||
assert "release_groups" not in s.load()
|
||||
|
||||
def test_multiple_entries_preserved_in_order(self, tmp_path):
|
||||
s = MetadataStore(tmp_path)
|
||||
for i in range(3):
|
||||
s.append_subtitle_history_entry({"i": i})
|
||||
assert [e["i"] for e in s.subtitle_history()] == [0, 1, 2]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for ``alfred.infrastructure.subtitle.rule_repository.RuleSetRepository``.
|
||||
|
||||
Loads/saves the SubtitleRuleSet inheritance chain from ``.alfred/`` YAML.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestLoad`` — no files → ``global_default``; rules.yaml override applied
|
||||
on top; release_groups/{NAME}.yaml override applied;
|
||||
SubtitlePreferences seeds the base when provided; full 3-level chain.
|
||||
- ``TestFilterOverride`` — unknown keys discarded.
|
||||
- ``TestSaveLocal`` — atomic write, merges with existing, creates .alfred/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from alfred.infrastructure.persistence.memory.ltm.components.subtitle_preferences import (
|
||||
SubtitlePreferences,
|
||||
)
|
||||
from alfred.infrastructure.subtitle.rule_repository import (
|
||||
RuleSetRepository,
|
||||
_filter_override,
|
||||
)
|
||||
|
||||
|
||||
def _write(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _filter_override #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestFilterOverride:
|
||||
def test_keeps_only_valid_keys(self):
|
||||
out = _filter_override({
|
||||
"languages": ["fra"],
|
||||
"formats": ["srt"],
|
||||
"types": ["standard"],
|
||||
"format_priority": ["srt"],
|
||||
"min_confidence": 0.8,
|
||||
"unknown_key": "ignored",
|
||||
"another": 42,
|
||||
})
|
||||
assert set(out) == {
|
||||
"languages", "formats", "types", "format_priority", "min_confidence"
|
||||
}
|
||||
assert "unknown_key" not in out
|
||||
|
||||
def test_empty(self):
|
||||
assert _filter_override({}) == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# load #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestLoad:
|
||||
def test_no_files_returns_global_default(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rs = repo.load()
|
||||
# Should resolve cleanly using the hardcoded defaults.
|
||||
rules = rs.resolve()
|
||||
assert rules.preferred_languages # non-empty
|
||||
assert rules.min_confidence > 0
|
||||
|
||||
def test_subtitle_preferences_override_base(self, tmp_path):
|
||||
prefs = SubtitlePreferences(
|
||||
languages=["jpn"], formats=["ass"], types=["standard"]
|
||||
)
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rules = repo.load(subtitle_preferences=prefs).resolve()
|
||||
assert rules.preferred_languages == ["jpn"]
|
||||
assert rules.preferred_formats == ["ass"]
|
||||
assert rules.allowed_types == ["standard"]
|
||||
|
||||
def test_local_rules_yaml_applied(self, tmp_path):
|
||||
_write(
|
||||
tmp_path / ".alfred" / "rules.yaml",
|
||||
{"override": {"languages": ["spa"], "min_confidence": 0.95}},
|
||||
)
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rules = repo.load().resolve()
|
||||
assert rules.preferred_languages == ["spa"]
|
||||
assert rules.min_confidence == 0.95
|
||||
|
||||
def test_release_group_override_applied(self, tmp_path):
|
||||
_write(
|
||||
tmp_path / ".alfred" / "release_groups" / "KONTRAST.yaml",
|
||||
{"override": {"format_priority": ["ass", "srt"]}},
|
||||
)
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rules = repo.load(release_group="KONTRAST").resolve()
|
||||
assert rules.format_priority == ["ass", "srt"]
|
||||
|
||||
def test_full_three_level_chain(self, tmp_path):
|
||||
# Base: prefs sets languages=["jpn"]
|
||||
prefs = SubtitlePreferences(languages=["jpn"])
|
||||
# Group: overrides format_priority
|
||||
_write(
|
||||
tmp_path / ".alfred" / "release_groups" / "GRP.yaml",
|
||||
{"override": {"format_priority": ["ass"]}},
|
||||
)
|
||||
# Local: overrides min_confidence
|
||||
_write(
|
||||
tmp_path / ".alfred" / "rules.yaml",
|
||||
{"override": {"min_confidence": 0.99}},
|
||||
)
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rules = repo.load(
|
||||
release_group="GRP", subtitle_preferences=prefs
|
||||
).resolve()
|
||||
# All three levels visible — local overrides on top
|
||||
assert rules.preferred_languages == ["jpn"]
|
||||
assert rules.format_priority == ["ass"]
|
||||
assert rules.min_confidence == 0.99
|
||||
|
||||
def test_release_group_yaml_without_override_section_ignored(self, tmp_path):
|
||||
_write(
|
||||
tmp_path / ".alfred" / "release_groups" / "GRP.yaml",
|
||||
{"name": "GRP"}, # no 'override' key
|
||||
)
|
||||
# Must not crash and must not introduce an intermediate node.
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rs = repo.load(release_group="GRP")
|
||||
# No extra rule set was created → it's still the global default.
|
||||
assert rs.scope.level == "global"
|
||||
|
||||
def test_missing_release_group_file_silently_ignored(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
rs = repo.load(release_group="DOES_NOT_EXIST")
|
||||
assert rs.scope.level == "global"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# save_local #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestSaveLocal:
|
||||
def test_creates_file(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
repo.save_local({"languages": ["spa"]})
|
||||
path = tmp_path / ".alfred" / "rules.yaml"
|
||||
assert path.is_file()
|
||||
loaded = yaml.safe_load(path.read_text())
|
||||
assert loaded == {"override": {"languages": ["spa"]}}
|
||||
|
||||
def test_merges_with_existing(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
repo.save_local({"languages": ["spa"]})
|
||||
repo.save_local({"min_confidence": 0.8})
|
||||
loaded = yaml.safe_load((tmp_path / ".alfred" / "rules.yaml").read_text())
|
||||
assert loaded["override"]["languages"] == ["spa"]
|
||||
assert loaded["override"]["min_confidence"] == 0.8
|
||||
|
||||
def test_overwrites_existing_key(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
repo.save_local({"languages": ["spa"]})
|
||||
repo.save_local({"languages": ["jpn"]})
|
||||
loaded = yaml.safe_load((tmp_path / ".alfred" / "rules.yaml").read_text())
|
||||
assert loaded["override"]["languages"] == ["jpn"]
|
||||
|
||||
def test_temp_file_cleaned_up(self, tmp_path):
|
||||
repo = RuleSetRepository(tmp_path)
|
||||
repo.save_local({"languages": ["spa"]})
|
||||
# No stale .tmp file
|
||||
assert not (tmp_path / ".alfred" / "rules.yaml.tmp").exists()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Tests for ``alfred.infrastructure.subtitle.metadata_store.SubtitleMetadataStore``.
|
||||
|
||||
Subtitle-pipeline view over a per-release ``.alfred/metadata.yaml``.
|
||||
|
||||
Coverage:
|
||||
|
||||
- ``TestPatternDelegation`` — ``confirmed_pattern`` / ``mark_pattern_confirmed``
|
||||
delegate to the generic store.
|
||||
- ``TestAppendHistory`` — entry shape (placed_at, release_group, tracks),
|
||||
per-track fields (language/type/format/source_file/placed_as/confidence),
|
||||
type inference from filename pieces (en.sdh.srt → "sdh"),
|
||||
empty pairs → no-op, season/episode included only when given.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from alfred.domain.subtitles.entities import SubtitleCandidate
|
||||
from alfred.domain.subtitles.services.placer import PlacedTrack
|
||||
from alfred.domain.subtitles.value_objects import (
|
||||
SubtitleFormat,
|
||||
SubtitleLanguage,
|
||||
SubtitleType,
|
||||
)
|
||||
from alfred.infrastructure.subtitle.metadata_store import SubtitleMetadataStore
|
||||
|
||||
SRT = SubtitleFormat(id="srt", extensions=[".srt"])
|
||||
FRA = SubtitleLanguage(code="fra", tokens=["fr"])
|
||||
ENG = SubtitleLanguage(code="eng", tokens=["en"])
|
||||
|
||||
|
||||
def _track(lang=FRA, *, embedded: bool = False, confidence: float = 0.92) -> SubtitleCandidate:
|
||||
return SubtitleCandidate(
|
||||
language=lang,
|
||||
format=SRT,
|
||||
subtitle_type=SubtitleType.STANDARD,
|
||||
is_embedded=embedded,
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
def _placed(src_name: str, dest_name: str, dest_dir: Path) -> PlacedTrack:
|
||||
return PlacedTrack(
|
||||
source=Path("/in") / src_name,
|
||||
destination=dest_dir / dest_name,
|
||||
filename=dest_name,
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pattern delegation #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestPatternDelegation:
|
||||
def test_confirmed_pattern_initially_none(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
assert s.confirmed_pattern() is None
|
||||
|
||||
def test_mark_then_read_back(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
s.mark_pattern_confirmed("adjacent", {"media_type": "movie"})
|
||||
assert s.confirmed_pattern() == "adjacent"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# append_history #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class TestAppendHistory:
|
||||
def test_empty_pairs_is_noop(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
s.append_history([])
|
||||
assert s.history() == []
|
||||
# No .alfred dir written either.
|
||||
assert not (tmp_path / ".alfred").exists()
|
||||
|
||||
def test_single_entry_shape(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
# Two-segment filename (after rsplit on '.', 2) → falls into the
|
||||
# "standard" branch only when len(parts) != 3. Here we pass a 2-part
|
||||
# name like ``moviesrt`` with one extension piece via an artificial
|
||||
# case — easier: use a "Movie.srt" simulation.
|
||||
p = _placed("input.srt", "Movie.srt", tmp_path)
|
||||
t = _track(lang=FRA, confidence=0.875)
|
||||
s.append_history([(p, t)], release_group="GRP")
|
||||
hist = s.history()
|
||||
assert len(hist) == 1
|
||||
entry = hist[0]
|
||||
assert entry["release_group"] == "GRP"
|
||||
assert "placed_at" in entry
|
||||
assert entry["tracks"] == [
|
||||
{
|
||||
"language": "fra",
|
||||
"type": "standard", # 2-part filename → default
|
||||
"format": "srt",
|
||||
"is_embedded": False,
|
||||
"source_file": "input.srt",
|
||||
"placed_as": "Movie.srt",
|
||||
"confidence": 0.875,
|
||||
}
|
||||
]
|
||||
|
||||
def test_type_inferred_from_filename_segments(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
# The implementation uses ``filename.rsplit('.', 2)`` and reads
|
||||
# ``parts[1]``. For "Show.eng.sdh.srt" → ["Show.eng", "sdh", "srt"]
|
||||
# → type="sdh". For "Show.fra.srt" → ["Show", "fra", "srt"]
|
||||
# → type="fra" (a known quirk — language token leaks into the type
|
||||
# slot when the filename has exactly three rsplit pieces).
|
||||
p_sdh = _placed("a.srt", "Show.eng.sdh.srt", tmp_path)
|
||||
p_forced = _placed("b.srt", "Show.fra.forced.srt", tmp_path)
|
||||
p_two_part = _placed("c.srt", "Show.srt", tmp_path) # < 3 → "standard"
|
||||
s.append_history(
|
||||
[(p_sdh, _track(ENG)), (p_forced, _track(FRA)), (p_two_part, _track(FRA))],
|
||||
)
|
||||
tracks = s.history()[0]["tracks"]
|
||||
assert tracks[0]["type"] == "sdh"
|
||||
assert tracks[1]["type"] == "forced"
|
||||
assert tracks[2]["type"] == "standard"
|
||||
|
||||
def test_unknown_language_when_track_has_no_language(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("a.srt", "Show.und.srt", tmp_path)
|
||||
t = _track(lang=None)
|
||||
s.append_history([(p, t)])
|
||||
assert s.history()[0]["tracks"][0]["language"] == "unknown"
|
||||
|
||||
def test_embedded_flag_propagated(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("x.srt", "Show.fra.srt", tmp_path)
|
||||
t = _track(embedded=True)
|
||||
s.append_history([(p, t)])
|
||||
assert s.history()[0]["tracks"][0]["is_embedded"] is True
|
||||
|
||||
def test_season_and_episode_present_when_given(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("x.srt", "Show.S01E03.fra.srt", tmp_path)
|
||||
s.append_history([(p, _track())], season=1, episode=3)
|
||||
entry = s.history()[0]
|
||||
assert entry["season"] == 1
|
||||
assert entry["episode"] == 3
|
||||
|
||||
def test_season_and_episode_absent_when_omitted(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("x.srt", "Movie.fra.srt", tmp_path)
|
||||
s.append_history([(p, _track())])
|
||||
entry = s.history()[0]
|
||||
assert "season" not in entry
|
||||
assert "episode" not in entry
|
||||
|
||||
def test_confidence_rounded_to_3_decimals(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("x.srt", "X.fra.srt", tmp_path)
|
||||
t = _track(confidence=0.123456789)
|
||||
s.append_history([(p, t)])
|
||||
assert s.history()[0]["tracks"][0]["confidence"] == 0.123
|
||||
|
||||
def test_release_group_appended_to_top_level_groups(self, tmp_path):
|
||||
s = SubtitleMetadataStore(tmp_path)
|
||||
p = _placed("x.srt", "X.fra.srt", tmp_path)
|
||||
s.append_history([(p, _track())], release_group="GRP1")
|
||||
s.append_history([(p, _track())], release_group="GRP1") # dup
|
||||
s.append_history([(p, _track())], release_group="GRP2")
|
||||
# Use the underlying MetadataStore by reading the YAML directly.
|
||||
from alfred.infrastructure.metadata.store import MetadataStore
|
||||
|
||||
groups = MetadataStore(tmp_path).load().get("release_groups", [])
|
||||
assert groups == ["GRP1", "GRP2"]
|
||||
Reference in New Issue
Block a user