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:
2026-05-17 23:38:00 +02:00
parent ba6f016d49
commit e07c9ec77b
99 changed files with 8833 additions and 6533 deletions
@@ -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
+281
View File
@@ -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"]