feat(services): add key resolution for orphaned anime folders

- Add key_resolution_service.py to resolve provider keys for folders without key/data files
- Search anime provider and match folder names (case-insensitive, exact match required)
- Only save to DB if exactly one match found; otherwise skip
- Add comprehensive unit tests (28 tests)
- Integrate into scheduler_service after nfo_repair scan
- Update ARCHITECTURE.md documentation
This commit is contained in:
2026-06-01 20:43:13 +02:00
parent 6dfb24de7e
commit c58b42dfa5
6 changed files with 565 additions and 12 deletions

View File

@@ -0,0 +1,218 @@
"""Unit tests for key_resolution_service."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.server.services.key_resolution_service import (
_extract_key_from_link,
_extract_year_from_folder,
_normalize_for_comparison,
_strip_year_from_folder,
resolve_key_for_folder,
)
class TestStripYearFromFolder:
"""Tests for _strip_year_from_folder."""
def test_removes_year_suffix(self):
assert _strip_year_from_folder("Rent-A-Girlfriend (2020)") == "Rent-A-Girlfriend"
def test_removes_year_suffix_with_spaces(self):
assert _strip_year_from_folder("Attack on Titan (2013)") == "Attack on Titan"
def test_no_year_returns_original(self):
assert _strip_year_from_folder("Naruto") == "Naruto"
def test_year_in_middle_not_stripped(self):
assert _strip_year_from_folder("2024 Anime (2024)") == "2024 Anime"
def test_empty_string(self):
assert _strip_year_from_folder("") == ""
def test_only_year(self):
assert _strip_year_from_folder("(2020)") == ""
class TestExtractYearFromFolder:
"""Tests for _extract_year_from_folder."""
def test_extracts_year(self):
assert _extract_year_from_folder("Rent-A-Girlfriend (2020)") == 2020
def test_no_year_returns_none(self):
assert _extract_year_from_folder("Naruto") is None
def test_year_in_middle_not_extracted(self):
# Only trailing year is extracted
assert _extract_year_from_folder("2024 Anime") is None
class TestExtractKeyFromLink:
"""Tests for _extract_key_from_link."""
def test_relative_link(self):
assert _extract_key_from_link("/anime/stream/rent-a-girlfriend") == "rent-a-girlfriend"
def test_full_url(self):
assert (
_extract_key_from_link("https://aniworld.to/anime/stream/attack-on-titan")
== "attack-on-titan"
)
def test_link_with_trailing_slash(self):
assert _extract_key_from_link("/anime/stream/naruto/") == "naruto"
def test_empty_link(self):
assert _extract_key_from_link("") is None
def test_none_link(self):
assert _extract_key_from_link(None) is None
def test_slug_only(self):
assert _extract_key_from_link("one-piece") == "one-piece"
class TestNormalizeForComparison:
"""Tests for _normalize_for_comparison."""
def test_case_insensitive(self):
assert _normalize_for_comparison("Rent-A-Girlfriend") == _normalize_for_comparison(
"rent-a-girlfriend"
)
def test_strips_whitespace(self):
assert _normalize_for_comparison(" Naruto ") == "naruto"
def test_normalizes_dashes(self):
assert _normalize_for_comparison("Rent-A-Girlfriend") == "rent a girlfriend"
def test_collapses_spaces(self):
assert _normalize_for_comparison("Attack on Titan") == "attack on titan"
class TestResolveKeyForFolder:
"""Tests for resolve_key_for_folder."""
@pytest.mark.asyncio
async def test_single_exact_match_returns_key(self):
"""When provider returns exactly one exact-name match, key is resolved."""
search_results = [
{"link": "/anime/stream/rent-a-girlfriend", "title": "Rent-A-Girlfriend"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
assert key == "rent-a-girlfriend"
@pytest.mark.asyncio
async def test_no_results_returns_none(self):
"""When provider returns no results, returns None."""
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=[],
):
key = await resolve_key_for_folder("Unknown Anime (2020)")
assert key is None
@pytest.mark.asyncio
async def test_multiple_exact_matches_returns_none(self):
"""When multiple results match the same name exactly, returns None."""
search_results = [
{"link": "/anime/stream/my-anime", "title": "My Anime"},
{"link": "/anime/stream/my-anime-2", "title": "My Anime"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("My Anime (2022)")
assert key is None
@pytest.mark.asyncio
async def test_no_exact_match_returns_none(self):
"""When results exist but none match the folder name, returns None."""
search_results = [
{"link": "/anime/stream/rent-a-girlfriend-2", "title": "Rent-A-Girlfriend 2nd Season"},
{"link": "/anime/stream/rent-a-girlfriend-3", "title": "Rent-A-Girlfriend 3rd Season"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Rent-A-Girlfriend (2020)")
assert key is None
@pytest.mark.asyncio
async def test_case_insensitive_match(self):
"""Matching is case-insensitive."""
search_results = [
{"link": "/anime/stream/naruto", "title": "NARUTO"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Naruto (2002)")
assert key == "naruto"
@pytest.mark.asyncio
async def test_provider_error_returns_none(self):
"""When provider search raises an exception, returns None gracefully."""
with patch(
"src.server.services.key_resolution_service._search_provider",
side_effect=RuntimeError("Network error"),
):
key = await resolve_key_for_folder("Some Anime (2020)")
assert key is None
@pytest.mark.asyncio
async def test_result_with_name_field_instead_of_title(self):
"""Search results using 'name' field instead of 'title' work."""
search_results = [
{"link": "/anime/stream/one-piece", "name": "One Piece"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("One Piece (1999)")
assert key == "one-piece"
@pytest.mark.asyncio
async def test_folder_without_year(self):
"""Folders without year suffix still work."""
search_results = [
{"link": "/anime/stream/naruto", "title": "Naruto"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Naruto")
assert key == "naruto"
@pytest.mark.asyncio
async def test_exact_match_among_partial_matches(self):
"""Only exact matches count, partial matches are ignored."""
search_results = [
{"link": "/anime/stream/dororo", "title": "Dororo"},
{"link": "/anime/stream/dororo-to-hyakkimaru", "title": "Dororo to Hyakkimaru"},
]
with patch(
"src.server.services.key_resolution_service._search_provider",
return_value=search_results,
):
key = await resolve_key_for_folder("Dororo (2019)")
assert key == "dororo"