diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 2a31d8b..188f901 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Publish image on: push: - branches: [main] + branches: [main, develop] tags: ["v*"] paths-ignore: ["**.md", "docs/**", "LICENSE"] workflow_dispatch: @@ -36,6 +36,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/develop' }} type=sha,prefix=sha- type=semver,pattern={{version}} diff --git a/affiche-backend/affiche/alembic/versions/a8e4c2f6b9d1_add_library_item_title_is_filename.py b/affiche-backend/affiche/alembic/versions/a8e4c2f6b9d1_add_library_item_title_is_filename.py new file mode 100644 index 0000000..5d1c1bc --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/a8e4c2f6b9d1_add_library_item_title_is_filename.py @@ -0,0 +1,16 @@ +from alembic import op +import sqlalchemy as sa + +revision = 'a8e4c2f6b9d1' +down_revision = 'c4f8b2e6a1d3' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('library_item', + sa.Column('title_is_filename', sa.Boolean(), nullable=False, + server_default=sa.false())) + +def downgrade() -> None: + with op.batch_alter_table('library_item') as batch_op: + batch_op.drop_column('title_is_filename') diff --git a/affiche-backend/affiche/alembic/versions/b9f3d5a7c2e4_title_mismatch_replaces_filename_guess.py b/affiche-backend/affiche/alembic/versions/b9f3d5a7c2e4_title_mismatch_replaces_filename_guess.py new file mode 100644 index 0000000..752af3c --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/b9f3d5a7c2e4_title_mismatch_replaces_filename_guess.py @@ -0,0 +1,23 @@ +from alembic import op +import sqlalchemy as sa + +revision = 'b9f3d5a7c2e4' +down_revision = 'a8e4c2f6b9d1' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('library_item', + sa.Column('title_mismatched', sa.Boolean(), nullable=False, + server_default=sa.false())) + op.add_column('library_item', sa.Column('title_checked_at', sa.DateTime(), nullable=True)) + with op.batch_alter_table('library_item') as batch_op: + batch_op.drop_column('title_is_filename') + +def downgrade() -> None: + op.add_column('library_item', + sa.Column('title_is_filename', sa.Boolean(), nullable=False, + server_default=sa.false())) + with op.batch_alter_table('library_item') as batch_op: + batch_op.drop_column('title_checked_at') + batch_op.drop_column('title_mismatched') diff --git a/affiche-backend/affiche/alembic/versions/c2a6e8b4d7f1_add_library_item_catalogue_title.py b/affiche-backend/affiche/alembic/versions/c2a6e8b4d7f1_add_library_item_catalogue_title.py new file mode 100644 index 0000000..592aa0d --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/c2a6e8b4d7f1_add_library_item_catalogue_title.py @@ -0,0 +1,18 @@ +from alembic import op +import sqlalchemy as sa + +revision = 'c2a6e8b4d7f1' +down_revision = 'b9f3d5a7c2e4' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('library_item', + sa.Column('catalogue_title', sa.String(length=1024), nullable=True)) + op.add_column('library_item', + sa.Column('catalogue_provider', sa.String(length=50), nullable=True)) + +def downgrade() -> None: + with op.batch_alter_table('library_item') as batch_op: + batch_op.drop_column('catalogue_provider') + batch_op.drop_column('catalogue_title') diff --git a/affiche-backend/affiche/alembic/versions/c4f8b2e6a1d3_add_season_tmdb_match_override.py b/affiche-backend/affiche/alembic/versions/c4f8b2e6a1d3_add_season_tmdb_match_override.py new file mode 100644 index 0000000..875eeac --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/c4f8b2e6a1d3_add_season_tmdb_match_override.py @@ -0,0 +1,26 @@ +from alembic import op +import sqlalchemy as sa + +revision = 'c4f8b2e6a1d3' +down_revision = 'b7d9f1a3c5e8' +branch_labels = None +depends_on = None + +_COLUMNS = ('tmdb_id_override', 'tmdb_season_number_override') + +def _columns(table: str) -> set[str]: + insp = sa.inspect(op.get_bind()) + return {col["name"] for col in insp.get_columns(table)} + +def upgrade() -> None: + existing = _columns('library_season') + for column in _COLUMNS: + if column not in existing: + op.add_column('library_season', sa.Column(column, sa.Integer(), nullable=True)) + +def downgrade() -> None: + existing = _columns('library_season') + with op.batch_alter_table('library_season') as batch_op: + for column in _COLUMNS: + if column in existing: + batch_op.drop_column(column) diff --git a/affiche-backend/affiche/alembic/versions/d7b3e9a1c5f2_add_season_poster_uploaded_at.py b/affiche-backend/affiche/alembic/versions/d7b3e9a1c5f2_add_season_poster_uploaded_at.py new file mode 100644 index 0000000..45fcd6f --- /dev/null +++ b/affiche-backend/affiche/alembic/versions/d7b3e9a1c5f2_add_season_poster_uploaded_at.py @@ -0,0 +1,30 @@ +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + +revision = 'd7b3e9a1c5f2' +down_revision = 'c2a6e8b4d7f1' +branch_labels = None +depends_on = None + +def _columns(table: str) -> set[str]: + insp = sa.inspect(op.get_bind()) + return {col["name"] for col in insp.get_columns(table)} + +def upgrade() -> None: + if 'poster_uploaded_at' in _columns('library_season'): + return + op.add_column('library_season', sa.Column('poster_uploaded_at', sa.DateTime(), nullable=True)) + + season = sa.table('library_season', + sa.column('poster_hash', sa.String), + sa.column('poster_uploaded_at', sa.DateTime)) + op.execute(season.update() + .where(season.c.poster_hash.is_not(None)) + .values(poster_uploaded_at=datetime.now(timezone.utc))) + +def downgrade() -> None: + if 'poster_uploaded_at' in _columns('library_season'): + with op.batch_alter_table('library_season') as batch_op: + batch_op.drop_column('poster_uploaded_at') diff --git a/affiche-backend/affiche/api/routers/library.py b/affiche-backend/affiche/api/routers/library.py index bbe626b..fe72c12 100644 --- a/affiche-backend/affiche/api/routers/library.py +++ b/affiche-backend/affiche/api/routers/library.py @@ -19,8 +19,17 @@ ApplyPosterRequest, BulkLockResponse, ItemLockRequest, + ItemRenameRequest, ItemSelectionLockRequest, ItemSelectionRequest, + SeasonTmdbMatchRequest, + SeasonTmdbMatchSuggestion, + ItemTitleSuggestions, + MatchedTitle, + TitleCheckProgress, + TitleCleanupRequest, + TitleCleanupState, + TitleProposalResponse, TrashEmptyResponse, ) from affiche.api.schemas.library_settings import LibrarySettingsResponse, LibrarySettingsUpdate @@ -29,6 +38,12 @@ from affiche.app.events import event_manager from affiche.app.mediaserver.library import LibraryService from affiche.app.mediaserver.library.model.library import Library as LibraryModel +from affiche.app.mediaserver.library.service.item_rename_service import ( + ItemRenameError, + ItemRenameService, +) +from affiche.app.mediaserver.library.service.title_check_service import TitleCheckService +from affiche.app.mediaserver.library.service.title_cleanup_service import TitleCleanupService from affiche.app.mediaserver.library.settings.library_settings_service import LibrarySettingsService from affiche.app.mediaserver.library.sync.media_server_synchronisation_service import ( MediaServerSynchronisationService, @@ -39,7 +54,11 @@ require_admin, container, get_async_task_service, + get_item_rename_service, + get_title_check_service, + get_title_cleanup_service, get_library_service, + get_poster_aggregator, get_library_settings_service, get_media_server_synchronisation_service, get_poster_sync_service, @@ -266,6 +285,35 @@ def set_library_item_lock(media_server_id: int, item = service.set_item_locked(media_server_id, library_id, item_id, request.locked) return _item_response(library_id, item) +@router.get("/{library_id}/items/{item_id}/title/suggestions", + response_model=ItemTitleSuggestions) +def suggest_item_titles(media_server_id: int, + library_id: int, + item_id: int, + service: ItemRenameService = Depends(get_item_rename_service) + ) -> ItemTitleSuggestions: + suggestions = service.suggest_titles(media_server_id, library_id, item_id) + return ItemTitleSuggestions( + current=suggestions.current, + matched=(MatchedTitle(title=suggestions.matched.title, + provider=suggestions.matched.provider) + if suggestions.matched else None), + reason=suggestions.reason, + ) + +@router.patch("/{library_id}/items/{item_id}", response_model=LibraryItemResponse) +def rename_library_item(media_server_id: int, + library_id: int, + item_id: int, + request: ItemRenameRequest, + service: ItemRenameService = Depends(get_item_rename_service) + ) -> LibraryItemResponse: + try: + item = service.rename_item(media_server_id, library_id, item_id, request.title) + except ItemRenameError as error: + raise HTTPException(status_code=502, detail=error.message) + return _item_response(library_id, item) + @router.post("/{library_id}/trash/empty", response_model=TrashEmptyResponse) def empty_library_trash(media_server_id: int, library_id: int, @@ -515,6 +563,128 @@ def get_season_episodes(media_server_id: int, episodes = service.get_season_episodes(library_id, item_id, season_number) return [ItemEpisode.model_validate(e) for e in episodes] +@router.post("/{library_id}/seasons/tmdb-match/resolve", response_model=SyncTaskResponse) +def resolve_season_tmdb_matches(media_server_id: int, + library_id: int, + background_tasks: BackgroundTasks, + service: LibraryService = Depends(get_library_service), + task_service: AsyncTaskService = Depends(get_async_task_service) + ) -> SyncTaskResponse: + service.get_library(media_server_id, library_id) + + task_id, task_status = task_service.submit_task( + background_tasks=background_tasks, + task_func=lambda cancel_check=None: library_tasks.fix_season_matches_task( + media_server_id, library_id, cancel_check=cancel_check), + task_name=f"season_match_{library_id}", + blocking=True, + resource=f"ms:{media_server_id}:lib:{library_id}", + ) + return SyncTaskResponse( + status=task_status, + task_id=task_id, + message=f"Season matching started for library {library_id}", + ) + +@router.get("/{library_id}/titles/cleanup", response_model=TitleCleanupState) +def get_title_cleanup_state(media_server_id: int, + library_id: int, + service: TitleCleanupService = Depends(get_title_cleanup_service), + checker: TitleCheckService = Depends(get_title_check_service) + ) -> TitleCleanupState: + return TitleCleanupState( + proposals=[TitleProposalResponse(**proposal._asdict()) + for proposal in service.proposals(media_server_id, library_id)], + check=TitleCheckProgress(**checker.progress(library_id)._asdict()), + ) + +@router.post("/{library_id}/titles/check", response_model=SyncTaskResponse) +def check_library_titles(media_server_id: int, + library_id: int, + background_tasks: BackgroundTasks, + service: LibraryService = Depends(get_library_service), + task_service: AsyncTaskService = Depends(get_async_task_service) + ) -> SyncTaskResponse: + service.get_library(media_server_id, library_id) + + task_id, task_status = task_service.submit_task( + background_tasks=background_tasks, + task_func=lambda cancel_check=None: library_tasks.check_titles_task( + media_server_id, library_id, cancel_check=cancel_check), + task_name=f"title_check_{library_id}", + blocking=True, + resource=f"ms:{media_server_id}:lib:{library_id}", + ) + return SyncTaskResponse( + status=task_status, + task_id=task_id, + message=f"Checking titles in library {library_id}", + ) + +@router.post("/{library_id}/titles/cleanup", response_model=SyncTaskResponse) +def apply_title_cleanup(media_server_id: int, + library_id: int, + request: TitleCleanupRequest, + background_tasks: BackgroundTasks, + service: LibraryService = Depends(get_library_service), + task_service: AsyncTaskService = Depends(get_async_task_service) + ) -> SyncTaskResponse: + service.get_library(media_server_id, library_id) + + item_ids = request.item_ids + regenerate = request.regenerate + task_id, task_status = task_service.submit_task( + background_tasks=background_tasks, + task_func=lambda cancel_check=None: library_tasks.apply_titles_task( + media_server_id, library_id, item_ids, regenerate=regenerate, + cancel_check=cancel_check), + task_name=f"title_cleanup_{library_id}", + blocking=True, + resource=f"ms:{media_server_id}:lib:{library_id}", + ) + return SyncTaskResponse( + status=task_status, + task_id=task_id, + message=f"Renaming {len(item_ids)} item(s) in library {library_id}", + ) + +@router.get("/{library_id}/items/{item_id}/seasons/{season_number}/tmdb-match/suggestion", + response_model=SeasonTmdbMatchSuggestion) +def suggest_season_tmdb_match(media_server_id: int, + library_id: int, + item_id: int, + season_number: int, + service: LibraryService = Depends(get_library_service), + aggregator=Depends(get_poster_aggregator)) -> SeasonTmdbMatchSuggestion: + item = service.get_library_item(media_server_id, library_id, item_id) + suggestion = aggregator.suggest_season_match( + show_title=item.title, + season_number=season_number, + tvdb_id=int(item.tvdb_id) if item.tvdb_id else None, + ) + if suggestion is None: + raise HTTPException(404, "No confident TMDB match found for this season") + return SeasonTmdbMatchSuggestion( + tmdb_id=suggestion.tmdb_id, + tmdb_season_number=suggestion.season_number, + series_name=suggestion.series_name, + reason=suggestion.reason, + ) + +@router.put("/{library_id}/items/{item_id}/seasons/{season_number}/tmdb-match", + response_model=ItemSeason) +def set_season_tmdb_match(media_server_id: int, + library_id: int, + item_id: int, + season_number: int, + request: SeasonTmdbMatchRequest, + service: LibraryService = Depends(get_library_service)) -> ItemSeason: + season = service.set_season_tmdb_match(media_server_id, library_id, item_id, season_number, + request.tmdb_id, request.tmdb_season_number) + if season is None: + raise HTTPException(404, f"Season {season_number} not found for item {item_id}") + return _season_response(library_id, item_id, season) + @router.post("/{library_id}/items/{item_id}/seasons/{season_number}/posters", status_code=status.HTTP_204_NO_CONTENT) def apply_season_poster(media_server_id: int, library_id: int, diff --git a/affiche-backend/affiche/api/routers/service.py b/affiche-backend/affiche/api/routers/service.py index b9c7867..784c0b4 100644 --- a/affiche-backend/affiche/api/routers/service.py +++ b/affiche-backend/affiche/api/routers/service.py @@ -32,7 +32,7 @@ get_poster_aggregator, get_provider_service, ) -from affiche.external.poster.poster_service import PosterAggregatorService +from affiche.external.poster.poster_service import PosterAggregatorService, TmdbSeasonMatch router = APIRouter() @@ -151,6 +151,10 @@ def get_season_posters( season_number: int = Query(..., description="Season number"), tmdb_id: Optional[int] = Query(None), tvdb_id: Optional[int] = Query(None), + tmdb_id_override: Optional[int] = Query( + None, description="TMDB series to ask instead, when it files this season separately"), + tmdb_season_number_override: Optional[int] = Query( + None, description="Which season of that series; defaults to season_number"), provider: Optional[str] = Query(None), language: Optional[str] = Query(None), aggregator: PosterAggregatorService = Depends(get_poster_aggregator) @@ -158,12 +162,19 @@ def get_season_posters( if not tmdb_id and not tvdb_id: raise HTTPException(status_code=400, detail="At least one of tmdb_id or tvdb_id required") + match = TmdbSeasonMatch( + tmdb_id=tmdb_id_override, + season_number=(season_number if tmdb_season_number_override is None + else tmdb_season_number_override), + ) if tmdb_id_override else None + return aggregator.get_all_season_posters( season_number=season_number, tmdb_id=tmdb_id, tvdb_id=tvdb_id, language=language or None, - provider_name=provider + provider_name=provider, + tmdb_match=match, ) @router.get("/fonts") diff --git a/affiche-backend/affiche/api/schemas/library.py b/affiche-backend/affiche/api/schemas/library.py index 32db380..4659abf 100644 --- a/affiche-backend/affiche/api/schemas/library.py +++ b/affiche-backend/affiche/api/schemas/library.py @@ -3,13 +3,14 @@ from typing import Dict, Optional, List, Tuple from fastapi import HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from affiche.api.schemas.settings_schema import OverlayOptionsResponse, TextOptionsResponse from affiche.app.filestore.filestore import FileStoreService from affiche.app.image import custom_poster from affiche.app.image.model import OverlayOptions, TextOptions from affiche.app.mediaserver.library.model import ItemStatusFilter, LibraryItemSearch, SortDir +from affiche.app.mediaserver.library.service.item_rename_service import MAX_TITLE_LENGTH PROCESSED_NO_POSTER_ERROR = "Marked as processed but no poster was generated — this item needs attention." @@ -110,6 +111,9 @@ class ItemSeason(BaseModel): tmdb_id: Optional[int] = None tvdb_id: Optional[int] = None + tmdb_id_override: Optional[int] = None + tmdb_season_number_override: Optional[int] = None + poster_url: Optional[str] = None poster_provider: Optional[str] = None processed: bool @@ -211,6 +215,58 @@ class LibraryStyleStaleness(BaseModel): class ItemLockRequest(BaseModel): locked: bool +class TitleProposalResponse(BaseModel): + item_id: int + current_title: str + proposed_title: str + provider: Optional[str] = None + status: str = "pending" + error: Optional[str] = None + +class TitleCheckProgress(BaseModel): + running: bool = False + checked: int = 0 + total: int = 0 + mismatched: int = 0 + +class TitleCleanupState(BaseModel): + proposals: List[TitleProposalResponse] = Field(default_factory=list) + check: TitleCheckProgress = Field(default_factory=TitleCheckProgress) + +class TitleCleanupRequest(BaseModel): + item_ids: List[int] = Field(..., min_length=1) + regenerate: bool = False + +class ItemRenameRequest(BaseModel): + title: str = Field(..., max_length=MAX_TITLE_LENGTH) + + @field_validator("title") + @classmethod + def _not_blank(cls, value: str) -> str: + trimmed = value.strip() + if not trimmed: + raise ValueError("A title is required.") + return trimmed + +class MatchedTitle(BaseModel): + title: str + provider: str + +class ItemTitleSuggestions(BaseModel): + current: str + matched: Optional[MatchedTitle] = None + reason: Optional[str] = None + +class SeasonTmdbMatchSuggestion(BaseModel): + tmdb_id: int + tmdb_season_number: int + series_name: str + reason: str + +class SeasonTmdbMatchRequest(BaseModel): + tmdb_id: Optional[int] = Field(None, ge=1) + tmdb_season_number: Optional[int] = Field(None, ge=0) + class ItemSelectionRequest(BaseModel): item_ids: List[int] = Field(..., min_length=1) diff --git a/affiche-backend/affiche/app/asynch/library_tasks.py b/affiche-backend/affiche/app/asynch/library_tasks.py index 35244d7..399e383 100644 --- a/affiche-backend/affiche/app/asynch/library_tasks.py +++ b/affiche-backend/affiche/app/asynch/library_tasks.py @@ -27,6 +27,7 @@ def sync_libraries_task(media_server_id: int, media_server_id, cancel_check=cancel_check ) _download_collection_posters(media_server_id, cancel_check=cancel_check) + _check_titles(media_server_id, cancel_check=cancel_check) report_task_progress(1, 1) def sync_library_task(media_server_id: int, @@ -48,8 +49,22 @@ def sync_library_task(media_server_id: int, media_server_id, library_id, cancel_check=cancel_check ) _download_collection_posters(media_server_id, library_id, cancel_check=cancel_check) + _check_titles(media_server_id, library_id, cancel_check=cancel_check) report_task_progress(1, 1) +def _check_titles(media_server_id: int, library_id: int = None, cancel_check=None) -> None: + service = container.title_check_service + for target in _target_libraries(media_server_id, library_id): + if cancel_check and cancel_check(): + return + try: + report = service.check_library(media_server_id, target, cancel_check=cancel_check) + if report.mismatched: + logger.info("Title check of library %s: %d of %d disagree with the catalogue", + target, report.mismatched, report.checked) + except Exception: + logger.exception("Could not check the titles of library %s", target) + def _download_collection_posters(media_server_id: int, library_id: int = None, cancel_check=None) -> None: @@ -97,6 +112,36 @@ def resolve_collection_ids_task(media_server_id: int, library_id: int, cancel_ch logger.info("Resolved %d collection ids in library %s", resolved, library_id) report_task_progress(1, 1) +def fix_season_matches_task(media_server_id: int, library_id: int, cancel_check=None): + service = container.season_match_service() + report = service.fix_library_season_matches(media_server_id, library_id, + cancel_check=cancel_check) + logger.info("Season match sweep of library %s: %d scanned, %d suspect, %d resolved, %d left", + library_id, report.shows_scanned, report.shows_suspect, report.resolved, + report.unresolved) + report_task_progress(1, 1) + +def check_titles_task(media_server_id: int, library_id: int, cancel_check=None): + report = container.title_check_service.check_library( + media_server_id, library_id, force=True, cancel_check=cancel_check) + logger.info("Title check of library %s: %d checked, %d disagree, %d skipped", + library_id, report.checked, report.mismatched, report.skipped) + report_task_progress(1, 1) + +def apply_titles_task(media_server_id: int, library_id: int, item_ids: list[int], + regenerate: bool = False, cancel_check=None): + service = container.title_cleanup_service + report = service.apply(media_server_id, library_id, item_ids, cancel_check=cancel_check) + logger.info("Title cleanup of library %s: %d approved, %d renamed, %d failed", + library_id, report.approved, report.renamed, report.failed) + + renamed = service.renamed_item_ids(library_id) if regenerate else [] + if renamed and not (cancel_check and cancel_check()): + logger.info("Regenerating %d poster(s) after the title cleanup", len(renamed)) + container.poster_sync_service().apply_posters_to_items( + media_server_id, renamed, cancel_check=cancel_check) + report_task_progress(1, 1) + def _target_libraries(media_server_id: int, library_id: int = None) -> list[int]: if library_id is not None: return [library_id] diff --git a/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py b/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py index e5573fe..7616060 100644 --- a/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py +++ b/affiche-backend/affiche/app/mediaserver/library/connector/alchemy_library_connector.py @@ -73,6 +73,8 @@ def _bucket_sums(): 'locked': func.sum(case((LibraryItemEntity.locked.is_(True), 1), else_=0)), 'uploaded': func.sum(case((LibraryItemEntity.poster_uploaded_at.is_not(None), 1), else_=0)), 'ready': func.sum(case((_ready_clause(), 1), else_=0)), + 'mismatched_titles': func.sum( + case((LibraryItemEntity.title_mismatched.is_(True), 1), else_=0)), } def _stats_from_row(row) -> LibraryItemStats: @@ -151,12 +153,17 @@ def create_or_update_items_batch(self, items: List[LibraryItem]) -> None: ) def _item_upsert_stmt(self, item: LibraryItem): - data = item.model_dump(exclude={'id', 'processed'}) + data = item.model_dump(exclude={'id', 'processed', 'title_mismatched', + 'title_checked_at', 'catalogue_title', + 'catalogue_provider'}) stmt = insert(LibraryItemEntity).values(**data, processed=False) - return stmt.on_conflict_do_update( - index_elements=['external_id', 'library_id'], - set_={col: stmt.excluded[col] for col in self._update_columns()} + updates = {col: stmt.excluded[col] for col in self._update_columns()} + updates['title_checked_at'] = case( + (LibraryItemEntity.title != stmt.excluded.title, None), + else_=LibraryItemEntity.title_checked_at, ) + return stmt.on_conflict_do_update( + index_elements=['external_id', 'library_id'], set_=updates) def _search_query(self, search: LibraryItemSearch) -> Query: query = self._session.query(LibraryItemEntity) @@ -195,6 +202,15 @@ def _search_query(self, search: LibraryItemSearch) -> Query: query = query.filter(LibraryItemEntity.poster_uploaded_at.is_not(None) if search.uploaded else LibraryItemEntity.poster_uploaded_at.is_(None)) + if search.title_mismatched is not None: + query = query.filter( + LibraryItemEntity.title_mismatched.is_(bool(search.title_mismatched))) + + if search.title_unchecked is not None: + query = query.filter(LibraryItemEntity.title_checked_at.is_(None) + if search.title_unchecked + else LibraryItemEntity.title_checked_at.is_not(None)) + if search.external_ids is not None: query = query.filter(LibraryItemEntity.external_id.in_(search.external_ids)) diff --git a/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py b/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py index c1a87ea..b015f80 100644 --- a/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py +++ b/affiche-backend/affiche/app/mediaserver/library/connector/library_item_entity.py @@ -53,6 +53,12 @@ class LibraryItemEntity(Base): media_bitrate: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) media_size_bytes: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True) + title_mismatched: Mapped[bool] = mapped_column(default=False, server_default=false(), + nullable=False) + title_checked_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + catalogue_title: Mapped[Optional[str]] = mapped_column(String(1024), nullable=True) + catalogue_provider: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + processed: Mapped[bool] = mapped_column(default=False) locked: Mapped[bool] = mapped_column(default=False, server_default=false(), nullable=False) error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True) diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item.py index 271831d..6c30f81 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item.py @@ -28,6 +28,10 @@ class LibraryItem(BaseModel): poster_hash: Optional[str] = None poster_provider: Optional[str] = None style_hash: Optional[str] = None + title_mismatched: bool = False + title_checked_at: Optional[datetime] = None + catalogue_title: Optional[str] = None + catalogue_provider: Optional[str] = None processed: bool = False locked: bool = False error_message: Optional[str] = None diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py index b66276b..ccd94c4 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item_search.py @@ -12,6 +12,7 @@ class ItemStatusFilter(str, Enum): LOCKED = "locked" READY = "ready" UPLOADED = "uploaded" + MISMATCHED_TITLES = "mismatched_titles" NO_PROVIDER = "none" @@ -28,6 +29,8 @@ class LibraryItemSearch(SearchCriteria): provider: Optional[str] = None attempted: Optional[bool] = None uploaded: Optional[bool] = None + title_mismatched: Optional[bool] = None + title_unchecked: Optional[bool] = None external_ids: Optional[List[str]] = None item_ids: Optional[List[int]] = None deleted: Optional[bool] = False @@ -37,15 +40,18 @@ class LibraryItemSearch(SearchCriteria): def _expand_and_check(self) -> 'LibraryItemSearch': if self.status is not None: if (self.processed is not None or self.has_error is not None - or self.locked is not None or self.uploaded is not None): + or self.locked is not None or self.uploaded is not None + or self.title_mismatched is not None): raise ValueError( - "pass either `status` or `processed`/`has_error`/`locked`/`uploaded`, not both") + "pass either `status` or `processed`/`has_error`/`locked`/`uploaded`/" + "`title_mismatched`, not both") expanded = { ItemStatusFilter.UNPROCESSED: {'processed': False, 'has_error': False}, ItemStatusFilter.ERRORS: {'has_error': True}, ItemStatusFilter.LOCKED: {'locked': True}, ItemStatusFilter.READY: {'processed': True, 'has_error': False, 'uploaded': False}, ItemStatusFilter.UPLOADED: {'uploaded': True}, + ItemStatusFilter.MISMATCHED_TITLES: {'title_mismatched': True}, }[self.status] for field, value in expanded.items(): object.__setattr__(self, field, value) diff --git a/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py b/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py index 8167f53..a0b9600 100644 --- a/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py +++ b/affiche-backend/affiche/app/mediaserver/library/model/library_item_stats.py @@ -8,6 +8,7 @@ class LibraryItemStats(BaseModel): locked: int = 0 uploaded: int = 0 ready: int = 0 + mismatched_titles: int = 0 def __add__(self, other: 'LibraryItemStats') -> 'LibraryItemStats': return LibraryItemStats(**{ diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/connector/alchemy_library_season_connector.py b/affiche-backend/affiche/app/mediaserver/library/seasons/connector/alchemy_library_season_connector.py index c023cb7..05b93f9 100644 --- a/affiche-backend/affiche/app/mediaserver/library/seasons/connector/alchemy_library_season_connector.py +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/connector/alchemy_library_season_connector.py @@ -1,4 +1,4 @@ -from typing import Mapping, Optional, List +from typing import Mapping, Optional, List, Set from sqlalchemy import update from sqlalchemy.dialects.sqlite import insert @@ -32,6 +32,7 @@ def rekey_seasons(self, adoptions: Mapping[int, str]) -> int: continue entity.external_id = external_id entity.poster_hash = None + entity.poster_uploaded_at = None rekeyed += 1 self._session.commit() except Exception: @@ -39,6 +40,19 @@ def rekey_seasons(self, adoptions: Mapping[int, str]) -> int: raise return rekeyed + def set_tmdb_match(self, + season_id: int, + tmdb_id: Optional[int], + season_number: Optional[int]) -> Optional[LibrarySeason]: + entity = self._session.get(LibrarySeasonEntity, season_id) + if entity is None: + return None + entity.tmdb_id_override = tmdb_id + entity.tmdb_season_number_override = season_number if tmdb_id is not None else None + self._session.commit() + self._session.refresh(entity) + return LibrarySeason.model_validate(entity) + def create_or_update_seasons_batch(self, seasons: List[LibrarySeason]) -> None: commit_batch_with_fallback( self._session, @@ -68,6 +82,29 @@ def get_seasons_by_show(self, return [LibrarySeason.model_validate(s) for s in query.all()] + def find_show_ids(self, + library_id: int, + processed: bool, + uploaded: Optional[bool] = None) -> List[int]: + query = (self._session.query(LibrarySeasonEntity.show_id) + .filter(LibrarySeasonEntity.library_id == library_id) + .filter(LibrarySeasonEntity.processed == processed)) + + if uploaded is not None: + query = query.filter(LibrarySeasonEntity.poster_uploaded_at.is_not(None) if uploaded + else LibrarySeasonEntity.poster_uploaded_at.is_(None)) + + return [row[0] for row in query.distinct().all()] + + def find_known_external_ids(self, library_id: int, external_ids: List[str]) -> Set[str]: + if not external_ids: + return set() + rows = (self._session.query(LibrarySeasonEntity.external_id) + .filter(LibrarySeasonEntity.library_id == library_id) + .filter(LibrarySeasonEntity.external_id.in_(external_ids)) + .all()) + return {row[0] for row in rows} + def _season_update_columns(self) -> list[str]: return [ 'season_number', diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/connector/library_season_entity.py b/affiche-backend/affiche/app/mediaserver/library/seasons/connector/library_season_entity.py index 2f14ec2..4887bb0 100644 --- a/affiche-backend/affiche/app/mediaserver/library/seasons/connector/library_season_entity.py +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/connector/library_season_entity.py @@ -27,8 +27,12 @@ class LibrarySeasonEntity(Base): tmdb_id: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) tvdb_id: Mapped[Optional[str]] = mapped_column(String(150), nullable=True) + tmdb_id_override: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + tmdb_season_number_override: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + poster_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True) poster_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + poster_uploaded_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) poster_provider: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) style_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) processed: Mapped[bool] = mapped_column(default=False) diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_repository.py b/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_repository.py index 71ad30c..2d5dd87 100644 --- a/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_repository.py +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_repository.py @@ -1,4 +1,4 @@ -from typing import List, Mapping, Optional +from typing import List, Mapping, Optional, Set from sqlalchemy.orm import Session @@ -19,6 +19,12 @@ def update_seasons(self, seasons: List[LibrarySeason], state: SeasonPosterState) def rekey_seasons(self, adoptions: Mapping[int, str]) -> int: return self._connector.rekey_seasons(adoptions) + def set_tmdb_match(self, + season_id: int, + tmdb_id: Optional[int], + season_number: Optional[int]) -> Optional[LibrarySeason]: + return self._connector.set_tmdb_match(season_id, tmdb_id, season_number) + def create_or_update(self, seasons: List[LibrarySeason]) -> None: self._connector.create_or_update_seasons_batch(seasons) @@ -28,3 +34,12 @@ def get_seasons_by_show(self, processed: Optional[bool] = None) -> List[LibrarySeason]: return self._connector.get_seasons_by_show(show_id, library_id, processed) + def find_known_external_ids(self, library_id: int, external_ids: List[str]) -> Set[str]: + return self._connector.find_known_external_ids(library_id, external_ids) + + def find_show_ids(self, + library_id: int, + processed: bool, + uploaded: Optional[bool] = None) -> List[int]: + return self._connector.find_show_ids(library_id, processed, uploaded) + diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_service.py b/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_service.py index 19533c2..a94ed24 100644 --- a/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_service.py +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/library_season_service.py @@ -1,4 +1,4 @@ -from typing import List, Mapping, Optional +from typing import List, Mapping, Optional, Set from sqlalchemy.orm import Session @@ -31,8 +31,30 @@ def adopt_readded_seasons(self, def create_or_update(self, seasons: List[LibrarySeason]) -> None: self._repository.create_or_update(seasons) + def get_season(self, + library_id: int, + item_id: int, + season_number: int) -> Optional[LibrarySeason]: + seasons = self._repository.get_seasons_by_show(show_id=item_id, library_id=library_id) + return next((s for s in seasons if s.season_number == season_number), None) + + def set_tmdb_match(self, + season_id: int, + tmdb_id: Optional[int], + season_number: Optional[int]) -> Optional[LibrarySeason]: + return self._repository.set_tmdb_match(season_id, tmdb_id, season_number) + def get_item_seasons(self, library_id: int, item_id: int, processed: Optional[bool] = None) -> List[LibrarySeason]: return self._repository.get_seasons_by_show(show_id=item_id, library_id=library_id, processed=processed) + + def find_known_external_ids(self, library_id: int, external_ids: List[str]) -> Set[str]: + return self._repository.find_known_external_ids(library_id, external_ids) + + def find_show_ids(self, + library_id: int, + processed: bool, + uploaded: Optional[bool] = None) -> List[int]: + return self._repository.find_show_ids(library_id, processed, uploaded) diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/model/library_season.py b/affiche-backend/affiche/app/mediaserver/library/seasons/model/library_season.py index c3a89da..2028954 100644 --- a/affiche-backend/affiche/app/mediaserver/library/seasons/model/library_season.py +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/model/library_season.py @@ -19,8 +19,12 @@ class LibrarySeason(BaseModel): tmdb_id: Optional[int] = None tvdb_id: Optional[int] = None + tmdb_id_override: Optional[int] = None + tmdb_season_number_override: Optional[int] = None + poster_url: Optional[str] = None poster_hash: Optional[str] = None + poster_uploaded_at: Optional[datetime] = None poster_provider: Optional[str] = None style_hash: Optional[str] = None processed: bool = False @@ -30,6 +34,7 @@ class SeasonPosterState(BaseModel): processed: Optional[bool] = None poster_hash: Optional[str] = None + poster_uploaded_at: Optional[datetime] = None poster_provider: Optional[str] = None style_hash: Optional[str] = None diff --git a/affiche-backend/affiche/app/mediaserver/library/seasons/season_match.py b/affiche-backend/affiche/app/mediaserver/library/seasons/season_match.py new file mode 100644 index 0000000..3f9d467 --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/library/seasons/season_match.py @@ -0,0 +1,11 @@ +from typing import Optional + +from affiche.app.mediaserver.library.seasons.model.library_season import LibrarySeason +from affiche.external.poster.poster_service import TmdbSeasonMatch + +def tmdb_season_match(season: LibrarySeason) -> Optional[TmdbSeasonMatch]: + if season.tmdb_id_override is None: + return None + number = season.tmdb_season_number_override + return TmdbSeasonMatch(tmdb_id=season.tmdb_id_override, + season_number=season.season_number if number is None else number) diff --git a/affiche-backend/affiche/app/mediaserver/library/service/item_rename_service.py b/affiche-backend/affiche/app/mediaserver/library/service/item_rename_service.py new file mode 100644 index 0000000..af9bd90 --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/library/service/item_rename_service.py @@ -0,0 +1,133 @@ +import logging +from typing import List, Optional + +from sqlalchemy.orm import Session + +from affiche.app.mediaserver.library.model import LibraryItem +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository +from affiche.app.mediaserver.library.title_check import matches_any, titles_differ +from affiche.app.mediaserver.service.media_server_connector_protocol import ItemTitleWriter +from affiche.app.mediaserver.service.media_server_repository import MediaServerRepository + +logger = logging.getLogger(__name__) + +MAX_TITLE_LENGTH = 1024 + +class ItemRenameError(Exception): + + def __init__(self, message: str): + super().__init__(message) + self.message = message + +NO_ID = "no_id" +NOT_CONFIGURED = "not_configured" +NOT_FOUND = "not_found" +ALREADY_CORRECT = "already_correct" + +class TitleSuggestions: + + def __init__(self, current: str, matched: Optional[object] = None, + reason: Optional[str] = None): + self.current = current + self.matched = matched + self.reason = reason + +class ItemRenameService: + + def __init__(self, session: Session, connector_factory: Optional[object] = None, + poster_aggregator: Optional[object] = None): + self._library_repository = LibraryRepository(session) + self._media_server_repository = MediaServerRepository(session) + self._connector_factory = connector_factory + self._poster_aggregator = poster_aggregator + + def suggest_titles(self, media_server_id: int, library_id: int, + item_id: int) -> TitleSuggestions: + item = self._library_repository.get_library_item(library_id, item_id) + matched = self.catalogue_title(media_server_id, library_id, item_id) + + if matched is None: + return TitleSuggestions(item.title, reason=self._why_no_answer(item)) + if self.agrees_with_catalogue(media_server_id, library_id, item, matched.title): + return TitleSuggestions(item.title, reason=ALREADY_CORRECT) + return TitleSuggestions(item.title, matched=matched) + + def agrees_with_catalogue(self, media_server_id: int, library_id: int, item: LibraryItem, + primary: str) -> bool: + if not titles_differ(item.title, primary): + return True + return matches_any(item.title, + self.catalogue_alternatives(media_server_id, library_id, item.id)) + + def catalogue_alternatives(self, media_server_id: int, library_id: int, + item_id: int) -> List[str]: + self._library_repository.get_library(media_server_id, library_id) + item = self._library_repository.get_library_item(library_id, item_id) + if self._poster_aggregator is None or not (item.tmdb_id or item.tvdb_id): + return [] + return self._poster_aggregator.alternative_titles( + media_type=item.type, tmdb_id=item.tmdb_id, tvdb_id=item.tvdb_id) + + def catalogue_title(self, media_server_id: int, library_id: int, item_id: int): + library = self._library_repository.get_library(media_server_id, library_id) + item = self._library_repository.get_library_item(library_id, item_id) + + if self._poster_aggregator is None or not (item.tmdb_id or item.tvdb_id): + return None + language = self._title_language(media_server_id, library) + if not language: + return None + return self._poster_aggregator.title_from_ids( + media_type=item.type, language=language, + tmdb_id=item.tmdb_id, tvdb_id=item.tvdb_id, + ) + + def _why_no_answer(self, item: LibraryItem) -> str: + if self._poster_aggregator is None: + return NOT_CONFIGURED + if not (item.tmdb_id or item.tvdb_id): + return NO_ID + return NOT_FOUND + + def _title_language(self, media_server_id: int, library) -> Optional[str]: + language = (getattr(library, 'language', None) or "").strip() + if language: + return language[:2].lower() + + try: + media_server = self._media_server_repository.get(media_server_id) + except Exception: + logger.warning("Could not read the language order of media server %s", media_server_id, + exc_info=True) + return None + return next((code for code in media_server.language_order if code), None) + + def rename_item(self, media_server_id: int, library_id: int, item_id: int, + title: str) -> LibraryItem: + cleaned = title.strip() + if not cleaned: + raise ItemRenameError("A title is required.") + if len(cleaned) > MAX_TITLE_LENGTH: + raise ItemRenameError(f"A title cannot be longer than {MAX_TITLE_LENGTH} characters.") + + item = self._get_item(media_server_id, library_id, item_id) + if cleaned == item.title: + return item + + if not self._writer(media_server_id).rename_item(item.external_id, cleaned): + raise ItemRenameError("The media server would not rename this item.") + + logger.info("Renamed item %s from '%s' to '%s'", item_id, item.title, cleaned) + item.title = cleaned + item.title_mismatched = False + item.title_checked_at = None + return self._library_repository.create_or_update_item(item) + + def _get_item(self, media_server_id: int, library_id: int, item_id: int) -> LibraryItem: + self._library_repository.get_library(media_server_id, library_id) + return self._library_repository.get_library_item(library_id, item_id) + + def _writer(self, media_server_id: int) -> ItemTitleWriter: + if self._connector_factory is None: + raise ItemRenameError("No media server connection is configured.") + return self._connector_factory.get(media_server_id) diff --git a/affiche-backend/affiche/app/mediaserver/library/service/library_service.py b/affiche-backend/affiche/app/mediaserver/library/service/library_service.py index d324d57..cf28d51 100644 --- a/affiche-backend/affiche/app/mediaserver/library/service/library_service.py +++ b/affiche-backend/affiche/app/mediaserver/library/service/library_service.py @@ -119,6 +119,19 @@ def letter_offsets(self, search: LibraryItemSearch) -> List[tuple[str, int]]: def get_item_seasons(self, library_id: int, item_id: int) -> List: return self.library_season_service.get_item_seasons(library_id, item_id) + def set_season_tmdb_match(self, + media_server_id: int, + library_id: int, + item_id: int, + season_number: int, + tmdb_id: Optional[int], + tmdb_season_number: Optional[int]): + self.get_library_item(media_server_id, library_id, item_id) + season = self.library_season_service.get_season(library_id, item_id, season_number) + if season is None or season.id is None: + return None + return self.library_season_service.set_tmdb_match(season.id, tmdb_id, tmdb_season_number) + def get_season_episodes(self, library_id: int, item_id: int, season_number: int) -> List: seasons = self.library_season_service.get_item_seasons(library_id, item_id) season = next((s for s in seasons if s.season_number == season_number), None) diff --git a/affiche-backend/affiche/app/mediaserver/library/service/title_check_service.py b/affiche-backend/affiche/app/mediaserver/library/service/title_check_service.py new file mode 100644 index 0000000..049910f --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/library/service/title_check_service.py @@ -0,0 +1,117 @@ +import logging +from contextlib import contextmanager +from datetime import datetime, timezone +from threading import Lock +from typing import Callable, Dict, NamedTuple + +from sqlalchemy.orm import Session + +from affiche.app.asynch.async_task_service import report_task_progress +from affiche.app.mediaserver.library.model import LibraryItemSearch +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository + +logger = logging.getLogger(__name__) + +class TitleCheckReport(NamedTuple): + checked: int = 0 + mismatched: int = 0 + skipped: int = 0 + +class CheckProgress(NamedTuple): + running: bool = False + checked: int = 0 + total: int = 0 + mismatched: int = 0 + +class TitleCheckService: + + def __init__(self, + session_factory: Callable[[], Session], + rename_service_factory: Callable[[Session], object]): + self._session_factory = session_factory + self._rename_service_factory = rename_service_factory + self._progress: Dict[int, CheckProgress] = {} + self._lock = Lock() + + def progress(self, library_id: int) -> CheckProgress: + with self._lock: + return self._progress.get(library_id, CheckProgress()) + + def check_library(self, + media_server_id: int, + library_id: int, + force: bool = False, + cancel_check: Callable[[], bool] = None) -> TitleCheckReport: + with self._session() as session: + search = (LibraryItemSearch(library_id=library_id) if force + else LibraryItemSearch(library_id=library_id, title_unchecked=True)) + pending = LibraryRepository(session).find_items(search) + + if not pending: + self._publish(library_id, CheckProgress(running=False)) + return TitleCheckReport() + + logger.info("[title-check] library %s: %d titles to check%s", library_id, len(pending), + " (forced)" if force else "") + report = TitleCheckReport() + self._publish(library_id, CheckProgress(running=True, total=len(pending))) + try: + for index, item in enumerate(pending): + if cancel_check and cancel_check(): + logger.info("[title-check] cancelled after %d items", index) + break + report = self._check_item(media_server_id, item, report) + self._publish(library_id, CheckProgress( + running=True, checked=index + 1, total=len(pending), + mismatched=report.mismatched)) + report_task_progress(index + 1, len(pending), "Titles") + finally: + self._publish(library_id, CheckProgress( + running=False, checked=report.checked + report.skipped, total=len(pending), + mismatched=report.mismatched)) + + logger.info("[title-check] library %s: %d checked, %d disagree, %d skipped", + library_id, report.checked, report.mismatched, report.skipped) + return report + + def _publish(self, library_id: int, progress: CheckProgress) -> None: + with self._lock: + self._progress[library_id] = progress + + def _check_item(self, media_server_id: int, item, report: TitleCheckReport) -> TitleCheckReport: + try: + with self._session() as session: + service = self._rename_service_factory(session) + matched = service.catalogue_title(media_server_id, item.library_id, item.id) + + if matched is None: + return report._replace(skipped=report.skipped + 1) + + mismatched = not service.agrees_with_catalogue( + media_server_id, item.library_id, item, matched.title) + self._record(session, item, mismatched, matched) + + if mismatched: + logger.info("[title-check] %s: %s calls it '%s'", item.title, matched.provider, + matched.title) + return report._replace(checked=report.checked + 1, + mismatched=report.mismatched + (1 if mismatched else 0)) + except Exception: + logger.exception("[title-check] %s (%s): FAILED", item.id, item.title) + return report._replace(skipped=report.skipped + 1) + + @staticmethod + def _record(session: Session, item, mismatched: bool, matched) -> None: + item.title_mismatched = mismatched + item.catalogue_title = matched.title + item.catalogue_provider = matched.provider + item.title_checked_at = datetime.now(timezone.utc).replace(tzinfo=None) + LibraryRepository(session).create_or_update_item(item) + + @contextmanager + def _session(self): + session = self._session_factory() + try: + yield session + finally: + session.close() diff --git a/affiche-backend/affiche/app/mediaserver/library/service/title_cleanup_service.py b/affiche-backend/affiche/app/mediaserver/library/service/title_cleanup_service.py new file mode 100644 index 0000000..8ef44c7 --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/library/service/title_cleanup_service.py @@ -0,0 +1,141 @@ +import logging +from contextlib import contextmanager +from threading import Lock +from typing import Callable, Dict, List, NamedTuple, Optional + +from sqlalchemy.orm import Session + +from affiche.app.asynch.async_task_service import report_task_progress +from affiche.app.events import event_manager +from affiche.app.mediaserver.library.model import LibraryItemSearch +from affiche.app.mediaserver.library.service.item_rename_service import ( + ItemRenameError, + ItemRenameService, +) +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository + +logger = logging.getLogger(__name__) + +PENDING = "pending" +RENAMED = "renamed" +FAILED = "failed" + +class TitleProposal(NamedTuple): + item_id: int + current_title: str + proposed_title: str + provider: Optional[str] + status: str = PENDING + error: Optional[str] = None + +class TitleCleanupReport(NamedTuple): + approved: int = 0 + renamed: int = 0 + failed: int = 0 + +class TitleCleanupService: + + def __init__(self, + session_factory: Callable[[], Session], + rename_service_factory: Callable[[Session], ItemRenameService]): + self._session_factory = session_factory + self._rename_service_factory = rename_service_factory + self._runs: Dict[int, Dict[int, TitleProposal]] = {} + self._lock = Lock() + + def proposals(self, media_server_id: int, library_id: int) -> List[TitleProposal]: + with self._session() as session: + LibraryRepository(session).get_library(media_server_id, library_id) + flagged = LibraryRepository(session).find_items( + LibraryItemSearch(library_id=library_id, title_mismatched=True)) + + run = self._run(library_id) + finished = [row for row in run.values() if row.status != PENDING] + pending = [ + TitleProposal(item_id=item.id, current_title=item.title, + proposed_title=item.catalogue_title, provider=item.catalogue_provider) + for item in flagged + if item.catalogue_title and run.get(item.id, TitleProposal(0, "", "", None)).status + == PENDING + ] + return finished + sorted(pending, key=lambda row: row.current_title.casefold()) + + def apply(self, + media_server_id: int, + library_id: int, + item_ids: List[int], + cancel_check: Callable[[], bool] = None) -> TitleCleanupReport: + with self._session() as session: + LibraryRepository(session).get_library(media_server_id, library_id) + approved = LibraryRepository(session).find_items(LibraryItemSearch( + library_id=library_id, title_mismatched=True, item_ids=item_ids)) + + approved = [item for item in approved if item.catalogue_title] + if not approved: + return TitleCleanupReport() + + self._begin(library_id, approved) + report = TitleCleanupReport(approved=len(approved)) + for index, item in enumerate(approved): + if cancel_check and cancel_check(): + logger.info("[title-cleanup] cancelled after %d items", index) + break + report = self._apply_one(media_server_id, item, report) + report_task_progress(index + 1, len(approved), "Titles") + + if report.renamed: + event_manager.publish_library_synced(media_server_id, library_id) + logger.info("[title-cleanup] library %s: %d approved, %d renamed, %d failed", + library_id, report.approved, report.renamed, report.failed) + return report + + def renamed_item_ids(self, library_id: int) -> List[int]: + return [row.item_id for row in self._run(library_id).values() if row.status == RENAMED] + + def _apply_one(self, media_server_id: int, item, + report: TitleCleanupReport) -> TitleCleanupReport: + try: + with self._session() as session: + self._rename_service_factory(session).rename_item( + media_server_id, item.library_id, item.id, item.catalogue_title) + self._record(item, RENAMED) + logger.info("[title-cleanup] %s -> %s", item.title, item.catalogue_title) + return report._replace(renamed=report.renamed + 1) + except ItemRenameError as error: + self._record(item, FAILED, error.message) + logger.warning("[title-cleanup] %s: refused (%s)", item.title, error.message) + return report._replace(failed=report.failed + 1) + except Exception: + self._record(item, FAILED, "Affiche could not complete this rename.") + logger.exception("[title-cleanup] %s (%s): FAILED", item.id, item.title) + return report._replace(failed=report.failed + 1) + + def _begin(self, library_id: int, approved: List) -> None: + with self._lock: + self._runs[library_id] = { + item.id: TitleProposal(item_id=item.id, current_title=item.title, + proposed_title=item.catalogue_title, + provider=item.catalogue_provider) + for item in approved + } + + def _record(self, item, status: str, error: Optional[str] = None) -> None: + with self._lock: + run = self._runs.setdefault(item.library_id, {}) + row = run.get(item.id) + run[item.id] = (row or TitleProposal( + item_id=item.id, current_title=item.title, + proposed_title=item.catalogue_title, provider=item.catalogue_provider, + ))._replace(status=status, error=error) + + def _run(self, library_id: int) -> Dict[int, TitleProposal]: + with self._lock: + return dict(self._runs.get(library_id, {})) + + @contextmanager + def _session(self): + session = self._session_factory() + try: + yield session + finally: + session.close() diff --git a/affiche-backend/affiche/app/mediaserver/library/sync/incremental.py b/affiche-backend/affiche/app/mediaserver/library/sync/incremental.py index 6241311..7c6876d 100644 --- a/affiche-backend/affiche/app/mediaserver/library/sync/incremental.py +++ b/affiche-backend/affiche/app/mediaserver/library/sync/incremental.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta, timezone -from typing import Optional +from typing import Collection, List, Mapping, Optional RECENT_ITEM_LIMIT = 50 @@ -12,3 +12,12 @@ def may_run_incrementally(last_full_sync_at: Optional[datetime], now: datetime) if last_full_sync_at is None: return False return as_utc(last_full_sync_at) + FULL_SYNC_MAX_AGE > as_utc(now) + +def shows_gaining_seasons(recent_seasons: Mapping[str, str], + known_season_ids: Collection[str], + listed_show_ids: Collection[str]) -> Optional[List[str]]: + new = {season: show for season, show in recent_seasons.items() + if season not in known_season_ids} + if len(new) >= RECENT_ITEM_LIMIT: + return None + return list(dict.fromkeys(show for show in new.values() if show not in listed_show_ids)) diff --git a/affiche-backend/affiche/app/mediaserver/library/title_check.py b/affiche-backend/affiche/app/mediaserver/library/title_check.py new file mode 100644 index 0000000..f7e3ebb --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/library/title_check.py @@ -0,0 +1,20 @@ +import re +import unicodedata + +_PUNCTUATION = re.compile(r"[^\w\s]", re.UNICODE) + +_CONJUNCTIONS = frozenset({"and", "et", "und", "en"}) + +def normalise(title: str) -> str: + decomposed = unicodedata.normalize("NFKD", (title or "").strip()) + stripped = "".join(ch for ch in decomposed if not unicodedata.combining(ch)) + words = _PUNCTUATION.sub(" ", stripped).casefold().split() + return " ".join(word for word in words if word not in _CONJUNCTIONS) + +def matches_any(server_title: str, candidates) -> bool: + return any(not titles_differ(server_title, candidate) for candidate in candidates or ()) + +def titles_differ(server_title: str, catalogue_title: str) -> bool: + if not (server_title or "").strip() or not (catalogue_title or "").strip(): + return False + return normalise(server_title) != normalise(catalogue_title) diff --git a/affiche-backend/affiche/app/mediaserver/service/jellyfin_sync_service.py b/affiche-backend/affiche/app/mediaserver/service/jellyfin_sync_service.py index b8b37e4..d00daeb 100644 --- a/affiche-backend/affiche/app/mediaserver/service/jellyfin_sync_service.py +++ b/affiche-backend/affiche/app/mediaserver/service/jellyfin_sync_service.py @@ -9,7 +9,7 @@ from affiche.app.mediaserver.library.episodes.library_episode_service import LibraryEpisodeService from affiche.app.mediaserver.library.collections.library_collection_service import LibraryCollectionService from affiche.app.mediaserver.library.settings import LibrarySettingsService -from affiche.app.mediaserver.library.sync.incremental import RECENT_ITEM_LIMIT +from affiche.app.mediaserver.library.sync.incremental import RECENT_ITEM_LIMIT, shows_gaining_seasons from affiche.app.mediaserver.library.sync.reidentification import RemoteIdentity from affiche.app.mediaserver.model.media_server import MediaServer from affiche.app.mediaserver.service.media_server_connector_factory import MediaServerConnectorFactory @@ -140,12 +140,32 @@ def _fetch_items(self, jellyfin_service: JellyfinService, library: Library, items = jellyfin_service.get_recently_added_items(library.external_id, RECENT_ITEM_LIMIT) if len(items) < RECENT_ITEM_LIMIT: - return items, True - - logger.info("Library '%s': %d recently added items filled the window — syncing in full", - library.name, len(items)) + shows = self._shows_gaining_seasons(jellyfin_service, library, items) + if shows is not None: + return items + shows, True + logger.info("Library '%s': new seasons filled the window — syncing in full", + library.name) + else: + logger.info("Library '%s': %d recently added items filled the window — syncing in full", + library.name, len(items)) return jellyfin_service.get_library_items(library.external_id), False + def _shows_gaining_seasons(self, jellyfin_service: JellyfinService, library: Library, items: list): + if library.type != 'show': + return [] + try: + recent = jellyfin_service.get_recently_added_seasons(library.external_id, RECENT_ITEM_LIMIT) + known = self.season_service.find_known_external_ids(library.id, list(recent)) + show_ids = shows_gaining_seasons(recent, known, {str(item.id) for item in items}) + if show_ids is None: + return None + shows = [jellyfin_service.get_library_item(show_id, library.external_id) for show_id in show_ids] + return [show for show in shows if show] + except Exception: + logger.warning("Library '%s': could not look for new seasons", library.name, + exc_info=True) + return [] + def _sync_single_library(self, jellyfin_service: JellyfinService, library: Library, diff --git a/affiche-backend/affiche/app/mediaserver/service/media_server_connector_protocol.py b/affiche-backend/affiche/app/mediaserver/service/media_server_connector_protocol.py index a78d22a..94465f3 100644 --- a/affiche-backend/affiche/app/mediaserver/service/media_server_connector_protocol.py +++ b/affiche-backend/affiche/app/mediaserver/service/media_server_connector_protocol.py @@ -30,6 +30,11 @@ class LibraryEnumerator(Protocol): def get_libraries(self) -> List[RemoteLibrary]: ... +class ItemTitleWriter(Protocol): + + def rename_item(self, external_id: str, title: str) -> bool: + ... + class CollectionWriter(Protocol): def create_collection(self, library_external_id: str, title: str, diff --git a/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py b/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py index b5eb78d..5f45875 100644 --- a/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py +++ b/affiche-backend/affiche/app/mediaserver/service/media_server_poster_service.py @@ -1,7 +1,7 @@ import logging from datetime import datetime, timezone from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Optional, Callable, List, NamedTuple +from typing import Optional, Callable, List, NamedTuple, Sequence from sqlalchemy.orm import Session @@ -306,17 +306,24 @@ def _process_library(self, library: Library, cancel_check: Callable[[], bool] = None, upload: Optional[bool] = None): - with library_session(self._session_factory) as (repo, _): + with library_session(self._session_factory) as (repo, session): items = repo.find_items(LibraryItemSearch(library_id=library.id, processed=False, locked=False)) - self._process_items(media_server_id, library, items, cancel_check=cancel_check, upload=upload) + + show_ids = LibrarySeasonService(session).find_show_ids(library.id, processed=False) + season_shows = repo.find_items(LibraryItemSearch( + library_id=library.id, item_ids=show_ids, processed=True, locked=False)) if show_ids else [] + + self._process_items(media_server_id, library, items, cancel_check=cancel_check, upload=upload, + season_shows=season_shows) def _process_items(self, media_server_id: int, library: Library, items: List[LibraryItem], cancel_check: Callable[[], bool] = None, - upload: Optional[bool] = None): + upload: Optional[bool] = None, + season_shows: Sequence[LibraryItem] = ()): library_id = library.id library_type = library.type library_name = library.name @@ -330,7 +337,7 @@ def _process_items(self, settings = self._get_server_poster_settings(media_server_id) connector = self._get_connector(media_server_id) - total = len(items) + total = len(items) + len(season_shows) progress_label = f"Generating posters — {library_name}" report_task_progress(0, total, progress_label) @@ -354,6 +361,19 @@ def _process_items(self, ): item for item in items } + future_to_item.update({ + executor.submit( + self._process_pending_seasons, + show, + provider_order, + settings, + style, + connector, + upload_enabled, + cancel_check + ): show + for show in season_shows + }) for future in as_completed(future_to_item): if cancel_check and cancel_check(): @@ -427,6 +447,23 @@ def _process_item(self, self._mark_item_failed(repo, item, _error_text(error)) return False + def _process_pending_seasons(self, + item: LibraryItem, + provider_order: List[str], + settings: ServerPosterSettings, + style: LibraryPosterStyle, + connector: MediaServerConnector, + upload: bool, + cancel_check: Callable[[], bool] = None) -> bool: + if cancel_check and cancel_check(): + return False + + with library_session(self._session_factory) as (_, session): + failed_seasons = self._process_series_seasons( + LibrarySeasonService(session), session, item, provider_order, settings, style, + connector, upload) + return not failed_seasons + def _mark_item_failed(self, repo: LibraryRepository, item: LibraryItem, message: str): item.error_message = (message or "Unknown error")[:MAX_ERROR_LENGTH] repo.create_or_update_item(item) @@ -587,16 +624,19 @@ def _process_season_poster(self, if not stored: return False - if upload: - self._uploader.upload_season_if_changed(season_service, season, item, stored.path, stored.digest, - connector) + uploaded = upload and self._uploader.upload_season_if_changed( + season_service, season, item, stored.path, stored.digest, connector) - ProviderStatsService(session).record(provider, item.library_id) - season_service.update_seasons([season], SeasonPosterState( + state = dict( poster_provider=provider, style_hash=self._decorator.style_fingerprint(overlay_options, text_options, apply_style), processed=True, - )) + ) + if not uploaded: + state['poster_uploaded_at'] = None + + ProviderStatsService(session).record(provider, item.library_id) + season_service.update_seasons([season], SeasonPosterState(**state)) event_manager.publish_season_processed(item.library_id, item.id, season.season_number) return True diff --git a/affiche-backend/affiche/app/mediaserver/service/plex_sync_service.py b/affiche-backend/affiche/app/mediaserver/service/plex_sync_service.py index 3158dea..77e9918 100644 --- a/affiche-backend/affiche/app/mediaserver/service/plex_sync_service.py +++ b/affiche-backend/affiche/app/mediaserver/service/plex_sync_service.py @@ -9,7 +9,7 @@ from affiche.app.mediaserver.library.episodes.library_episode_service import LibraryEpisodeService from affiche.app.mediaserver.library.collections.library_collection_service import LibraryCollectionService from affiche.app.mediaserver.library.settings import LibrarySettingsService -from affiche.app.mediaserver.library.sync.incremental import RECENT_ITEM_LIMIT +from affiche.app.mediaserver.library.sync.incremental import RECENT_ITEM_LIMIT, shows_gaining_seasons from affiche.app.mediaserver.library.sync.reidentification import RemoteIdentity from affiche.app.mediaserver.model.media_server import MediaServer from affiche.app.mediaserver.service.media_server_connector_factory import MediaServerConnectorFactory @@ -139,12 +139,32 @@ def _fetch_items(self, plex_service: PlexService, library: Library, incremental: items = plex_service.get_recently_added_items(int(library.external_id), RECENT_ITEM_LIMIT) if len(items) < RECENT_ITEM_LIMIT: - return items, True - - logger.info("Library '%s': %d recently added items filled the window — syncing in full", - library.name, len(items)) + shows = self._shows_gaining_seasons(plex_service, library, items) + if shows is not None: + return items + shows, True + logger.info("Library '%s': new seasons filled the window — syncing in full", + library.name) + else: + logger.info("Library '%s': %d recently added items filled the window — syncing in full", + library.name, len(items)) return plex_service.get_library_items(int(library.external_id)), False + def _shows_gaining_seasons(self, plex_service: PlexService, library: Library, items: list): + if library.type != 'show': + return [] + try: + recent = plex_service.get_recently_added_seasons(int(library.external_id), RECENT_ITEM_LIMIT) + known = self.season_service.find_known_external_ids(library.id, list(recent)) + show_ids = shows_gaining_seasons(recent, known, {str(item.id) for item in items}) + if show_ids is None: + return None + shows = [plex_service.get_library_item(show_id) for show_id in show_ids] + return [show for show in shows if show] + except Exception: + logger.warning("Library '%s': could not look for new seasons", library.name, + exc_info=True) + return [] + def _sync_library(self, plex_service: PlexService, library: Library, diff --git a/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py b/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py index 2d52839..bd05dce 100644 --- a/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py +++ b/affiche-backend/affiche/app/mediaserver/service/poster_resetter.py @@ -75,6 +75,7 @@ def reset_season_posters(self, season_service.update_seasons(reset_seasons, SeasonPosterState( processed=False, poster_hash=None, + poster_uploaded_at=None, poster_provider=None, style_hash=None, )) diff --git a/affiche-backend/affiche/app/mediaserver/service/poster_resolver.py b/affiche-backend/affiche/app/mediaserver/service/poster_resolver.py index fee2487..fbd01aa 100644 --- a/affiche-backend/affiche/app/mediaserver/service/poster_resolver.py +++ b/affiche-backend/affiche/app/mediaserver/service/poster_resolver.py @@ -4,6 +4,7 @@ from affiche.app.filestore.filestore import FileStoreService from affiche.app.mediaserver.library.model.library_item import LibraryItem from affiche.app.mediaserver.library.seasons.model.library_season import LibrarySeason +from affiche.app.mediaserver.library.seasons.season_match import tmdb_season_match from affiche.external.poster.poster_service import PosterAggregatorService logger = logging.getLogger(__name__) @@ -66,6 +67,7 @@ def resolve_season_poster(self, season_number=season.season_number, provider_order=provider_order, language=language or None, + tmdb_match=tmdb_season_match(season), ) if found: return PosterSource(found.url, self._should_style(language, settings), found.provider) diff --git a/affiche-backend/affiche/app/mediaserver/service/poster_uploader.py b/affiche-backend/affiche/app/mediaserver/service/poster_uploader.py index 78c75d6..b2d9735 100644 --- a/affiche-backend/affiche/app/mediaserver/service/poster_uploader.py +++ b/affiche-backend/affiche/app/mediaserver/service/poster_uploader.py @@ -1,7 +1,7 @@ import logging from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone -from typing import Callable, List, Optional +from typing import Callable, List, Optional, Sequence from sqlalchemy.orm import Session @@ -100,20 +100,27 @@ def upload_season_if_changed(self, connector: MediaServerConnector) -> bool: if digest and season.poster_hash == digest: logger.info("[upload] season %d of '%s': unchanged, skipped", season.season_number, item.title) + if season.poster_uploaded_at is None: + season.poster_uploaded_at = datetime.now(timezone.utc) + season_service.update_seasons( + [season], SeasonPosterState(poster_uploaded_at=season.poster_uploaded_at)) return True if not self.upload_poster(season.external_id, poster_path, connector): return False season.poster_hash = digest - season_service.update_seasons([season], SeasonPosterState(poster_hash=digest)) + season.poster_uploaded_at = datetime.now(timezone.utc) + season_service.update_seasons([season], SeasonPosterState( + poster_hash=digest, poster_uploaded_at=season.poster_uploaded_at)) return True def upload_existing_season_posters(self, season_service: LibrarySeasonService, item: LibraryItem, - connector: MediaServerConnector): - seasons = season_service.get_item_seasons(item.library_id, item.id) + connector: MediaServerConnector) -> bool: + succeeded = True + seasons = season_service.get_item_seasons(item.library_id, item.id, processed=True) for season in seasons: if not self._file_store.exists(item.library_id, item.id, season_number=season.season_number): continue @@ -121,32 +128,55 @@ def upload_existing_season_posters(self, season_number=season.season_number)) digest = self._file_store.digest(item.library_id, item.id, season_number=season.season_number) - self.upload_season_if_changed(season_service, season, item, poster_path, digest, connector) + if not self.upload_season_if_changed(season_service, season, item, poster_path, digest, + connector): + logger.warning("[upload] season %d of '%s': FAILED", season.season_number, item.title) + succeeded = False + return succeeded + + def upload_show_season_posters(self, item: LibraryItem, connector: MediaServerConnector) -> bool: + with library_session(self._session_factory) as (_, session): + return self.upload_existing_season_posters(LibrarySeasonService(session), item, connector) + + def upload_existing_posters(self, item: LibraryItem, connector: MediaServerConnector) -> bool: + uploaded = self.upload_existing_item_poster(item, connector) + if item.type == 'show': + uploaded = self.upload_show_season_posters(item, connector) and uploaded + return uploaded def upload_library_posters(self, library: Library, connector: MediaServerConnector, cancel_check: Callable[[], bool] = None): - with library_session(self._session_factory) as (repo, _): + with library_session(self._session_factory) as (repo, session): items = repo.find_items(LibraryItemSearch( library_id=library.id, processed=True, uploaded=False)) - self.upload_items(items, library.name, connector, cancel_check) + queued = {item.id for item in items} + show_ids = [show_id for show_id in LibrarySeasonService(session).find_show_ids( + library.id, processed=True, uploaded=False) if show_id not in queued] + season_shows = repo.find_items(LibraryItemSearch( + library_id=library.id, item_ids=show_ids)) if show_ids else [] + + self.upload_items(items, library.name, connector, cancel_check, season_shows=season_shows) def upload_items(self, items: List[LibraryItem], library_name: str, connector: MediaServerConnector, - cancel_check: Callable[[], bool] = None): + cancel_check: Callable[[], bool] = None, + season_shows: Sequence[LibraryItem] = ()): logger.info("Uploading %d posters in '%s' using %d workers", - len(items), library_name, MAX_WORKERS) + len(items) + len(season_shows), library_name, MAX_WORKERS) uploaded = 0 failed = 0 with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = {executor.submit(self.upload_existing_item_poster, item, connector): item + futures = {executor.submit(self.upload_existing_posters, item, connector): item for item in items} + futures.update({executor.submit(self.upload_show_season_posters, show, connector): show + for show in season_shows}) for future in as_completed(futures): if cancel_check and cancel_check(): logger.info("Upload cancelled, shutting down executor") diff --git a/affiche-backend/affiche/app/mediaserver/service/season_match_service.py b/affiche-backend/affiche/app/mediaserver/service/season_match_service.py new file mode 100644 index 0000000..687ba80 --- /dev/null +++ b/affiche-backend/affiche/app/mediaserver/service/season_match_service.py @@ -0,0 +1,112 @@ +import logging +from contextlib import contextmanager +from typing import Callable, List, NamedTuple, Optional + +from sqlalchemy.orm import Session + +from affiche.app.events import event_manager +from affiche.app.asynch.async_task_service import report_task_progress +from affiche.app.mediaserver.library.model import LibraryItemSearch +from affiche.app.mediaserver.library.seasons.library_season_service import LibrarySeasonService +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository +from affiche.external.poster import season_match + +logger = logging.getLogger(__name__) + +class SeasonMatchReport(NamedTuple): + shows_scanned: int = 0 + shows_suspect: int = 0 + resolved: int = 0 + unresolved: int = 0 + +class SeasonMatchService: + + def __init__(self, + session_factory: Callable[[], Session], + aggregator_factory: Callable[[Session], object]): + self._session_factory = session_factory + self._aggregator_factory = aggregator_factory + + def fix_library_season_matches(self, + media_server_id: int, + library_id: int, + cancel_check: Callable[[], bool] = None) -> SeasonMatchReport: + with self._session() as session: + aggregator = self._aggregator_factory(session) + shows = [item for item in LibraryRepository(session).find_items( + LibraryItemSearch(library_id=library_id)) if item.type != "movie"] + + if not shows: + return SeasonMatchReport() + + logger.info("[season-match] scanning %d shows in library %s", len(shows), library_id) + report = SeasonMatchReport(shows_scanned=len(shows)) + for index, show in enumerate(shows): + if cancel_check and cancel_check(): + logger.info("[season-match] cancelled after %d shows", index) + break + report = self._sweep_show(show, aggregator, report) + report_task_progress(index + 1, len(shows), "Season matches") + + if report.resolved: + event_manager.publish_library_synced(media_server_id, library_id) + logger.info("[season-match] library %s: %d suspect, %d resolved, %d left for review", + library_id, report.shows_suspect, report.resolved, report.unresolved) + return report + + def _sweep_show(self, show, aggregator, report: SeasonMatchReport) -> SeasonMatchReport: + try: + seasons = self._seasons_needing_a_match(show, aggregator) + if not seasons: + return report + + resolved = sum(self._resolve_one(show, season, aggregator) for season in seasons) + return report._replace( + shows_suspect=report.shows_suspect + 1, + resolved=report.resolved + resolved, + unresolved=report.unresolved + len(seasons) - resolved, + ) + except Exception: + logger.exception("[season-match] %s (%s): FAILED", show.id, show.title) + return report + + def _seasons_needing_a_match(self, show, aggregator) -> List: + if not show.tmdb_id or not show.tvdb_id: + return [] + + with self._session() as session: + stored = LibrarySeasonService(session).get_item_seasons(show.library_id, show.id) + + pending = [s for s in stored if s.tmdb_id_override is None and s.season_number != 0] + if not pending: + return [] + + facts = aggregator.describe_tmdb_series(int(show.tmdb_id)) + missing = set(season_match.missing_seasons(facts, [s.season_number for s in pending])) + return [s for s in pending if s.season_number in missing] + + def _resolve_one(self, show, season, aggregator) -> bool: + suggestion = aggregator.suggest_season_match( + show_title=show.title, + season_number=season.season_number, + tvdb_id=int(show.tvdb_id), + ) + if suggestion is None: + logger.info("[season-match] %s S%d: no confident match, left for review", + show.title, season.season_number) + return False + + with self._session() as session: + LibrarySeasonService(session).set_tmdb_match( + season.id, suggestion.tmdb_id, suggestion.season_number) + logger.info("[season-match] %s S%d -> TMDB %d S%d (%s)", show.title, season.season_number, + suggestion.tmdb_id, suggestion.season_number, suggestion.series_name) + return True + + @contextmanager + def _session(self): + session = self._session_factory() + try: + yield session + finally: + session.close() diff --git a/affiche-backend/affiche/config/dependencies.py b/affiche-backend/affiche/config/dependencies.py index 980a19d..59a46d1 100644 --- a/affiche-backend/affiche/config/dependencies.py +++ b/affiche-backend/affiche/config/dependencies.py @@ -9,6 +9,7 @@ from affiche.app.auth.service.user_repository import UserRepository from affiche.app.dashboard import DashboardService from affiche.app.mediaserver.service.collection_poster_service import CollectionPosterService +from affiche.app.mediaserver.service.season_match_service import SeasonMatchService from affiche.app.provider_stats import ProviderStatsService from affiche.app.search import SearchService from affiche.app.task_history.service.task_history_service import TaskHistoryService @@ -25,6 +26,9 @@ from affiche.app.mediaserver.library.seasons.library_season_service import LibrarySeasonService from affiche.app.mediaserver.library.episodes.library_episode_service import LibraryEpisodeService from affiche.app.mediaserver.library.collections.library_collection_service import LibraryCollectionService +from affiche.app.mediaserver.library.service.item_rename_service import ItemRenameService +from affiche.app.mediaserver.library.service.title_check_service import TitleCheckService +from affiche.app.mediaserver.library.service.title_cleanup_service import TitleCleanupService from affiche.app.mediaserver.service.media_server_poster_service import LibraryPosterService from affiche.app.mediaserver.service.source_poster_service import SourcePosterService from affiche.app.mediaserver.library.settings.library_settings_service import LibrarySettingsService @@ -35,6 +39,7 @@ from affiche.app.mediaserver.service.media_server_repository import MediaServerRepository from affiche.app.mediaserver.service.media_server_service import MediaServerService from affiche.app.mediaserver.library.sync.media_server_synchronisation_service import MediaServerSynchronisationService +from affiche.app.service_configuration.exceptions import NoProvidersConfiguredError from affiche.app.service_configuration.provider_service import ProviderService from affiche.app.service_configuration.service.configuration_repository import ConfigurationRepository from affiche.app.service_configuration.service.service_configuration_service import ServiceConfigurationService @@ -70,6 +75,8 @@ def __init__(self): self._connector_factory: Optional[MediaServerConnectorFactory] = None self._font_store: Optional[FontStore] = None self._collection_file_store: Optional[FileStoreService] = None + self._title_cleanup_service: Optional[TitleCleanupService] = None + self._title_check_service: Optional[TitleCheckService] = None @property def file_store(self) -> FileStoreService: @@ -207,6 +214,13 @@ def library_episode_service(self, session: Session) -> LibraryEpisodeService: def library_collection_service(self, session: Session) -> LibraryCollectionService: return LibraryCollectionService(session, self.connector_factory) + def item_rename_service(self, session: Session) -> ItemRenameService: + try: + aggregator = self.poster_aggregator(session) + except NoProvidersConfiguredError: + aggregator = None + return ItemRenameService(session, self.connector_factory, aggregator) + def library_service(self, session: Session) -> LibraryService: return LibraryService(session, self.file_store, self.app_settings_store) @@ -260,6 +274,33 @@ def source_poster_service( file_store=self.file_store, ) + @property + def title_check_service(self) -> TitleCheckService: + if self._title_check_service is None: + self._title_check_service = TitleCheckService( + session_factory=SessionLocal, + rename_service_factory=self.item_rename_service, + ) + return self._title_check_service + + @property + def title_cleanup_service(self) -> TitleCleanupService: + if self._title_cleanup_service is None: + self._title_cleanup_service = TitleCleanupService( + session_factory=SessionLocal, + rename_service_factory=self.item_rename_service, + ) + return self._title_cleanup_service + + def season_match_service( + self, + session_factory: Callable[[], Session] = None + ) -> SeasonMatchService: + return SeasonMatchService( + session_factory=session_factory or SessionLocal, + aggregator_factory=self.poster_aggregator, + ) + def collection_poster_service( self, session_factory: Callable[[], Session] = None @@ -355,6 +396,17 @@ def get_library_collection_service( ) -> LibraryCollectionService: return container.library_collection_service(session) +def get_title_cleanup_service() -> TitleCleanupService: + return container.title_cleanup_service + +def get_title_check_service() -> TitleCheckService: + return container.title_check_service + +def get_item_rename_service( + session: Session = Depends(get_db) +) -> ItemRenameService: + return container.item_rename_service(session) + def get_dashboard_service( session: Session = Depends(get_db) ) -> DashboardService: diff --git a/affiche-backend/affiche/external/jellyfin/service/jellyfin_service.py b/affiche-backend/affiche/external/jellyfin/service/jellyfin_service.py index 07bf0ac..1cf9555 100644 --- a/affiche-backend/affiche/external/jellyfin/service/jellyfin_service.py +++ b/affiche-backend/affiche/external/jellyfin/service/jellyfin_service.py @@ -162,6 +162,23 @@ def get_recently_added_items(self, library_id: str, limit: int) -> List[Jellyfin logger.exception("Error fetching recently added items for library %s", library_id) raise + def get_recently_added_seasons(self, library_id: str, limit: int) -> Dict[str, str]: + try: + data = self._get("/Items", params={ + 'Recursive': 'true', + 'IncludeItemTypes': 'Season', + 'ParentId': library_id, + 'SortBy': 'DateCreated', + 'SortOrder': 'Descending', + 'Limit': limit, + }) + return {season['Id']: season['SeriesId'] for season in data.get('Items', []) + if season.get('Id') and season.get('SeriesId')} + + except Exception: + logger.exception("Error fetching recently added seasons for library %s", library_id) + raise + def get_library_item(self, item_id: str, library_id: str) -> Optional[JellyfinLibraryItem]: data = self._get("/Items", params={ 'Ids': item_id, @@ -219,6 +236,26 @@ def create_collection(self, library_external_id: str, title: str, logger.exception("Failed to create Jellyfin collection '%s'", title) return None + def rename_item(self, external_id: str, title: str) -> bool: + try: + data = self._get("/Items", params={'Ids': external_id, + 'Fields': 'SortName,Overview,LockedFields'}) + items = data.get('Items', []) + if not items: + logger.warning("Item %s not found on Jellyfin", external_id) + return False + + locked = list(items[0].get('LockedFields') or []) + if 'Name' not in locked: + locked.append('Name') + + payload = {**items[0], 'Name': title, 'LockedFields': locked} + self._post(f"/Items/{external_id}", data=json.dumps(payload)) + return True + except Exception: + logger.exception("Failed to rename Jellyfin item %s", external_id) + return False + def rename_collection(self, external_id: str, title: str) -> bool: try: data = self._get("/Items", params={'Ids': external_id, 'Fields': 'SortName,Overview'}) diff --git a/affiche-backend/affiche/external/plex/service/plex_service.py b/affiche-backend/affiche/external/plex/service/plex_service.py index fa00879..65c3e31 100644 --- a/affiche-backend/affiche/external/plex/service/plex_service.py +++ b/affiche-backend/affiche/external/plex/service/plex_service.py @@ -152,6 +152,19 @@ def get_recently_added_items(self, library_id: int, limit: int) -> List[PlexLibr logger.exception("Error fetching recently added items") raise + def get_recently_added_seasons(self, library_id: int, limit: int) -> Dict[str, str]: + try: + section = self.plex.library.sectionByID(library_id) + seasons = section.search(libtype='season', sort='addedAt:desc', maxresults=limit) + return {str(season.ratingKey): str(season.parentRatingKey) for season in seasons} + + except NotFound: + logger.error(f"Library with ID {library_id} not found") + raise + except Exception: + logger.exception("Error fetching recently added seasons") + raise + def _to_library_item(self, item, library_id: int) -> PlexLibraryItem: data = self._extract_item_metadata(item) return PlexLibraryItem( @@ -235,6 +248,14 @@ def create_collection(self, library_external_id: str, title: str, logger.exception("Failed to create Plex collection '%s'", title) return None + def rename_item(self, external_id: str, title: str) -> bool: + try: + self.plex.fetchItem(int(external_id)).editTitle(title) + return True + except Exception: + logger.exception("Failed to rename Plex item %s", external_id) + return False + def rename_collection(self, external_id: str, title: str) -> bool: try: self.plex.fetchItem(int(external_id)).editTitle(title) diff --git a/affiche-backend/affiche/external/poster/poster_service.py b/affiche-backend/affiche/external/poster/poster_service.py index d24e044..5ae1935 100644 --- a/affiche-backend/affiche/external/poster/poster_service.py +++ b/affiche-backend/affiche/external/poster/poster_service.py @@ -3,17 +3,28 @@ from typing import Callable, NamedTuple, Optional, List, TypeVar from affiche.app.service_configuration.exceptions import NoProvidersConfiguredError -from affiche.external.poster.provider.base_provider import ExternalProvider +from affiche.external.poster import season_match +from affiche.external.poster.season_match import SeasonMatchSuggestion +from affiche.external.poster.provider.base_provider import ExternalProvider, SeriesFacts logger = logging.getLogger(__name__) T = TypeVar("T") +class TitleMatch(NamedTuple): + title: str + provider: str + @dataclass class MediaIds: tmdb_id: Optional[int] = None tvdb_id: Optional[int] = None +@dataclass(frozen=True) +class TmdbSeasonMatch: + tmdb_id: int + season_number: int + class ProviderPoster(NamedTuple): url: str provider: str @@ -85,12 +96,14 @@ def find_best_season_poster(self, tvdb_id: int, season_number: int, provider_order: List[str], - language: Optional[str] = None + language: Optional[str] = None, + tmdb_match: Optional[TmdbSeasonMatch] = None, ) -> Optional[ProviderPoster]: for provider in self._get_providers_by_order(provider_order): + ids, number = self._season_lookup(provider, tmdb_id, season_number, tmdb_match) result = self._ask(provider, lambda p=provider: p.get_season_poster( - season_number=season_number, - tmdb_id=tmdb_id, + season_number=number, + tmdb_id=ids, tvdb_id=tvdb_id, language=language ), None) @@ -124,20 +137,31 @@ def get_all_season_posters(self, tmdb_id: Optional[int] = None, tvdb_id: Optional[int] = None, language: Optional[str] = None, - provider_name: Optional[str] = None + provider_name: Optional[str] = None, + tmdb_match: Optional[TmdbSeasonMatch] = None, ) -> List[ProviderPoster]: all_posters: List[ProviderPoster] = [] for provider in self._providers_to_ask(provider_name): + ids, number = self._season_lookup(provider, tmdb_id, season_number, tmdb_match) all_posters.extend(self._tag(provider, lambda p=provider: p.get_all_season_posters( - season_number=season_number, - tmdb_id=tmdb_id, + season_number=number, + tmdb_id=ids, tvdb_id=tvdb_id, language=language, ))) return all_posters + @staticmethod + def _season_lookup(provider: ExternalProvider, + tmdb_id: Optional[int], + season_number: int, + tmdb_match: Optional[TmdbSeasonMatch]) -> tuple[Optional[int], int]: + if tmdb_match and provider.name == "tmdb": + return tmdb_match.tmdb_id, tmdb_match.season_number + return tmdb_id, season_number + def get_all_collection_posters(self, collection_id: int, language: Optional[str] = None, @@ -166,6 +190,75 @@ def find_collection_id(self, movie_tmdb_ids: List[int]) -> Optional[int]: return found return None + MAX_CANDIDATES = 4 + + def suggest_season_match(self, + show_title: str, + season_number: int, + tvdb_id: Optional[int] = None) -> Optional[SeasonMatchSuggestion]: + tvdb, tmdb = self._get_provider("tvdb"), self._get_provider("tmdb") + if not tvdb or not tmdb or not tvdb_id: + return None + + target = self._ask(tvdb, lambda: tvdb.describe_season(season_number, tvdb_id=tvdb_id), None) + if not target or not season_match.is_distinctive(target.name, season_number): + return None + + for query in season_match.queries(show_title, target.name): + found = self._ask(tmdb, lambda q=query: tmdb.find_series(q), []) or [] + for candidate in found[:self.MAX_CANDIDATES]: + facts = self._ask(tmdb, lambda c=candidate: tmdb.describe_series(c.id), None) + if not facts: + continue + picked = season_match.pick_season(facts, target) + if picked: + return SeasonMatchSuggestion( + tmdb_id=facts.id, + season_number=picked.number, + series_name=facts.name, + reason=season_match.describe(facts, picked, target), + ) + return None + + def title_from_ids(self, media_type: str, language: str, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None) -> Optional[TitleMatch]: + if not tmdb_id and not tvdb_id: + return None + + for provider in self.providers: + found = self._ask(provider, lambda p=provider: p.get_translated_title( + media_type=media_type, language=language, + tmdb_id=tmdb_id, tvdb_id=tvdb_id, + ), None) + if found: + return TitleMatch(title=found, provider=provider.name) + return None + + def alternative_titles(self, media_type: str, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None) -> List[str]: + if not tmdb_id and not tvdb_id: + return [] + + seen: set[str] = set() + names: List[str] = [] + for provider in self.providers: + found = self._ask(provider, lambda p=provider: p.get_alternative_titles( + media_type=media_type, tmdb_id=tmdb_id, tvdb_id=tvdb_id), []) or [] + for name in found: + key = name.casefold() + if key not in seen: + seen.add(key) + names.append(name) + return names + + def describe_tmdb_series(self, tmdb_id: int) -> Optional[SeriesFacts]: + tmdb = self._get_provider("tmdb") + if not tmdb: + return None + return self._ask(tmdb, lambda: tmdb.describe_series(tmdb_id), None) + def _collection_providers(self, provider_name: Optional[str]) -> List[ExternalProvider]: return [p for p in self._providers_to_ask(provider_name) if p.supports_collections] diff --git a/affiche-backend/affiche/external/poster/provider/base_provider.py b/affiche-backend/affiche/external/poster/provider/base_provider.py index 19632e1..a43104c 100644 --- a/affiche-backend/affiche/external/poster/provider/base_provider.py +++ b/affiche-backend/affiche/external/poster/provider/base_provider.py @@ -1,5 +1,6 @@ import threading from abc import ABC, abstractmethod +from dataclasses import dataclass, field from enum import Enum from typing import Optional, List @@ -26,6 +27,20 @@ def __new__(cls, url: str, *, language: Optional[str] = None, textless: Optional image.height = height or None return image +@dataclass(frozen=True) +class SeriesSeason: + number: int + name: Optional[str] = None + episode_count: Optional[int] = None + year: Optional[int] = None + +@dataclass(frozen=True) +class SeriesFacts: + id: int + name: str + year: Optional[int] = None + seasons: List[SeriesSeason] = field(default_factory=list) + class ExternalProvider(ABC): requires_api_key: bool = True @@ -135,6 +150,28 @@ def search_by_title( ) -> Optional[str]: return None + def describe_season( + self, + season_number: int, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None, + ) -> Optional[SeriesSeason]: + return None + + def find_series(self, title: str, year: Optional[int] = None) -> List[SeriesFacts]: + return [] + + def describe_series(self, series_id: int) -> Optional[SeriesFacts]: + return None + + def get_alternative_titles( + self, + media_type: str, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None, + ) -> List[str]: + return [] + def get_translated_title( self, media_type: str, diff --git a/affiche-backend/affiche/external/poster/provider/tmdb.py b/affiche-backend/affiche/external/poster/provider/tmdb.py index 2917902..6acf357 100644 --- a/affiche-backend/affiche/external/poster/provider/tmdb.py +++ b/affiche-backend/affiche/external/poster/provider/tmdb.py @@ -4,7 +4,12 @@ import requests from affiche.config.http_config import HTTP_TIMEOUT -from affiche.external.poster.provider.base_provider import ExternalProvider, PosterImage +from affiche.external.poster.provider.base_provider import ( + ExternalProvider, + PosterImage, + SeriesFacts, + SeriesSeason, +) logger = logging.getLogger(__name__) @@ -111,6 +116,69 @@ def search_by_title( logger.error(f"Error searching TMDB for '{title}': {e}") return None + def find_series(self, title: str, year: Optional[int] = None) -> List[SeriesFacts]: + try: + params = {"query": title} + if year: + params["first_air_date_year"] = str(year) + response = self.session.get(f"{self.base_url}/search/tv", params=params, + timeout=HTTP_TIMEOUT) + response.raise_for_status() + return [SeriesFacts(id=r["id"], name=r.get("name") or "", + year=self._year(r.get("first_air_date"))) + for r in response.json().get("results", []) if r.get("id")] + except requests.RequestException as e: + logger.error(f"Error searching TMDB series for '{title}': {e}") + return [] + + def describe_series(self, series_id: int) -> Optional[SeriesFacts]: + try: + response = self.session.get(f"{self.base_url}/tv/{series_id}", timeout=HTTP_TIMEOUT) + response.raise_for_status() + data = response.json() + return SeriesFacts( + id=data["id"], + name=data.get("name") or "", + year=self._year(data.get("first_air_date")), + seasons=[SeriesSeason(number=s["season_number"], + name=s.get("name"), + episode_count=s.get("episode_count") or None, + year=self._year(s.get("air_date"))) + for s in data.get("seasons", []) if s.get("season_number") is not None], + ) + except requests.RequestException as e: + logger.error(f"Error describing TMDB series {series_id}: {e}") + return None + + def get_alternative_titles( + self, + media_type: str, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None, + ) -> List[str]: + if not tmdb_id: + return [] + try: + provider_media_type = self._get_provider_media_type(media_type) + response = self.session.get( + f"{self.base_url}/{provider_media_type}/{tmdb_id}", + params={"append_to_response": "alternative_titles"}, timeout=HTTP_TIMEOUT) + response.raise_for_status() + data = response.json() + + if provider_media_type == "movie": + primary = [data.get("title"), data.get("original_title")] + entries = (data.get("alternative_titles") or {}).get("titles") or [] + else: + primary = [data.get("name"), data.get("original_name")] + entries = (data.get("alternative_titles") or {}).get("results") or [] + + names = primary + [entry.get("title") for entry in entries] + return [name for name in names if name] + except requests.RequestException as e: + logger.error(f"Error fetching TMDB alternative titles for {tmdb_id}: {e}") + return [] + def get_translated_title( self, media_type: str, @@ -202,5 +270,12 @@ def _image(self, poster: dict) -> PosterImage: width=poster.get("width"), height=poster.get("height")) + @staticmethod + def _year(raw) -> Optional[int]: + try: + return int(str(raw)[:4]) + except (TypeError, ValueError): + return None + def _get_provider_media_type(self, media_type: str) -> str: return "movie" if media_type == "movie" else "tv" diff --git a/affiche-backend/affiche/external/poster/provider/tvdb.py b/affiche-backend/affiche/external/poster/provider/tvdb.py index f088d7e..49ca41c 100644 --- a/affiche-backend/affiche/external/poster/provider/tvdb.py +++ b/affiche-backend/affiche/external/poster/provider/tvdb.py @@ -5,7 +5,12 @@ import requests from tvdb_v4_official import TVDB -from affiche.external.poster.provider.base_provider import BaseUrlMode, ExternalProvider, PosterImage +from affiche.external.poster.provider.base_provider import ( + BaseUrlMode, + ExternalProvider, + PosterImage, + SeriesSeason, +) logger = logging.getLogger(__name__) @@ -88,6 +93,27 @@ def get_all_season_posters(self, return [] return self._fetch_all_season_posters(tvdb_id, season_number) + def describe_season(self, + season_number: int, + tmdb_id: Optional[int] = None, + tvdb_id: Optional[int] = None) -> Optional[SeriesSeason]: + if not tvdb_id: + return None + try: + season = self._official_season(tvdb_id, season_number) + if not season: + return None + extended = self.tvdb.get_season_extended(season["id"]) or {} + return SeriesSeason( + number=season_number, + name=self._season_name(season["id"], extended), + episode_count=len(extended.get("episodes") or []) or None, + year=self._year(extended.get("year")), + ) + except Exception as e: + logger.error(f"Error describing TVDB season {tvdb_id} S{season_number}: {e}") + return None + def search_by_title( self, title: str, @@ -138,6 +164,29 @@ def test_connection(self, api_token) -> bool: except requests.RequestException: return False + def _official_season(self, tvdb_id: int, season_number: int) -> Optional[dict]: + seasons = (self.tvdb.get_series_extended(tvdb_id) or {}).get("seasons") or [] + official = [s for s in seasons + if (s.get("type") or {}).get("type", "official") == "official"] + return next((s for s in (official or seasons) if s.get("number") == season_number), None) + + def _season_name(self, season_id: int, extended: dict) -> Optional[str]: + name = (extended.get("name") or "").strip() + if name: + return name + try: + translation = self.tvdb.get_season_translation(season_id, "eng") or {} + return (translation.get("name") or "").strip() or None + except Exception: + return None + + @staticmethod + def _year(raw) -> Optional[int]: + try: + return int(str(raw)[:4]) + except (TypeError, ValueError): + return None + def _fetch_season_poster( self, tvdb_id: int, diff --git a/affiche-backend/affiche/external/poster/season_match.py b/affiche-backend/affiche/external/poster/season_match.py new file mode 100644 index 0000000..1c50674 --- /dev/null +++ b/affiche-backend/affiche/external/poster/season_match.py @@ -0,0 +1,81 @@ +import re +from typing import Iterable, List, NamedTuple, Optional + +from affiche.external.poster.provider.base_provider import SeriesFacts, SeriesSeason + +_GENERIC = re.compile( + r"^(season|saison|serie|series|staffel|temporada|stagione|seizoen|sezon)?\s*0*(\d+)?$", + re.IGNORECASE) +_SPECIALS = re.compile(r"^(specials?|extras?|hors[- ]s[ée]rie)$", re.IGNORECASE) + +YEAR_SLACK = 1 + +class SeasonMatchSuggestion(NamedTuple): + tmdb_id: int + season_number: int + series_name: str + reason: str + +def is_distinctive(name: Optional[str], season_number: int) -> bool: + text = (name or "").strip() + if not text or _SPECIALS.match(text): + return False + match = _GENERIC.match(text) + if not match: + return True + number = match.group(2) + return number is not None and int(number) != season_number and match.group(1) is None + +def missing_seasons(facts: Optional[SeriesFacts], + library_season_numbers: Iterable[int]) -> List[int]: + if facts is None: + return [] + theirs = {season.number for season in facts.seasons if season.number != 0} + return sorted({n for n in library_season_numbers if n != 0} - theirs) + +def pick_season(candidate: SeriesFacts, target: SeriesSeason) -> Optional[SeriesSeason]: + seasons = [s for s in candidate.seasons if s.number != 0] + if not seasons: + return None + + by_episodes = [s for s in seasons if _episodes_agree(s, target)] + dated = [s for s in (by_episodes or seasons) if _years_agree(s, target, candidate)] + + if len(seasons) == 1: + return seasons[0] if dated or _nothing_to_check(target) else None + if len(by_episodes) == 1 and (dated or _nothing_to_check(target)): + return by_episodes[0] + if len(dated) == 1 and by_episodes: + return dated[0] + return None + +def describe(candidate: SeriesFacts, season: SeriesSeason, target: SeriesSeason) -> str: + parts = [] + if season.episode_count and target.episode_count: + parts.append(f"{season.episode_count} episodes") + year = season.year or candidate.year + if year: + parts.append(str(year)) + detail = f" ({', '.join(parts)})" if parts else "" + return f"Matched “{target.name}” to {candidate.name}{detail}" + +def queries(show_title: str, season_name: str) -> List[str]: + name = season_name.strip() + show = show_title.strip() + if not show or name.lower().startswith(show.lower()): + return [name] + return [f"{show}: {name}", name] + +def _episodes_agree(season: SeriesSeason, target: SeriesSeason) -> bool: + return (season.episode_count is not None + and target.episode_count is not None + and season.episode_count == target.episode_count) + +def _years_agree(season: SeriesSeason, target: SeriesSeason, candidate: SeriesFacts) -> bool: + theirs = season.year or candidate.year + return (theirs is not None + and target.year is not None + and abs(theirs - target.year) <= YEAR_SLACK) + +def _nothing_to_check(target: SeriesSeason) -> bool: + return target.year is None and target.episode_count is None diff --git a/affiche-backend/tests/test_incremental_sync.py b/affiche-backend/tests/test_incremental_sync.py index 87b21c4..21e0e86 100644 --- a/affiche-backend/tests/test_incremental_sync.py +++ b/affiche-backend/tests/test_incremental_sync.py @@ -12,7 +12,7 @@ ) from affiche.app.mediaserver.library.settings.model.library_settings import AutoPickupAction from affiche.app.mediaserver.library.sync.incremental import ( - FULL_SYNC_MAX_AGE, RECENT_ITEM_LIMIT, may_run_incrementally, + FULL_SYNC_MAX_AGE, RECENT_ITEM_LIMIT, may_run_incrementally, shows_gaining_seasons, ) from affiche.app.mediaserver.model.media_server import MediaServerType from affiche.app.mediaserver.service.jellyfin_sync_service import JellyfinSynchronisationService @@ -69,7 +69,8 @@ def _plex(recent, full=None): connector.get_library_items.return_value = full if full is not None else recent return svc, library_service, settings_service, connector -LIBRARY = SimpleNamespace(name="L", external_id="7", id=7) +LIBRARY = SimpleNamespace(name="L", external_id="7", id=7, type="movie") +SHOW_LIBRARY = SimpleNamespace(name="S", external_id="8", id=8, type="show") @pytest.mark.parametrize("build,run", [ (_jellyfin, "_sync_single_library"), @@ -132,6 +133,89 @@ def test_a_filled_window_falls_back_to_a_full_enumeration(build, run): library_service.reconcile_deletions.assert_called_once() settings_service.mark_full_sync.assert_called_once() +def test_a_season_we_hold_asks_for_nothing(): + assert shows_gaining_seasons({"s1": "show-a"}, {"s1"}, set()) == [] + +def test_a_new_season_names_its_show_once(): + recent = {"s1": "show-a", "s2": "show-a", "s3": "show-b", "s4": "show-c"} + + assert shows_gaining_seasons(recent, {"s4"}, set()) == ["show-a", "show-b"] + +def test_a_show_the_item_window_already_lists_is_left_to_it(): + assert shows_gaining_seasons({"s1": "show-a"}, set(), {"show-a"}) == [] + +def test_a_window_of_nothing_but_new_seasons_gives_up(): + recent = {f"s{i}": f"show-{i}" for i in range(RECENT_ITEM_LIMIT)} + + assert shows_gaining_seasons(recent, set(), set()) is None + +def _show(i): + show = _item(i) + show.type = "show" + return show + +def _with_seasons(build, recent_seasons, known): + svc, library_service, settings_service, connector = build([_item(1)], full=[_item(1), _item(2)]) + connector.get_recently_added_seasons.return_value = recent_seasons + connector.get_library_item.side_effect = lambda show_id, *_: _show(show_id) + connector.get_show_seasons.return_value = [] + svc.season_service = MagicMock() + svc.season_service.find_known_external_ids.return_value = known + svc._sync_seasons = MagicMock() + svc._episodes_tracked = lambda library: False + return svc, library_service, settings_service, connector + +@pytest.mark.parametrize("build,run", [ + (_jellyfin, "_sync_single_library"), + (_plex, "_sync_library"), +]) +def test_incremental_rereads_an_old_show_that_gained_a_season(build, run): + svc, library_service, _, connector = _with_seasons(build, {"s9": "42"}, known=set()) + + getattr(svc, run)(connector, SHOW_LIBRARY, incremental=True) + + connector.get_library_items.assert_not_called() + assert [show.id for show in svc._sync_seasons.call_args.args[2]] == ["42"] + library_service.reconcile_deletions.assert_not_called() + +@pytest.mark.parametrize("build,run", [ + (_jellyfin, "_sync_single_library"), + (_plex, "_sync_library"), +]) +def test_incremental_fetches_no_show_when_every_recent_season_is_known(build, run): + svc, _, _, connector = _with_seasons(build, {"s9": "42"}, known={"s9"}) + + getattr(svc, run)(connector, SHOW_LIBRARY, incremental=True) + + connector.get_library_item.assert_not_called() + svc._sync_seasons.assert_not_called() + +@pytest.mark.parametrize("build,run", [ + (_jellyfin, "_sync_single_library"), + (_plex, "_sync_library"), +]) +def test_a_window_full_of_new_seasons_falls_back_to_a_full_enumeration(build, run): + recent = {f"s{i}": f"show-{i}" for i in range(RECENT_ITEM_LIMIT)} + svc, _, settings_service, connector = _with_seasons(build, recent, known=set()) + + getattr(svc, run)(connector, SHOW_LIBRARY, incremental=True) + + connector.get_library_items.assert_called_once() + settings_service.mark_full_sync.assert_called_once() + +@pytest.mark.parametrize("build,run", [ + (_jellyfin, "_sync_single_library"), + (_plex, "_sync_library"), +]) +def test_a_failed_season_lookup_does_not_fail_the_pass(build, run): + svc, library_service, _, connector = _with_seasons(build, {}, known=set()) + connector.get_recently_added_seasons.side_effect = RuntimeError("server said no") + + getattr(svc, run)(connector, SHOW_LIBRARY, incremental=True) + + connector.get_library_items.assert_not_called() + library_service.create_or_update_items_batch.assert_called_once() + class _RecordingTaskService: def __init__(self): self.kwargs = [] diff --git a/affiche-backend/tests/test_item_rename.py b/affiche-backend/tests/test_item_rename.py new file mode 100644 index 0000000..c4c868b --- /dev/null +++ b/affiche-backend/tests/test_item_rename.py @@ -0,0 +1,290 @@ +import json +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import Library, LibraryItem, LibraryItemSearch, LibrarySearch +from affiche.app.mediaserver.library.service.item_rename_service import ( + ALREADY_CORRECT, + ItemRenameError, + ItemRenameService, + NO_ID, + NOT_CONFIGURED, + NOT_FOUND, +) +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.config import Base +from affiche.config.exceptions.exceptions import (LibraryItemNotFoundException, + LibraryNotFoundException) +from affiche.external.poster.poster_service import TitleMatch + +@pytest.fixture +def db(): + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + +@pytest.fixture +def library(db): + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t", + )) + db.flush() + service = LibraryService(db) + service.create(Library(media_server_id=server.id, external_id="sec-1", name="Movies", + type="movie", language="fr-FR", enabled=True)) + db.commit() + stored_library = service.find_libraries(LibrarySearch(media_server_id=server.id))[0] + + service.create_or_update_items_batch([ + LibraryItem(library_id=stored_library.id, external_id="100", + title="My.movie.2012.1080p", type="movie"), + ]) + db.commit() + return server.id, stored_library + +def _item(db, library_id: int) -> LibraryItem: + return LibraryService(db).find_items(LibraryItemSearch(library_id=library_id))[0] + +def _service(db, connector: MagicMock, aggregator=None) -> ItemRenameService: + factory = MagicMock() + factory.get.return_value = connector + return ItemRenameService(db, connector_factory=factory, poster_aggregator=aggregator) + +def _connector(rename_item=True) -> MagicMock: + connector = MagicMock() + connector.rename_item.return_value = rename_item + return connector + +def test_renaming_writes_to_the_media_server_then_records_the_row(db, library): + server_id, lib = library + connector = _connector() + + renamed = _service(db, connector).rename_item(server_id, lib.id, _item(db, lib.id).id, + "My Movie") + + connector.rename_item.assert_called_once_with("100", "My Movie") + assert renamed.title == "My Movie" + assert _item(db, lib.id).title == "My Movie" + +def test_a_server_that_refuses_leaves_our_row_alone(db, library): + server_id, lib = library + + with pytest.raises(ItemRenameError): + _service(db, _connector(rename_item=False)).rename_item( + server_id, lib.id, _item(db, lib.id).id, "My Movie") + + assert _item(db, lib.id).title == "My.movie.2012.1080p" + +def test_the_title_is_trimmed_and_the_unchanged_case_costs_no_upstream_call(db, library): + server_id, lib = library + connector = _connector() + service = _service(db, connector) + item_id = _item(db, lib.id).id + + service.rename_item(server_id, lib.id, item_id, " My Movie ") + assert _item(db, lib.id).title == "My Movie" + + service.rename_item(server_id, lib.id, item_id, "My Movie") + connector.rename_item.assert_called_once() + +@pytest.mark.parametrize("title", ["", " "]) +def test_a_blank_title_is_refused_before_the_server_is_asked(db, library, title): + server_id, lib = library + connector = _connector() + + with pytest.raises(ItemRenameError): + _service(db, connector).rename_item(server_id, lib.id, _item(db, lib.id).id, title) + + connector.rename_item.assert_not_called() + +def test_a_title_longer_than_the_column_is_refused(db, library): + server_id, lib = library + connector = _connector() + + with pytest.raises(ItemRenameError): + _service(db, connector).rename_item(server_id, lib.id, _item(db, lib.id).id, "x" * 1025) + + connector.rename_item.assert_not_called() + +def test_a_service_with_no_connector_refuses_rather_than_writing_locally(db, library): + server_id, lib = library + + with pytest.raises(ItemRenameError): + ItemRenameService(db).rename_item(server_id, lib.id, _item(db, lib.id).id, "My Movie") + + assert _item(db, lib.id).title == "My.movie.2012.1080p" + +def test_an_item_in_another_media_servers_library_is_not_reachable(db, library): + server_id, lib = library + other = MediaServerPersistenceConnector(db).create(MediaServer( + name="Other", type=MediaServerType.PLEX, url="http://y", token="t2")) + db.commit() + connector = _connector() + + with pytest.raises(LibraryNotFoundException): + _service(db, connector).rename_item(other.id, lib.id, _item(db, lib.id).id, "My Movie") + + connector.rename_item.assert_not_called() + +def test_a_missing_item_is_not_found_rather_than_renamed(db, library): + server_id, lib = library + connector = _connector() + + with pytest.raises(LibraryItemNotFoundException): + _service(db, connector).rename_item(server_id, lib.id, 9999, "My Movie") + + connector.rename_item.assert_not_called() + +def _aggregator(matched=None) -> MagicMock: + aggregator = MagicMock() + aggregator.title_from_ids.return_value = matched + return aggregator + +def _identify(db, library_id: int, **ids) -> LibraryItem: + item = _item(db, library_id) + for field, value in ids.items(): + setattr(item, field, value) + stored = LibraryService(db).library_repo.create_or_update_item(item) + db.commit() + return stored + +def test_the_title_comes_from_the_id_and_is_labelled_with_who_gave_it(db, library): + server_id, lib = library + item = _identify(db, lib.id, tmdb_id=603) + aggregator = _aggregator(TitleMatch(title="The Matrix", provider="tmdb")) + + suggestions = _service(db, _connector(), aggregator).suggest_titles(server_id, lib.id, item.id) + + assert suggestions.current == "My.movie.2012.1080p" + assert (suggestions.matched.title, suggestions.matched.provider) == ("The Matrix", "tmdb") + +def test_the_language_asked_for_is_the_librarys_own(db, library): + server_id, lib = library + item = _identify(db, lib.id, tmdb_id=603) + aggregator = _aggregator(TitleMatch(title="Matrix", provider="tmdb")) + + _service(db, _connector(), aggregator).suggest_titles(server_id, lib.id, item.id) + + aggregator.title_from_ids.assert_called_once_with( + media_type="movie", language="fr", tmdb_id=603, tvdb_id=None) + +def test_an_item_with_no_id_gets_nothing_rather_than_a_guess(db, library): + server_id, lib = library + aggregator = _aggregator(matched=None) + + suggestions = _service(db, _connector(), aggregator).suggest_titles( + server_id, lib.id, _item(db, lib.id).id) + + assert suggestions.matched is None + assert suggestions.reason == NO_ID + assert suggestions.current == "My.movie.2012.1080p" + aggregator.title_from_ids.assert_not_called() + +def test_an_imdb_id_alone_is_not_usable(db, library): + server_id, lib = library + item = _identify(db, lib.id, imdb_id="tt0133093") + aggregator = _aggregator(TitleMatch(title="The Matrix", provider="tmdb")) + + _service(db, _connector(), aggregator).suggest_titles(server_id, lib.id, item.id) + + aggregator.title_from_ids.assert_not_called() + +def test_an_id_no_catalogue_recognises_says_so_rather_than_blaming_the_id(db, library): + server_id, lib = library + item = _identify(db, lib.id, tmdb_id=999999) + + suggestions = _service(db, _connector(), _aggregator(None)).suggest_titles( + server_id, lib.id, item.id) + + assert (suggestions.matched, suggestions.reason) == (None, NOT_FOUND) + +def test_a_catalogue_that_agrees_reports_agreement_not_a_missing_id(db, library): + server_id, lib = library + item = _identify(db, lib.id, tmdb_id=603) + aggregator = _aggregator(TitleMatch(title=item.title, provider="tmdb")) + + suggestions = _service(db, _connector(), aggregator).suggest_titles(server_id, lib.id, item.id) + + assert (suggestions.matched, suggestions.reason) == (None, ALREADY_CORRECT) + +def test_suggestions_still_answer_when_no_provider_is_configured(db, library): + server_id, lib = library + item = _identify(db, lib.id, tmdb_id=603) + + suggestions = ItemRenameService(db).suggest_titles(server_id, lib.id, item.id) + + assert (suggestions.matched, suggestions.reason) == (None, NOT_CONFIGURED) + assert suggestions.current == "My.movie.2012.1080p" + +def test_plex_locks_the_title_it_writes(): + from affiche.external.plex.service.plex_service import PlexService + + service = PlexService("http://plex", "token") + item = MagicMock() + service._plex = MagicMock() + service._plex.fetchItem.return_value = item + + assert service.rename_item("100", "My Movie") is True + item.editTitle.assert_called_once_with("My Movie") + +def test_plex_reports_a_failure_rather_than_raising(): + from affiche.external.plex.service.plex_service import PlexService + + service = PlexService("http://plex", "token") + service._plex = MagicMock() + service._plex.fetchItem.side_effect = Exception("gone") + + assert service.rename_item("100", "My Movie") is False + +def test_jellyfin_adds_name_to_the_locked_fields_it_read_back(): + from affiche.external.jellyfin.service.jellyfin_service import JellyfinService + + service = JellyfinService("http://jellyfin", "key") + service._get = MagicMock(return_value={'Items': [{'Id': 'abc', 'Name': 'My.movie.2012.1080p', + 'LockedFields': ['Overview']}]}) + service._post = MagicMock() + + assert service.rename_item("abc", "My Movie") is True + payload = json.loads(service._post.call_args.kwargs['data']) + assert payload['Name'] == "My Movie" + assert payload['LockedFields'] == ['Overview', 'Name'] + +def test_jellyfin_reports_a_failure_when_the_item_is_gone(): + from affiche.external.jellyfin.service.jellyfin_service import JellyfinService + + service = JellyfinService("http://jellyfin", "key") + service._get = MagicMock(return_value={'Items': []}) + service._post = MagicMock() + + assert service.rename_item("abc", "My Movie") is False + service._post.assert_not_called() + +def test_the_rename_endpoints_are_session_gated(): + with TestClient(main_module.app) as client: + base = "/affiche/media-servers/1/libraries/1/items/1" + assert client.patch(base, json={"title": "X"}).status_code == 401 + assert client.get(f"{base}/title/suggestions").status_code == 401 + +@pytest.mark.parametrize("title", ["", " ", "x" * 1025]) +def test_the_rename_endpoint_rejects_a_title_the_column_cannot_hold(authenticated_app, title): + with TestClient(authenticated_app) as client: + response = client.patch("/affiche/media-servers/1/libraries/1/items/1", + json={"title": title}) + assert response.status_code == 422 diff --git a/affiche-backend/tests/test_poster_aggregator.py b/affiche-backend/tests/test_poster_aggregator.py index 8210520..fbf79ab 100644 --- a/affiche-backend/tests/test_poster_aggregator.py +++ b/affiche-backend/tests/test_poster_aggregator.py @@ -10,11 +10,13 @@ class StubProvider(ExternalProvider): def __init__(self, name: str, poster: Optional[str] = None, title: Optional[str] = None, search_id: Optional[str] = None, raises: bool = False, - posters: Optional[List[str]] = None): + posters: Optional[List[str]] = None, + alternatives: Optional[List[str]] = None): self._name = name self._poster = poster self._posters = posters self._title = title + self._alternatives = alternatives or [] self._search_id = search_id self._raises = raises self.calls = 0 @@ -59,6 +61,9 @@ def get_translated_title(self, media_type, language, tmdb_id=None, tvdb_id=None, season_number=None) -> Optional[str]: return self._answer(self._title) + def get_alternative_titles(self, media_type, tmdb_id=None, tvdb_id=None) -> List[str]: + return self._answer(list(self._alternatives)) + def test_connection(self, api_token) -> bool: return True @@ -252,6 +257,83 @@ def test_a_raising_provider_falls_through_to_the_next(self): assert aggregator.get_translated_title(media_type="movie", language="fr", tvdb_id=2) == "Blade Runner" +class TestTitleFromIds: + + def test_names_the_provider_that_answered(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", title="The Matrix"), + StubProvider("tvdb", title="Matrix, The"), + ]) + + found = aggregator.title_from_ids(media_type="movie", language="en", tmdb_id=603) + + assert (found.title, found.provider) == ("The Matrix", "tmdb") + + def test_a_provider_that_cannot_answer_falls_through_to_the_next(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", title=None), + StubProvider("tvdb", title="Blade Runner"), + ]) + + assert aggregator.title_from_ids(media_type="movie", language="fr", + tvdb_id=2).provider == "tvdb" + + def test_a_raising_provider_falls_through_to_the_next(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", raises=True), + StubProvider("tvdb", title="Blade Runner"), + ]) + + assert aggregator.title_from_ids(media_type="movie", language="fr", + tvdb_id=2).title == "Blade Runner" + + def test_no_answer_anywhere_is_none_rather_than_an_empty_match(self): + aggregator = PosterAggregatorService([StubProvider("tmdb", title=None)]) + + assert aggregator.title_from_ids(media_type="movie", language="en", tmdb_id=1) is None + + def test_no_id_asks_nobody(self): + tmdb = StubProvider("tmdb", title="The Matrix") + aggregator = PosterAggregatorService([tmdb]) + + assert aggregator.title_from_ids(media_type="movie", language="en") is None + assert tmdb.calls == 0 + +class TestAlternativeTitles: + + def test_every_provider_contributes_its_names(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", alternatives=["Act of Vengeance"]), + StubProvider("tvdb", alternatives=["The Terrorist"]), + ]) + + assert aggregator.alternative_titles("movie", tmdb_id=51794) == [ + "Act of Vengeance", "The Terrorist"] + + def test_the_same_name_from_two_catalogues_is_listed_once(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", alternatives=["Act of Vengeance"]), + StubProvider("tvdb", alternatives=["act of vengeance", "The Terrorist"]), + ]) + + assert aggregator.alternative_titles("movie", tmdb_id=51794) == [ + "Act of Vengeance", "The Terrorist"] + + def test_a_raising_provider_costs_its_own_names_and_no_more(self): + aggregator = PosterAggregatorService([ + StubProvider("tmdb", raises=True), + StubProvider("tvdb", alternatives=["The Terrorist"]), + ]) + + assert aggregator.alternative_titles("movie", tmdb_id=51794) == ["The Terrorist"] + + def test_no_id_asks_nobody(self): + tmdb = StubProvider("tmdb", alternatives=["Act of Vengeance"]) + aggregator = PosterAggregatorService([tmdb]) + + assert aggregator.alternative_titles("movie") == [] + assert tmdb.calls == 0 + class CollectionStub(ExternalProvider): supports_collections = True diff --git a/affiche-backend/tests/test_poster_hash_skip.py b/affiche-backend/tests/test_poster_hash_skip.py index d8baa01..362e3a5 100644 --- a/affiche-backend/tests/test_poster_hash_skip.py +++ b/affiche-backend/tests/test_poster_hash_skip.py @@ -15,6 +15,7 @@ POSTER_BYTES = b"poster-bytes" STYLE_HASH = "style-fingerprint" POSTER_HASH = poster_digest(POSTER_BYTES) +UPLOADED_AT = datetime(2026, 1, 1, tzinfo=timezone.utc) def _fake_session_scope(repo): @contextmanager @@ -153,7 +154,7 @@ def test_process_season_poster_skips_upload_when_hash_matches(monkeypatch): svc = _service_with_mocks() season_service = MagicMock() connector = MagicMock() - season = _season(poster_hash=POSTER_HASH) + season = _season(poster_hash=POSTER_HASH, poster_uploaded_at=UPLOADED_AT) assert svc._process_season_poster(season_service, MagicMock(), season, _item(type="show"), "http://poster", connector, upload=True) is True @@ -163,6 +164,7 @@ def test_process_season_poster_skips_upload_when_hash_matches(monkeypatch): assert seasons == [season] assert changes["processed"] is True assert "poster_hash" not in changes + assert "poster_uploaded_at" not in changes def test_process_season_poster_uploads_and_stores_hash_when_content_differs(monkeypatch): monkeypatch.setattr(poster_module, "event_manager", MagicMock()) @@ -176,13 +178,17 @@ def test_process_season_poster_uploads_and_stores_hash_when_content_differs(monk connector, upload=True) is True connector.upload_poster.assert_called_once() - assert ([season], {"poster_hash": POSTER_HASH}) in _season_updates(season_service) + (_, uploaded), (_, generated) = _season_updates(season_service) + assert uploaded["poster_hash"] == POSTER_HASH + assert uploaded["poster_uploaded_at"] is not None + assert "poster_uploaded_at" not in generated def test_upload_existing_season_posters_skips_unchanged_seasons(): svc = _service_with_mocks() connector = MagicMock() connector.upload_poster.return_value = True - unchanged = _season(id=7, season_number=1, external_id="s1", poster_hash=POSTER_HASH) + unchanged = _season(id=7, season_number=1, external_id="s1", poster_hash=POSTER_HASH, + poster_uploaded_at=UPLOADED_AT) changed = _season(id=8, season_number=2, external_id="s2", poster_hash="stale-hash") season_service = MagicMock() season_service.get_item_seasons.return_value = [unchanged, changed] @@ -191,7 +197,10 @@ def test_upload_existing_season_posters_skips_unchanged_seasons(): connector.upload_poster.assert_called_once() assert connector.upload_poster.call_args.args[0] == "s2" - assert _season_updates(season_service) == [([changed], {"poster_hash": POSTER_HASH})] + (updated, changes), = _season_updates(season_service) + assert updated == [changed] + assert changes["poster_hash"] == POSTER_HASH + assert changes["poster_uploaded_at"] is not None def test_reset_season_posters_clears_poster_hash(monkeypatch): monkeypatch.setattr(resetter_module, "event_manager", MagicMock()) @@ -222,7 +231,7 @@ def test_reset_season_posters_leaves_a_failed_season_untouched(monkeypatch): (updated, changes), = _season_updates(season_service) assert updated == [reset_ok] - assert changes == {"processed": False, "poster_hash": None, + assert changes == {"processed": False, "poster_hash": None, "poster_uploaded_at": None, "poster_provider": None, "style_hash": None} season_service.create_or_update.assert_called_once_with([reset_ok]) assert svc._file_store.delete.call_count == 1 diff --git a/affiche-backend/tests/test_season_match_suggestion.py b/affiche-backend/tests/test_season_match_suggestion.py new file mode 100644 index 0000000..098951c --- /dev/null +++ b/affiche-backend/tests/test_season_match_suggestion.py @@ -0,0 +1,169 @@ +from typing import List, Optional + +import pytest + +from affiche.external.poster.poster_service import PosterAggregatorService +from affiche.external.poster.season_match import ( + describe, + is_distinctive, + pick_season, + queries, +) +from affiche.external.poster.provider.base_provider import ( + ExternalProvider, + SeriesFacts, + SeriesSeason, +) + +@pytest.mark.parametrize("name", [ + "Season 2", "season 2", "Saison 2", "Staffel 2", "Temporada 2", "Seizoen 2", "Series 2", + "Specials", "Extras", "", " ", None, +]) +def test_generic_names_are_not_distinctive(name): + assert is_distinctive(name, 2) is False + +@pytest.mark.parametrize("name", [ + "The Lyle and Erik Menendez Story", + "Dahmer: The Jeffrey Dahmer Story", + "The Ed Gein Story", + "Season of the Witch", +]) +def test_real_titles_are_distinctive(name): + assert is_distinctive(name, 2) is True + +def test_a_bare_number_matching_the_season_is_not_a_name(): + assert is_distinctive("2", 2) is False + +MENENDEZ = SeriesSeason(number=2, name="The Lyle and Erik Menendez Story", + episode_count=9, year=2024) + +def _single(season_year=2024, episodes=9, series_year=2024): + return SeriesFacts(id=225634, name="Monsters: The Lyle and Erik Menendez Story", + year=series_year, + seasons=[SeriesSeason(number=1, episode_count=episodes, year=season_year)]) + +def test_a_lone_agreeing_season_is_the_answer(): + picked = pick_season(_single(), MENENDEZ) + assert picked is not None and picked.number == 1 + +def test_the_season_number_is_confirmed_not_assumed(): + whole = SeriesFacts(id=1, name="Monster", year=2022, seasons=[ + SeriesSeason(number=1, episode_count=10, year=2022), + SeriesSeason(number=2, episode_count=9, year=2024), + SeriesSeason(number=3, episode_count=8, year=2025), + ]) + picked = pick_season(whole, MENENDEZ) + assert picked is not None and picked.number == 2 + +def test_a_wrong_year_is_refused(): + assert pick_season(_single(season_year=2019, series_year=2019), MENENDEZ) is None + +def test_a_wrong_episode_count_with_several_seasons_is_refused(): + ambiguous = SeriesFacts(id=1, name="Monster", year=2024, seasons=[ + SeriesSeason(number=1, episode_count=6, year=2024), + SeriesSeason(number=2, episode_count=7, year=2024), + ]) + assert pick_season(ambiguous, MENENDEZ) is None + +def test_specials_are_never_the_answer(): + only_specials = SeriesFacts(id=1, name="Monster", year=2024, + seasons=[SeriesSeason(number=0, episode_count=9, year=2024)]) + assert pick_season(only_specials, MENENDEZ) is None + +def test_a_year_within_the_slack_still_agrees(): + picked = pick_season(_single(season_year=2025, series_year=2025), MENENDEZ) + assert picked is not None + +def test_a_lone_season_carries_the_match_when_tvdb_said_nothing(): + nameless_target = SeriesSeason(number=2, name="The Ed Gein Story") + picked = pick_season(_single(season_year=None, episodes=None, series_year=None), + nameless_target) + assert picked is not None and picked.number == 1 + +def test_the_show_qualifies_the_search_first(): + assert queries("Monster", "The Ed Gein Story") == [ + "Monster: The Ed Gein Story", "The Ed Gein Story"] + +def test_a_name_already_carrying_the_show_is_not_doubled(): + assert queries("Monster", "Monster: The Ed Gein Story") == ["Monster: The Ed Gein Story"] + +def test_the_reason_names_both_sides(): + reason = describe(_single(), SeriesSeason(number=1, episode_count=9, year=2024), MENENDEZ) + assert "The Lyle and Erik Menendez Story" in reason + assert "9 episodes" in reason and "2024" in reason + +class StubProvider(ExternalProvider): + def __init__(self, name: str, season=None, found=None, described=None, raises=False): + self._name = name + self._season = season + self._found = found or {} + self._described = described or {} + self._raises = raises + self.searched: List[str] = [] + + @property + def name(self) -> str: + return self._name + + def describe_season(self, season_number, tmdb_id=None, tvdb_id=None): + if self._raises: + raise RuntimeError("tvdb is down") + return self._season + + def find_series(self, title, year=None): + self.searched.append(title) + return self._found.get(title, []) + + def describe_series(self, series_id): + return self._described.get(series_id) + + def get_movie_poster(self, tmdb_id=None, tvdb_id=None, language=None): + return None + + def get_show_poster(self, tmdb_id=None, tvdb_id=None, language=None): + return None + + def get_season_poster(self, season_number, tmdb_id=None, tvdb_id=None, language=None): + return None + + def get_all_posters(self, media_type, tmdb_id=None, tvdb_id=None, language=None): + return [] + + def get_all_season_posters(self, season_number, tmdb_id=None, tvdb_id=None, language=None): + return [] + + def test_connection(self, api_token) -> bool: + return True + +def _aggregator(season=MENENDEZ, raises=False): + tvdb = StubProvider("tvdb", season=season, raises=raises) + tmdb = StubProvider( + "tmdb", + found={"Monster: The Lyle and Erik Menendez Story": [_single()]}, + described={225634: _single()}, + ) + return tvdb, tmdb, PosterAggregatorService([tmdb, tvdb]) + +def test_the_whole_chain_resolves_monster_season_2(): + _, tmdb, aggregator = _aggregator() + + found = aggregator.suggest_season_match("Monster", season_number=2, tvdb_id=389492) + + assert found is not None + assert (found.tmdb_id, found.season_number) == (225634, 1) + assert tmdb.searched[0] == "Monster: The Lyle and Erik Menendez Story" + +def test_a_generic_season_name_is_never_searched(): + _, tmdb, aggregator = _aggregator(season=SeriesSeason(number=2, name="Season 2", + episode_count=9, year=2024)) + + assert aggregator.suggest_season_match("Monster", season_number=2, tvdb_id=389492) is None + assert tmdb.searched == [] + +def test_no_tvdb_id_means_no_suggestion(): + _, _, aggregator = _aggregator() + assert aggregator.suggest_season_match("Monster", season_number=2, tvdb_id=None) is None + +def test_a_failing_tvdb_degrades_to_no_suggestion(caplog): + _, _, aggregator = _aggregator(raises=True) + assert aggregator.suggest_season_match("Monster", season_number=2, tvdb_id=389492) is None diff --git a/affiche-backend/tests/test_season_match_sweep.py b/affiche-backend/tests/test_season_match_sweep.py new file mode 100644 index 0000000..cf49f54 --- /dev/null +++ b/affiche-backend/tests/test_season_match_sweep.py @@ -0,0 +1,209 @@ +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import ( + Library, + LibraryItem, + LibraryItemSearch, + LibrarySearch, +) +from affiche.app.mediaserver.library.seasons.library_season_service import LibrarySeasonService +from affiche.app.mediaserver.library.seasons.model.library_season import LibrarySeason +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.app.mediaserver.service.season_match_service import SeasonMatchService +from affiche.config import Base +from affiche.external.poster.season_match import SeasonMatchSuggestion, missing_seasons +from affiche.external.poster.provider.base_provider import SeriesFacts, SeriesSeason + +def _facts(*numbers): + return SeriesFacts(id=299939, name="Monster: The Lizzie Borden Story", + seasons=[SeriesSeason(number=n) for n in numbers]) + +def test_a_library_season_tmdb_does_not_have_is_missing(): + assert missing_seasons(_facts(1), [1, 2, 3, 4]) == [2, 3, 4] + +def test_an_entry_that_agrees_reports_nothing(): + assert missing_seasons(_facts(1, 2, 3), [1, 2, 3]) == [] + +def test_specials_are_ignored_on_both_sides(): + assert missing_seasons(_facts(0, 1), [0, 1]) == [] + +def test_an_unfetchable_entry_is_not_evidence(): + assert missing_seasons(None, [1, 2, 3]) == [] + +def test_numbering_the_catalogue_does_not_share_is_caught(): + assert missing_seasons(_facts(1, 2), [1, 5]) == [5] + +class FakeAggregator: + + def __init__(self, tmdb_seasons=(1,), suggestion="ok"): + self._tmdb_seasons = tmdb_seasons + self._suggestion = suggestion + self.described = 0 + self.suggested = [] + + def describe_tmdb_series(self, tmdb_id): + self.described += 1 + return _facts(*self._tmdb_seasons) if self._tmdb_seasons else None + + def suggest_season_match(self, show_title, season_number, tvdb_id=None): + self.suggested.append(season_number) + if self._suggestion is None: + return None + return SeasonMatchSuggestion(tmdb_id=225634, season_number=1, + series_name="Monsters: The Lyle and Erik Menendez Story", + reason="checked out") + +@pytest.fixture +def library(): + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine) + db = factory() + + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t")) + db.flush() + services = LibraryService(db) + services.create(Library(media_server_id=server.id, external_id="sec-1", name="Shows", + type="show", language="en", enabled=True)) + db.commit() + lib = services.find_libraries(LibrarySearch(media_server_id=server.id))[0] + + def add_show(title, tmdb_id, tvdb_id, season_numbers): + services.create_or_update_items_batch([LibraryItem( + library_id=lib.id, external_id=title, title=title, type="show", + tmdb_id=tmdb_id, tvdb_id=tvdb_id)]) + db.commit() + item = next(i for i in services.find_items(LibraryItemSearch(library_id=lib.id)) + if i.title == title) + LibrarySeasonService(db).create_or_update([ + LibrarySeason(show_id=item.id, library_id=lib.id, external_id=f"{title}-s{n}", + season_number=n, title=f"Season {n}") + for n in season_numbers]) + db.commit() + return item + + yield factory, db, server.id, lib.id, add_show + + db.close() + engine.dispose() + +def _seasons(db, library_id, item_id): + return {s.season_number: s + for s in LibrarySeasonService(db).get_item_seasons(library_id, item_id)} + +def test_a_suspect_show_gets_its_missing_seasons_corrected(library): + factory, db, server_id, library_id, add_show = library + show = add_show("Monster", "299939", "389492", [1, 2, 3, 4]) + aggregator = FakeAggregator(tmdb_seasons=(1,)) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert (report.shows_suspect, report.resolved, report.unresolved) == (1, 3, 0) + assert aggregator.suggested == [2, 3, 4] + stored = _seasons(db, library_id, show.id) + assert stored[2].tmdb_id_override == 225634 + assert stored[1].tmdb_id_override is None + +def test_a_show_tmdb_agrees_with_costs_one_call_and_no_more(library): + factory, _, server_id, library_id, add_show = library + add_show("Severance", "95396", "371980", [1, 2]) + aggregator = FakeAggregator(tmdb_seasons=(1, 2)) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert report.shows_suspect == 0 + assert aggregator.described == 1 + assert aggregator.suggested == [] + +def test_a_season_already_corrected_is_left_alone(library): + factory, db, server_id, library_id, add_show = library + show = add_show("Monster", "299939", "389492", [1, 2]) + seasons = LibrarySeasonService(db) + seasons.set_tmdb_match(_seasons(db, library_id, show.id)[2].id, 111111, 1) + aggregator = FakeAggregator(tmdb_seasons=(1,)) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert aggregator.suggested == [] + assert report.shows_suspect == 0 + assert _seasons(db, library_id, show.id)[2].tmdb_id_override == 111111 + +def test_a_season_that_does_not_resolve_is_counted_not_guessed(library): + factory, db, server_id, library_id, add_show = library + show = add_show("Monster", "299939", "389492", [1, 2, 3]) + aggregator = FakeAggregator(tmdb_seasons=(1,), suggestion=None) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert (report.resolved, report.unresolved) == (0, 2) + stored = _seasons(db, library_id, show.id) + assert stored[2].tmdb_id_override is None and stored[3].tmdb_id_override is None + +def test_a_show_without_a_tvdb_id_is_skipped(library): + factory, _, server_id, library_id, add_show = library + add_show("Monster", "299939", None, [1, 2]) + aggregator = FakeAggregator(tmdb_seasons=(1,)) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert report.shows_suspect == 0 + assert aggregator.described == 0 + +def test_a_show_without_a_tmdb_id_is_skipped(library): + factory, _, server_id, library_id, add_show = library + add_show("Monster", None, "389492", [1, 2]) + aggregator = FakeAggregator(tmdb_seasons=(1,)) + + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert report.shows_suspect == 0 + assert aggregator.described == 0 + +def test_one_failing_show_does_not_end_the_sweep(library): + factory, db, server_id, library_id, add_show = library + add_show("Explodes", "1", "2", [1, 2]) + good = add_show("Monster", "299939", "389492", [1, 2]) + + class Exploding(FakeAggregator): + def describe_tmdb_series(self, tmdb_id): + if tmdb_id == 1: + raise RuntimeError("tmdb is down for this one") + return super().describe_tmdb_series(tmdb_id) + + aggregator = Exploding(tmdb_seasons=(1,)) + report = SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id) + + assert report.shows_scanned == 2 + assert report.resolved == 1 + assert _seasons(db, library_id, good.id)[2].tmdb_id_override == 225634 + +def test_cancelling_stops_the_sweep(library): + factory, _, server_id, library_id, add_show = library + for n in range(3): + add_show(f"Show {n}", "299939", "389492", [1, 2]) + aggregator = FakeAggregator(tmdb_seasons=(1,)) + + SeasonMatchService(factory, lambda _s: aggregator).fix_library_season_matches( + server_id, library_id, cancel_check=lambda: True) + + assert aggregator.described == 0 diff --git a/affiche-backend/tests/test_season_tmdb_match.py b/affiche-backend/tests/test_season_tmdb_match.py new file mode 100644 index 0000000..4303701 --- /dev/null +++ b/affiche-backend/tests/test_season_tmdb_match.py @@ -0,0 +1,197 @@ +from typing import List, Optional + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import ( + Library, + LibraryItem, + LibraryItemSearch, + LibrarySearch, +) +from affiche.app.mediaserver.library.seasons.library_season_service import LibrarySeasonService +from affiche.app.mediaserver.library.seasons.model.library_season import LibrarySeason +from affiche.app.mediaserver.library.seasons.season_match import tmdb_season_match +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.config import Base +from affiche.external.poster.poster_service import PosterAggregatorService, TmdbSeasonMatch +from affiche.external.poster.provider.base_provider import ExternalProvider + +class RecordingProvider(ExternalProvider): + + def __init__(self, name: str): + self._name = name + self.asked: List[tuple] = [] + + @property + def name(self) -> str: + return self._name + + def _record(self, season_number, tmdb_id): + self.asked.append((tmdb_id, season_number)) + return f"{self._name}.jpg" + + def get_season_poster(self, season_number, tmdb_id=None, tvdb_id=None, + language=None) -> Optional[str]: + return self._record(season_number, tmdb_id) + + def get_all_season_posters(self, season_number, tmdb_id=None, tvdb_id=None, + language=None) -> List[str]: + return [self._record(season_number, tmdb_id)] + + def get_movie_poster(self, tmdb_id=None, tvdb_id=None, language=None): + return None + + def get_show_poster(self, tmdb_id=None, tvdb_id=None, language=None): + return None + + def get_all_posters(self, media_type, tmdb_id=None, tvdb_id=None, language=None): + return [] + + def search_by_title(self, title, media_type, year=None): + return None + + def get_translated_title(self, media_type, language, tmdb_id=None, tvdb_id=None, + season_number=None): + return None + + def test_connection(self, api_token) -> bool: + return True + +def test_no_override_means_no_match(): + season = LibrarySeason(show_id=1, library_id=1, external_id="s2", season_number=2, title="S2") + assert tmdb_season_match(season) is None + +def test_a_series_without_a_number_keeps_the_seasons_own(): + season = LibrarySeason(show_id=1, library_id=1, external_id="s2", season_number=2, title="S2", + tmdb_id_override=225634) + assert tmdb_season_match(season) == TmdbSeasonMatch(tmdb_id=225634, season_number=2) + +def test_the_anthology_case_substitutes_both(): + season = LibrarySeason(show_id=1, library_id=1, external_id="s2", season_number=2, title="S2", + tmdb_id_override=225634, tmdb_season_number_override=1) + assert tmdb_season_match(season) == TmdbSeasonMatch(tmdb_id=225634, season_number=1) + +def test_a_number_alone_is_ignored(): + season = LibrarySeason(show_id=1, library_id=1, external_id="s2", season_number=2, title="S2", + tmdb_season_number_override=1) + assert tmdb_season_match(season) is None + +@pytest.fixture +def providers(): + tmdb, tvdb = RecordingProvider("tmdb"), RecordingProvider("tvdb") + return tmdb, tvdb, PosterAggregatorService([tmdb, tvdb]) + +def test_only_tmdb_is_redirected(providers): + tmdb, tvdb, aggregator = providers + + aggregator.find_best_season_poster( + title="Monster", tmdb_id=299939, tvdb_id=389492, season_number=2, + provider_order=["tmdb", "tvdb"], + tmdb_match=TmdbSeasonMatch(tmdb_id=225634, season_number=1)) + + assert tmdb.asked == [(225634, 1)] + assert tvdb.asked == [] + +def test_the_browse_grid_is_redirected_the_same_way(providers): + tmdb, tvdb, aggregator = providers + + aggregator.get_all_season_posters( + season_number=2, tmdb_id=299939, tvdb_id=389492, + tmdb_match=TmdbSeasonMatch(tmdb_id=225634, season_number=1)) + + assert tmdb.asked == [(225634, 1)] + assert tvdb.asked == [(299939, 2)] + +def test_without_a_match_nothing_moves(providers): + tmdb, tvdb, aggregator = providers + + aggregator.get_all_season_posters(season_number=2, tmdb_id=299939, tvdb_id=389492) + + assert tmdb.asked == [(299939, 2)] + assert tvdb.asked == [(299939, 2)] + +@pytest.fixture +def show(): + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t")) + db.flush() + library_service = LibraryService(db) + library_service.create(Library(media_server_id=server.id, external_id="sec-1", name="Shows", + type="show", language="en", enabled=True)) + db.commit() + library = library_service.find_libraries(LibrarySearch(media_server_id=server.id))[0] + library_service.create_or_update_items_batch( + [LibraryItem(library_id=library.id, external_id="1", title="Monster", type="show", + tmdb_id=299939)]) + db.commit() + item = library_service.find_items(LibraryItemSearch(library_id=library.id))[0] + + seasons = LibrarySeasonService(db) + seasons.create_or_update([ + LibrarySeason(show_id=item.id, library_id=library.id, external_id=f"s{n}", + season_number=n, title=f"Season {n}") + for n in (1, 2) + ]) + db.commit() + + yield library_service, seasons, server.id, library.id, item.id + + db.close() + engine.dispose() + +def test_the_correction_is_stored_against_the_named_season(show): + library_service, seasons, server_id, library_id, item_id = show + + library_service.set_season_tmdb_match(server_id, library_id, item_id, 2, + tmdb_id=225634, tmdb_season_number=1) + + corrected = seasons.get_season(library_id, item_id, 2) + assert (corrected.tmdb_id_override, corrected.tmdb_season_number_override) == (225634, 1) + assert seasons.get_season(library_id, item_id, 1).tmdb_id_override is None + +def test_a_resync_does_not_clobber_it(show): + library_service, seasons, server_id, library_id, item_id = show + library_service.set_season_tmdb_match(server_id, library_id, item_id, 2, + tmdb_id=225634, tmdb_season_number=1) + + seasons.create_or_update([ + LibrarySeason(show_id=item_id, library_id=library_id, external_id="s2", + season_number=2, title="Season 2", tmdb_id=299939) + ]) + + corrected = seasons.get_season(library_id, item_id, 2) + assert (corrected.tmdb_id_override, corrected.tmdb_season_number_override) == (225634, 1) + +def test_clearing_the_series_clears_the_number_with_it(show): + library_service, seasons, server_id, library_id, item_id = show + library_service.set_season_tmdb_match(server_id, library_id, item_id, 2, + tmdb_id=225634, tmdb_season_number=1) + + library_service.set_season_tmdb_match(server_id, library_id, item_id, 2, + tmdb_id=None, tmdb_season_number=1) + + cleared = seasons.get_season(library_id, item_id, 2) + assert cleared.tmdb_id_override is None + assert cleared.tmdb_season_number_override is None + +def test_a_season_the_show_does_not_have_is_not_found(show): + library_service, _, server_id, library_id, item_id = show + + assert library_service.set_season_tmdb_match(server_id, library_id, item_id, 9, + tmdb_id=225634, tmdb_season_number=1) is None diff --git a/affiche-backend/tests/test_season_upload_tracking.py b/affiche-backend/tests/test_season_upload_tracking.py new file mode 100644 index 0000000..b6ef424 --- /dev/null +++ b/affiche-backend/tests/test_season_upload_tracking.py @@ -0,0 +1,225 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +import affiche.app.mediaserver.service.media_server_poster_service as poster_module +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import ( + Library, + LibraryItem, + LibraryItemSearch, + LibrarySearch, + SeasonPosterState, +) +from affiche.app.mediaserver.library.seasons.library_season_service import LibrarySeasonService +from affiche.app.mediaserver.library.seasons.model.library_season import LibrarySeason +from affiche.app.mediaserver.library.service.library_repository import LibraryRepository +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.app.mediaserver.service.media_server_poster_service import LibraryPosterService +from affiche.app.mediaserver.service.poster_resolver import PosterSource +from affiche.app.mediaserver.service.poster_uploader import PosterUploader +from affiche.config import Base + +UPLOADED_AT = datetime(2026, 1, 1, tzinfo=timezone.utc) +SERVER_HASH = "what-the-server-holds" + +class Show: + + def __init__(self, session_factory): + self.session_factory = session_factory + db = session_factory() + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t")) + db.flush() + library_service = LibraryService(db) + library_service.create(Library(media_server_id=server.id, external_id="sec-1", name="Shows", + type="show", language="en", enabled=True)) + db.commit() + self.server_id = server.id + self.library = library_service.find_libraries(LibrarySearch(media_server_id=server.id))[0] + library_service.create_or_update_items_batch( + [LibraryItem(library_id=self.library.id, external_id="show", title="Monster", + type="show")]) + db.commit() + self.item_id = library_service.find_items(LibraryItemSearch(library_id=self.library.id))[0].id + LibrarySeasonService(db).create_or_update([ + LibrarySeason(show_id=self.item_id, library_id=self.library.id, external_id=f"s{n}", + season_number=n, title=f"Season {n}") + for n in (1, 2) + ]) + db.commit() + db.close() + + def set_item(self, **state): + db = self.session_factory() + repo = LibraryRepository(db) + item = repo.get_library_item(self.library.id, self.item_id) + repo.create_or_update_item(item.model_copy(update=state)) + db.close() + + def set_season(self, number: int, **state): + db = self.session_factory() + seasons = LibrarySeasonService(db) + seasons.update_seasons([seasons.get_season(self.library.id, self.item_id, number)], + SeasonPosterState(**state)) + db.close() + + def season(self, number: int) -> LibrarySeason: + db = self.session_factory() + try: + return LibrarySeasonService(db).get_season(self.library.id, self.item_id, number) + finally: + db.close() + + def uploaded(self): + self.set_item(processed=True, poster_uploaded_at=UPLOADED_AT, poster_hash=SERVER_HASH) + self.set_season(1, processed=True, poster_uploaded_at=UPLOADED_AT, poster_hash=SERVER_HASH) + +@pytest.fixture +def show(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'seasons.db'}") + Base.metadata.create_all(engine) + yield Show(sessionmaker(bind=engine)) + engine.dispose() + +def _uploader(show: Show) -> PosterUploader: + file_store = MagicMock() + file_store.exists.return_value = True + file_store.path.return_value = "/tmp/poster.jpg" + file_store.digest.return_value = SERVER_HASH + return PosterUploader(file_store=file_store, session_factory=show.session_factory) + +def _pushed(connector) -> list[str]: + return sorted(call.args[0] for call in connector.upload_poster.call_args_list) + +def test_the_upload_run_pushes_a_season_generated_after_its_show_was_uploaded(show): + show.uploaded() + show.set_season(2, processed=True) + connector = MagicMock() + connector.upload_poster.return_value = True + + _uploader(show).upload_library_posters(show.library, connector) + + assert _pushed(connector) == ["s2"] + assert show.season(2).poster_uploaded_at is not None + assert show.season(2).poster_hash == SERVER_HASH + +def test_the_upload_run_pushes_the_seasons_of_a_show_it_uploads(show): + show.set_item(processed=True) + show.set_season(1, processed=True) + show.set_season(2, processed=True) + connector = MagicMock() + connector.upload_poster.return_value = True + + _uploader(show).upload_library_posters(show.library, connector) + + assert _pushed(connector) == ["s1", "s2", "show"] + +def test_a_season_that_was_never_generated_is_not_pushed(show): + show.uploaded() + connector = MagicMock() + + _uploader(show).upload_library_posters(show.library, connector) + + connector.upload_poster.assert_not_called() + assert show.season(2).poster_hash is None + +def test_a_season_whose_push_failed_is_still_waiting(show): + show.uploaded() + show.set_season(2, processed=True) + connector = MagicMock() + connector.upload_poster.return_value = False + + _uploader(show).upload_library_posters(show.library, connector) + + assert show.season(2).poster_uploaded_at is None + +def test_a_resync_leaves_the_upload_stamp_alone(show): + show.uploaded() + db = show.session_factory() + LibrarySeasonService(db).create_or_update([ + LibrarySeason(show_id=show.item_id, library_id=show.library.id, external_id="s1", + season_number=1, title="Season 1, renamed")]) + db.close() + + assert show.season(1).title == "Season 1, renamed" + assert show.season(1).poster_uploaded_at is not None + +def _poster_service(show: Show, monkeypatch) -> LibraryPosterService: + monkeypatch.setattr(poster_module, "event_manager", MagicMock()) + svc = object.__new__(LibraryPosterService) + svc._session_factory = show.session_factory + svc._decorator = MagicMock() + svc._decorator.decorate_poster.return_value = b"poster-bytes" + svc._decorator.style_fingerprint.return_value = "style" + svc._file_store = MagicMock() + svc._file_store.save.return_value = "/tmp/poster.jpg" + svc._uploader = PosterUploader(file_store=svc._file_store, session_factory=show.session_factory) + svc._resolver = MagicMock() + svc._resolver.resolve_season_poster.return_value = PosterSource( + source="http://poster", styled=True, provider="tmdb") + svc._get_connector = lambda media_server_id: MagicMock() + svc._get_server_poster_settings = lambda media_server_id: MagicMock() + return svc + +def test_generating_without_uploading_leaves_the_season_waiting(show, monkeypatch): + show.uploaded() + svc = _poster_service(show, monkeypatch) + db = show.session_factory() + seasons = LibrarySeasonService(db) + item = LibraryRepository(db).get_library_item(show.library.id, show.item_id) + + assert svc._process_season_poster(seasons, db, show.season(1), item, "http://poster", + MagicMock(), upload=False) is True + db.close() + + assert show.season(1).poster_uploaded_at is None + assert show.season(1).poster_hash == SERVER_HASH + +def test_the_generate_run_picks_up_a_new_season_of_a_processed_show(show, monkeypatch): + show.uploaded() + svc = _poster_service(show, monkeypatch) + svc._process_item = MagicMock() + + svc._process_library(show.server_id, show.library, upload=False) + + svc._process_item.assert_not_called() + assert show.season(2).processed is True + asked = [call.args[1].season_number for call in svc._resolver.resolve_season_poster.call_args_list] + assert asked == [2] + +def test_a_new_season_with_no_artwork_does_not_fail_its_show(show, monkeypatch): + show.uploaded() + svc = _poster_service(show, monkeypatch) + svc._resolver.resolve_season_poster.return_value = None + + svc._process_library(show.server_id, show.library, upload=False) + + db = show.session_factory() + item = LibraryRepository(db).get_library_item(show.library.id, show.item_id) + db.close() + assert item.processed is True + assert item.error_message is None + assert show.season(2).processed is False + +def test_a_locked_show_keeps_its_new_season_untouched(show, monkeypatch): + show.uploaded() + show.set_item(processed=True, poster_uploaded_at=UPLOADED_AT, poster_hash=SERVER_HASH, + locked=True) + svc = _poster_service(show, monkeypatch) + + svc._process_library(show.server_id, show.library, upload=False) + + assert show.season(2).processed is False + +def test_known_season_ids_are_told_from_new_ones(show): + db = show.session_factory() + known = LibrarySeasonService(db).find_known_external_ids(show.library.id, ["s1", "s2", "s3"]) + db.close() + + assert known == {"s1", "s2"} diff --git a/affiche-backend/tests/test_title_check.py b/affiche-backend/tests/test_title_check.py new file mode 100644 index 0000000..0115f70 --- /dev/null +++ b/affiche-backend/tests/test_title_check.py @@ -0,0 +1,72 @@ +import pytest + +from affiche.app.mediaserver.library.title_check import matches_any, normalise, titles_differ + +@pytest.mark.parametrize("server,catalogue", [ + ("Astérix & Obélix : Au service de Sa Majesté", "Astérix et Obélix: Au service de sa Majesté"), + ("Asterix & Obelix: Au service de Sa Majeste", "Astérix & Obélix : Au service de Sa Majesté"), + ("Divergente 3 : Au-delà du mur", "Divergente 3 - Au-delà du mur"), + ("Cars 2: Quatre roues", "Cars 2 : Quatre roues"), + ("A Complete Unknown", "a complete unknown"), + ("Spider-Man: No Way Home", "Spider Man No Way Home"), + ("After.Life", "After Life"), + ("W.E.", "W E"), +]) +def test_two_spellings_of_the_same_title_do_not_disagree(server, catalogue): + assert titles_differ(server, catalogue) is False + +@pytest.mark.parametrize("server,catalogue", [ + ("My.movie.2012.1080p", "My Movie"), + ("Ratatouille TRUEFRENCH DVDRip XviD VFC", "Ratatouille"), + ("Avatar", "Avatar: The Last Airbender"), +]) +def test_a_real_difference_is_reported(server, catalogue): + assert titles_differ(server, catalogue) is True + +@pytest.mark.parametrize("server,catalogue", [ + ("Anything", ""), + ("Anything", " "), + ("", "Anything"), + ("Anything", None), + (None, "Anything"), +]) +def test_a_missing_answer_is_not_a_disagreement(server, catalogue): + assert titles_differ(server, catalogue) is False + +def test_normalise_keeps_differences_that_are_real(): + assert normalise("The Matrix") != normalise("Matrix") + assert normalise("Blade Runner") != normalise("Blade Runner 2049") + assert normalise("Alien") != normalise("Aliens") + +@pytest.mark.parametrize("other", ["Tom and Jerry", "Tom et Jerry", "Tom Jerry"]) +def test_normalise_drops_the_conjunction_joining_two_names(other): + assert normalise("Tom & Jerry") == normalise(other) + +def test_normalise_collapses_whitespace_left_by_dropped_punctuation(): + assert normalise("Spider-Man: No Way Home") == "spider man no way home" + +FIVE_MINARETS = [ + "Five Minarets in New York", "Act of Vengeance", "Terrorismo en Nueva York", + "The Terrorist", "New York'ta Beş Minare", "5 Minarets à New York", +] + +class TestMatchesAny: + + def test_a_release_title_other_than_the_primary_is_still_one_of_its_names(self): + assert matches_any("Act of Vengeance", FIVE_MINARETS) is True + + def test_the_primary_matches_too(self): + assert matches_any("Five Minarets in New York", FIVE_MINARETS) is True + + def test_spelling_is_normalised_here_as_well(self): + assert matches_any("5 minarets a new york!", FIVE_MINARETS) is True + + def test_a_release_name_matches_none_of_them(self): + assert matches_any("Five.Minarets.2010.1080p.WEB-DL", FIVE_MINARETS) is False + + def test_an_unrelated_title_matches_none_of_them(self): + assert matches_any("The Matrix", FIVE_MINARETS) is False + + @pytest.mark.parametrize("candidates", [[], (), None]) + def test_nothing_to_compare_against_matches_nothing(self, candidates): + assert matches_any("Anything", candidates) is False diff --git a/affiche-backend/tests/test_title_detection.py b/affiche-backend/tests/test_title_detection.py new file mode 100644 index 0000000..a782d46 --- /dev/null +++ b/affiche-backend/tests/test_title_detection.py @@ -0,0 +1,429 @@ +from datetime import datetime +from unittest.mock import MagicMock + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker + +import affiche.main as main_module # noqa: F401 (initialises routers/DI before the imports below) +from affiche.app.mediaserver.connector.media_server_connector import MediaServerPersistenceConnector +from affiche.app.mediaserver.library.model import ( + ItemStatusFilter, Library, LibraryItem, LibraryItemSearch, LibrarySearch, +) +from affiche.app.mediaserver.library.service.item_rename_service import ItemRenameError +from affiche.app.mediaserver.library.service.library_service import LibraryService +from affiche.app.mediaserver.library.service.title_check_service import TitleCheckService +from affiche.app.mediaserver.library.title_check import matches_any, titles_differ +from affiche.app.mediaserver.library.service.title_cleanup_service import ( + FAILED, + PENDING, + RENAMED, + TitleCleanupService, +) +from affiche.app.mediaserver.model.media_server import MediaServer, MediaServerType +from affiche.config import Base +from affiche.external.poster.poster_service import TitleMatch + +@pytest.fixture +def db(): + engine = create_engine("sqlite:///:memory:") + + @event.listens_for(engine, "connect") + def _fk(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + +@pytest.fixture +def library(db): + server = MediaServerPersistenceConnector(db).create(MediaServer( + name="S", type=MediaServerType.PLEX, url="http://x", token="t", + )) + db.flush() + service = LibraryService(db) + service.create(Library(media_server_id=server.id, external_id="sec-1", name="Films FR", + type="movie", language="fr-FR", enabled=True)) + db.commit() + stored = service.find_libraries(LibrarySearch(media_server_id=server.id))[0] + + service.create_or_update_items_batch([ + LibraryItem(library_id=stored.id, external_id="100", title="My.movie.2012.1080p", + type="movie", tmdb_id=603), + ]) + db.commit() + return server.id, stored + +def _item(db, library_id: int) -> LibraryItem: + return LibraryService(db).find_items(LibraryItemSearch(library_id=library_id))[0] + +def _upsert(db, library_id: int, title: str, external_id: str = "100") -> None: + LibraryService(db).create_or_update_items_batch([ + LibraryItem(library_id=library_id, external_id=external_id, title=title, type="movie", + tmdb_id=603), + ]) + db.commit() + +def _checker(db, catalogue_title, alternatives=(), raises=None) -> tuple[TitleCheckService, + MagicMock]: + rename_service = MagicMock() + rename_service.catalogue_title.return_value = ( + TitleMatch(title=catalogue_title, provider="tmdb") if catalogue_title else None) + rename_service.agrees_with_catalogue.side_effect = ( + lambda _server, _library, item, primary: not titles_differ(item.title, primary) + or matches_any(item.title, alternatives)) + if raises is not None: + rename_service.catalogue_title.side_effect = raises + return TitleCheckService(session_factory=lambda: db, + rename_service_factory=lambda _s: rename_service), rename_service + +def test_a_title_the_catalogue_disagrees_with_is_flagged(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + + report = service.check_library(server_id, lib.id) + + assert (report.checked, report.mismatched, report.skipped) == (1, 1, 0) + assert _item(db, lib.id).title_mismatched is True + +def test_a_title_the_catalogue_agrees_with_is_recorded_as_checked_not_flagged(db, library): + server_id, lib = library + service, _ = _checker(db, "My.movie.2012.1080p") + + report = service.check_library(server_id, lib.id) + + item = _item(db, lib.id) + assert (report.checked, report.mismatched) == (1, 0) + assert item.title_mismatched is False + assert item.title_checked_at is not None + +def test_a_spelling_difference_is_not_a_disagreement(db, library): + server_id, lib = library + _upsert(db, lib.id, "Astérix & Obélix : Au service de Sa Majesté") + service, _ = _checker(db, "Astérix et Obélix: Au service de sa Majesté") + + report = service.check_library(server_id, lib.id) + + assert (report.checked, report.mismatched) == (1, 0) + +def test_a_title_the_catalogue_knows_under_another_name_is_not_flagged(db, library): + server_id, lib = library + _upsert(db, lib.id, "Act of Vengeance") + service, _ = _checker(db, "Five Minarets in New York", + alternatives=["Five Minarets in New York", "Act of Vengeance", + "5 Minarets à New York"]) + + report = service.check_library(server_id, lib.id) + + assert (report.checked, report.mismatched) == (1, 0) + assert _item(db, lib.id).title_mismatched is False + +def test_a_release_name_matches_no_alternative_and_is_still_flagged(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie", + alternatives=["My Movie", "Mon film", "The Movie"]) + + report = service.check_library(server_id, lib.id) + + assert (report.checked, report.mismatched) == (1, 1) + +def test_alternatives_are_not_fetched_when_the_primary_already_agrees(db, library): + server_id, lib = library + _upsert(db, lib.id, "My Movie") + service, rename_service = _checker(db, "My Movie") + + service.check_library(server_id, lib.id) + + rename_service.catalogue_alternatives.assert_not_called() + +def test_an_item_the_catalogue_cannot_answer_for_is_left_unchecked(db, library): + server_id, lib = library + service, _ = _checker(db, None) + + report = service.check_library(server_id, lib.id) + + item = _item(db, lib.id) + assert (report.checked, report.skipped) == (0, 1) + assert item.title_checked_at is None + assert item.title_mismatched is False + +def test_one_failing_item_does_not_end_the_run(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie", raises=RuntimeError("tmdb exploded")) + + report = service.check_library(server_id, lib.id) + + assert (report.checked, report.skipped) == (0, 1) + +def test_the_check_stops_when_cancelled(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + + service.check_library(server_id, lib.id, cancel_check=lambda: True) + + rename_service.catalogue_title.assert_not_called() + +def test_an_item_already_checked_is_not_checked_again(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + assert rename_service.catalogue_title.call_count == 1 + + service.check_library(server_id, lib.id) + + assert rename_service.catalogue_title.call_count == 1 + +def test_a_sync_that_changes_the_title_sends_it_back_for_a_re_check(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + + _upsert(db, lib.id, "Something Else Entirely") + + assert _item(db, lib.id).title_checked_at is None + service.check_library(server_id, lib.id) + assert rename_service.catalogue_title.call_count == 2 + +def test_a_sync_that_leaves_the_title_alone_keeps_the_verdict(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + assert _item(db, lib.id).title_mismatched is True + + _upsert(db, lib.id, "My.movie.2012.1080p") + + item = _item(db, lib.id) + assert item.title_mismatched is True + assert item.title_checked_at is not None + +def test_renaming_an_item_sends_it_back_for_a_re_check(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + + _upsert(db, lib.id, "My Movie") + + assert _item(db, lib.id).title_checked_at is None + +def test_a_forced_check_looks_at_items_that_already_carry_a_verdict(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + assert rename_service.catalogue_title.call_count == 1 + + service.check_library(server_id, lib.id, force=True) + + assert rename_service.catalogue_title.call_count == 2 + +def test_progress_reads_as_idle_before_anything_has_run(db, library): + _, lib = library + service, _ = _checker(db, "My Movie") + + assert service.progress(lib.id) == type(service.progress(lib.id))() + +def test_progress_is_published_per_item_so_rows_appear_as_they_are_found(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + seen = [] + rename_service.catalogue_title.side_effect = lambda *a, **k: ( + seen.append(service.progress(lib.id)) or TitleMatch(title="My Movie", provider="tmdb")) + + service.check_library(server_id, lib.id) + + assert seen[0].running is True + assert seen[0].total == 1 + +def test_progress_reports_the_run_as_over_once_it_is(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + + service.check_library(server_id, lib.id) + + progress = service.progress(lib.id) + assert progress.running is False + assert (progress.checked, progress.total, progress.mismatched) == (1, 1, 1) + +def test_a_run_that_blows_up_still_stops_reporting_itself_as_running(db, library): + server_id, lib = library + service, rename_service = _checker(db, "My Movie") + rename_service.catalogue_title.side_effect = RuntimeError("tmdb exploded") + + service.check_library(server_id, lib.id) + + assert service.progress(lib.id).running is False + +def test_a_library_with_nothing_to_check_is_not_left_looking_busy(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + + service.check_library(server_id, lib.id) + + assert service.progress(lib.id).running is False + +def test_the_status_filter_lists_the_same_items_as_its_count(db, library): + server_id, lib = library + service, _ = _checker(db, "My Movie") + service.check_library(server_id, lib.id) + + library_service = LibraryService(db) + search = LibraryItemSearch(library_id=lib.id, status=ItemStatusFilter.MISMATCHED_TITLES) + + assert library_service.count_items(search) == 1 + assert library_service.count_status_buckets( + LibraryItemSearch(library_id=lib.id)).mismatched_titles == 1 + assert [i.title for i in library_service.find_items(search)] == ["My.movie.2012.1080p"] + +def test_an_unchecked_library_flags_nothing(db, library): + _, lib = library + + assert LibraryService(db).count_status_buckets( + LibraryItemSearch(library_id=lib.id)).mismatched_titles == 0 + +def test_status_and_the_raw_predicate_cannot_be_passed_together(db): + with pytest.raises(ValueError): + LibraryItemSearch(library_id=1, status=ItemStatusFilter.MISMATCHED_TITLES, + title_mismatched=False) + +def _cleanup(db, rename=True) -> tuple[TitleCleanupService, MagicMock]: + rename_service = MagicMock() + if isinstance(rename, Exception): + rename_service.rename_item.side_effect = rename + return TitleCleanupService(session_factory=lambda: db, + rename_service_factory=lambda _s: rename_service), rename_service + +@pytest.fixture +def flagged(db, library): + server_id, lib = library + item = _item(db, lib.id) + item.title_mismatched = True + item.catalogue_title = "My Movie" + item.title_checked_at = datetime(2026, 9, 21, 12, 0, 0) + LibraryService(db).library_repo.create_or_update_item(item) + db.commit() + return server_id, lib, item.id + +def test_the_panel_lists_what_would_change_without_asking_the_catalogue(db, flagged): + server_id, lib, item_id = flagged + service, rename_service = _cleanup(db) + + proposals = service.proposals(server_id, lib.id) + + assert [(p.item_id, p.current_title, p.proposed_title, p.status) for p in proposals] == [ + (item_id, "My.movie.2012.1080p", "My Movie", PENDING)] + rename_service.rename_item.assert_not_called() + +def test_an_item_checked_and_found_to_agree_is_not_proposed(db, library): + server_id, lib = library + service, _ = _cleanup(db) + + assert service.proposals(server_id, lib.id) == [] + +def test_a_flagged_item_with_no_stored_catalogue_title_is_not_proposed(db, library): + server_id, lib = library + item = _item(db, lib.id) + item.title_mismatched = True + LibraryService(db).library_repo.create_or_update_item(item) + db.commit() + service, _ = _cleanup(db) + + assert service.proposals(server_id, lib.id) == [] + +def test_only_the_approved_rows_are_renamed(db, flagged): + server_id, lib, item_id = flagged + service, rename_service = _cleanup(db) + + report = service.apply(server_id, lib.id, [item_id]) + + assert (report.approved, report.renamed, report.failed) == (1, 1, 0) + rename_service.rename_item.assert_called_once_with(server_id, lib.id, item_id, "My Movie") + +def test_a_row_left_unticked_is_not_touched(db, flagged): + server_id, lib, _ = flagged + service, rename_service = _cleanup(db) + + report = service.apply(server_id, lib.id, [99999]) + + assert report.approved == 0 + rename_service.rename_item.assert_not_called() + +def test_an_id_that_was_never_proposed_cannot_be_renamed_through_the_request(db, library): + server_id, lib = library + unflagged = _item(db, lib.id) + service, rename_service = _cleanup(db) + + report = service.apply(server_id, lib.id, [unflagged.id]) + + assert report.approved == 0 + rename_service.rename_item.assert_not_called() + +def test_a_finished_row_stays_visible_when_the_panel_is_reopened(db, flagged): + server_id, lib, item_id = flagged + service, _ = _cleanup(db) + service.apply(server_id, lib.id, [item_id]) + + proposals = service.proposals(server_id, lib.id) + + assert [(p.item_id, p.status) for p in proposals] == [(item_id, RENAMED)] + +def test_a_refused_rename_is_reported_against_its_own_row(db, flagged): + server_id, lib, item_id = flagged + service, _ = _cleanup(db, rename=ItemRenameError("Plex would not.")) + + report = service.apply(server_id, lib.id, [item_id]) + + assert (report.renamed, report.failed) == (0, 1) + row = service.proposals(server_id, lib.id)[0] + assert (row.status, row.error) == (FAILED, "Plex would not.") + +def test_one_failing_row_does_not_end_the_run(db, flagged): + server_id, lib, item_id = flagged + service, _ = _cleanup(db, rename=RuntimeError("boom")) + + report = service.apply(server_id, lib.id, [item_id]) + + assert (report.approved, report.failed) == (1, 1) + assert service.proposals(server_id, lib.id)[0].status == FAILED + +def test_only_what_was_renamed_is_offered_for_regeneration(db, flagged): + server_id, lib, item_id = flagged + service, _ = _cleanup(db, rename=ItemRenameError("no")) + service.apply(server_id, lib.id, [item_id]) + + assert service.renamed_item_ids(lib.id) == [] + +def test_renamed_ids_are_what_the_regeneration_chains_onto(db, flagged): + server_id, lib, item_id = flagged + service, _ = _cleanup(db) + service.apply(server_id, lib.id, [item_id]) + + assert service.renamed_item_ids(lib.id) == [item_id] + +def test_the_run_stops_when_cancelled(db, flagged): + server_id, lib, item_id = flagged + service, rename_service = _cleanup(db) + + report = service.apply(server_id, lib.id, [item_id], cancel_check=lambda: True) + + assert report.renamed == 0 + rename_service.rename_item.assert_not_called() + +def test_renaming_invalidates_the_verdict_about_the_title_it_replaced(db, flagged): + server_id, lib, item_id = flagged + from affiche.app.mediaserver.library.service.item_rename_service import ItemRenameService + + connector = MagicMock() + connector.rename_item.return_value = True + factory = MagicMock() + factory.get.return_value = connector + ItemRenameService(db, connector_factory=factory).rename_item( + server_id, lib.id, item_id, "My Movie") + + item = _item(db, lib.id) + assert item.title_mismatched is False + assert item.title_checked_at is None diff --git a/affiche-frontend/src/api/libraries.ts b/affiche-frontend/src/api/libraries.ts index 1e4043a..d0e522c 100644 --- a/affiche-frontend/src/api/libraries.ts +++ b/affiche-frontend/src/api/libraries.ts @@ -1,5 +1,5 @@ import { api, API_BASE } from './client'; -import type { AlphaIndexEntry, AutoPickupAction, LibraryItemCounts, ItemEpisode, ItemStatusFilter, Library, LibraryItem, LibraryItemWithSeasons, LibrarySettings, LibraryStyleStaleness, PaginatedLibraryItems, SyncTaskResponse } from '../types'; +import type { AlphaIndexEntry, AutoPickupAction, ItemTitleSuggestions, TitleCleanupState, LibraryItemCounts, ItemEpisode, ItemSeason, ItemStatusFilter, Library, LibraryItem, LibraryItemWithSeasons, LibrarySettings, LibraryStyleStaleness, PaginatedLibraryItems, SeasonTmdbMatchSuggestion, SyncTaskResponse } from '../types'; export interface LibrarySettingsUpdate { enabled?: boolean; @@ -148,6 +148,58 @@ export const libraryApi = { getSeasonEpisodes: (mediaServerId: number, libraryId: number, itemId: number, seasonNumber: number) => api.get(`/media-servers/${mediaServerId}/libraries/${libraryId}/items/${itemId}/seasons/${seasonNumber}/episodes`), + resolveSeasonTmdbMatches: (mediaServerId: number, libraryId: number) => + api.post( + `/media-servers/${mediaServerId}/libraries/${libraryId}/seasons/tmdb-match/resolve`), + + suggestSeasonTmdbMatch: ( + mediaServerId: number, + libraryId: number, + itemId: number, + seasonNumber: number, + ) => + api.get( + `/media-servers/${mediaServerId}/libraries/${libraryId}/items/${itemId}/seasons/${seasonNumber}/tmdb-match/suggestion` + ), + + setSeasonTmdbMatch: ( + mediaServerId: number, + libraryId: number, + itemId: number, + seasonNumber: number, + tmdbId: number | null, + tmdbSeasonNumber: number | null, + ) => + api.put( + `/media-servers/${mediaServerId}/libraries/${libraryId}/items/${itemId}/seasons/${seasonNumber}/tmdb-match`, + { tmdb_id: tmdbId, tmdb_season_number: tmdbSeasonNumber } + ), + + getTitleCleanupState: (mediaServerId: number, libraryId: number) => + api.get( + `/media-servers/${mediaServerId}/libraries/${libraryId}/titles/cleanup`), + + checkLibraryTitles: (mediaServerId: number, libraryId: number) => + api.post( + `/media-servers/${mediaServerId}/libraries/${libraryId}/titles/check`), + + applyTitleCleanup: (mediaServerId: number, libraryId: number, itemIds: number[], + regenerate: boolean) => + api.post( + `/media-servers/${mediaServerId}/libraries/${libraryId}/titles/cleanup`, + { item_ids: itemIds, regenerate }), + + suggestItemTitles: (mediaServerId: number, libraryId: number, itemId: number) => + api.get( + `/media-servers/${mediaServerId}/libraries/${libraryId}/items/${itemId}/title/suggestions` + ), + + renameItem: (mediaServerId: number, libraryId: number, itemId: number, title: string) => + api.patch( + `/media-servers/${mediaServerId}/libraries/${libraryId}/items/${itemId}`, + { title } + ), + syncAllLibraries: (mediaServerId: number) => api.post(`/media-servers/${mediaServerId}/libraries/sync`), diff --git a/affiche-frontend/src/api/posters.ts b/affiche-frontend/src/api/posters.ts index e3afeea..d3a5dea 100644 --- a/affiche-frontend/src/api/posters.ts +++ b/affiche-frontend/src/api/posters.ts @@ -38,6 +38,9 @@ export const postersApi = { season_number: number; tmdb_id?: number; tvdb_id?: number; + + tmdb_id_override?: number; + tmdb_season_number_override?: number; provider?: string; language?: string; }): Promise => { @@ -45,6 +48,13 @@ export const postersApi = { searchParams.set('season_number', params.season_number.toString()); if (params.tmdb_id) searchParams.set('tmdb_id', params.tmdb_id.toString()); if (params.tvdb_id) searchParams.set('tvdb_id', params.tvdb_id.toString()); + if (params.tmdb_id_override) { + searchParams.set('tmdb_id_override', params.tmdb_id_override.toString()); + } + if (params.tmdb_season_number_override !== undefined) { + searchParams.set('tmdb_season_number_override', + params.tmdb_season_number_override.toString()); + } if (params.provider) searchParams.set('provider', params.provider); if (params.language) searchParams.set('language', params.language); return api.get(`/service/posters/season?${searchParams.toString()}`); diff --git a/affiche-frontend/src/components/common/Modal.module.css b/affiche-frontend/src/components/common/Modal.module.css index 8ef6574..1540a84 100644 --- a/affiche-frontend/src/components/common/Modal.module.css +++ b/affiche-frontend/src/components/common/Modal.module.css @@ -16,6 +16,8 @@ width: 100%; max-height: calc(100vh - 48px); overflow-y: auto; + + overscroll-behavior: contain; position: relative; box-shadow: var(--shadow-xl); animation: slideIn 0.15s ease-out; diff --git a/affiche-frontend/src/components/image/PosterStyleControls.tsx b/affiche-frontend/src/components/image/PosterStyleControls.tsx index 90d40fc..3a43cc1 100644 --- a/affiche-frontend/src/components/image/PosterStyleControls.tsx +++ b/affiche-frontend/src/components/image/PosterStyleControls.tsx @@ -265,7 +265,7 @@ export function PosterStyleControls({ {capsOnlyFont && ( - {fontBaseName(textOptions.font_name)} has no lowercase, so this changes nothing + {fontBaseName(textOptions.font_name)} has no lowercase )} diff --git a/affiche-frontend/src/components/layout/Header.test.tsx b/affiche-frontend/src/components/layout/Header.test.tsx index dc5307c..7f4d0e0 100644 --- a/affiche-frontend/src/components/layout/Header.test.tsx +++ b/affiche-frontend/src/components/layout/Header.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Header } from './Header'; import type { TaskKind } from '../../types'; @@ -57,3 +58,36 @@ describe('Header progress bar', () => { expect(screen.getByText('Resetting posters — Films')).toBeTruthy(); }); }); + +describe('Header season matching', () => { + const renderWith = (onMatchSeasons?: () => void) => + render( +
, + ); + + it('offers the sweep when a library is open', async () => { + const user = userEvent.setup(); + const onMatchSeasons = vi.fn(); + renderWith(onMatchSeasons); + + await user.click(screen.getByRole('button', { name: 'Library actions' })); + await user.click(screen.getByRole('menuitem', { name: 'Match seasons' })); + + expect(onMatchSeasons).toHaveBeenCalled(); + }); + + it('leaves it out with no library to sweep', async () => { + const user = userEvent.setup(); + renderWith(undefined); + + await user.click(screen.getByRole('button', { name: 'Library actions' })); + + expect(screen.queryByRole('menuitem', { name: 'Match seasons' })).toBeNull(); + }); +}); diff --git a/affiche-frontend/src/components/layout/Header.tsx b/affiche-frontend/src/components/layout/Header.tsx index 177303a..c93bf30 100644 --- a/affiche-frontend/src/components/layout/Header.tsx +++ b/affiche-frontend/src/components/layout/Header.tsx @@ -1,4 +1,4 @@ -import { RefreshCw, Image, RotateCcw, ListRestart, Loader, Search, X, StopCircle, Trash2, Upload, LayoutGrid, List, CheckSquare } from 'lucide-react'; +import { RefreshCw, Image, RotateCcw, ListRestart, Loader, Pencil, Search, X, Sparkles, StopCircle, Trash2, Upload, LayoutGrid, List, CheckSquare } from 'lucide-react'; import { OverflowMenu, TaskProgressBar, type OverflowMenuItem } from '../common'; import { FilterMenu } from '../library/FilterMenu'; @@ -18,6 +18,10 @@ interface HeaderProps { onSyncPosters: () => void; onUploadPosters?: () => void; onResetPosters: () => void; + + onMatchSeasons?: () => void; + + onCleanTitles?: () => void; onRefreshItems?: () => void; onEmptyTrash?: () => void; onStopTask?: () => void; @@ -54,6 +58,8 @@ export function Header({ onSyncPosters, onUploadPosters, onResetPosters, + onMatchSeasons, + onCleanTitles, onRefreshItems, onEmptyTrash, onStopTask, @@ -93,6 +99,14 @@ export function Header({ ? [{ icon: , label: 'Upload posters', onClick: onUploadPosters, disabled: isLoading }] : []), + ...(onMatchSeasons + ? [{ icon: , label: 'Match seasons', onClick: onMatchSeasons, + disabled: isLoading }] + : []), + ...(onCleanTitles + ? [{ icon: , label: 'Clean up titles', onClick: onCleanTitles, + disabled: isLoading }] + : []), { icon: , label: 'Reset posters', onClick: onResetPosters, disabled: isLoading, danger: true }, ]; diff --git a/affiche-frontend/src/components/library/ItemDetail.tsx b/affiche-frontend/src/components/library/ItemDetail.tsx index eabb33e..bd6744e 100644 --- a/affiche-frontend/src/components/library/ItemDetail.tsx +++ b/affiche-frontend/src/components/library/ItemDetail.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useEffectEvent, type ReactNode } from 'react'; -import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, Image, RotateCcw, ExternalLink, CheckCircle, Circle, Loader2, Images, Upload, AlertTriangle, Lock, Unlock, Columns2, ChevronDown, ChevronUp } from 'lucide-react'; +import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, Image, RotateCcw, ExternalLink, CheckCircle, Circle, Loader2, Images, Upload, AlertTriangle, Lock, Unlock, Columns2, ChevronDown, ChevronUp, Pencil } from 'lucide-react'; import { errorMessage, libraryApi } from '../../api'; import type { PosterVariant } from '../../api/libraries'; import { useToast } from '../../context/ToastContext'; @@ -38,6 +38,8 @@ interface ItemDetailProps { onSelectPoster: (posterUrl?: string) => void; onUpload: () => void; onToggleLock: () => void; + + onRename?: () => void; onSeasonSelectPoster?: (season: ItemSeason) => void; onSeasonClick?: (season: ItemSeason) => void; isLoading?: boolean; @@ -62,6 +64,7 @@ export function ItemDetail({ onSelectPoster, onUpload, onToggleLock, + onRename, onSeasonSelectPoster, onSeasonClick, isLoading, @@ -304,6 +307,12 @@ export function ItemDetail({ triggerClassName={styles.actionButton} items={[ { icon: , label: 'Sync metadata', onClick: onSync, disabled: isLoading }, + ...(onRename ? [{ + icon: , + label: 'Rename…', + onClick: onRename, + disabled: isLoading, + }] : []), { icon: , label: 'Reset poster', diff --git a/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx b/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx index d31c074..bf8865f 100644 --- a/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx +++ b/affiche-frontend/src/components/library/PosterBrowserModal.test.tsx @@ -346,13 +346,6 @@ describe('PosterBrowserModal — style drafts', () => { expect(onSave).toHaveBeenLastCalledWith('https://cdn/a.jpg', expect.objectContaining({ upload: false })); }); - it('says in words which save is the default, not only by which button is filled', async () => { - renderModal({ defaultUpload: true }); - await candidates(); - - expect(screen.getByText(/This library uploads new posters/)).toBeInTheDocument(); - }); - it('offers no upload where applying cannot reach the media server', async () => { renderModal({ canUpload: false }); await candidates(); @@ -401,6 +394,105 @@ describe('PosterBrowserModal — style drafts', () => { })); }); + it('points a season at another TMDB series, and saves the correction', async () => { + const user = userEvent.setup(); + const onTmdbMatchChange = vi.fn().mockResolvedValue(undefined); + renderModal({ seasonNumber: SEASON_NUMBER, onTmdbMatchChange }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + + await user.type(screen.getByLabelText('TMDB series'), '225634'); + await user.type(screen.getByLabelText('Its season'), '1'); + await user.click(screen.getByRole('button', { name: 'Save match' })); + + expect(onTmdbMatchChange).toHaveBeenCalledWith({ tmdbId: 225634, seasonNumber: 1 }); + + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(2)); + + expect(getSeasonPosters.mock.calls[1][0]).toMatchObject({ + season_number: 2, + tmdb_id_override: 225634, + tmdb_season_number_override: 1, + }); + }); + + it('opens on a correction the season already carries', async () => { + renderModal({ + seasonNumber: SEASON_NUMBER, + tmdbMatch: { tmdbId: 225634, seasonNumber: 1 }, + onTmdbMatchChange: vi.fn(), + }); + + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + expect(getSeasonPosters.mock.calls[0][0]).toMatchObject({ + tmdb_id_override: 225634, + tmdb_season_number_override: 1, + }); + expect(screen.getByLabelText('TMDB series')).toHaveValue(225634); + }); + + it('clears a correction, handing the season back to the show match', async () => { + const user = userEvent.setup(); + const onTmdbMatchChange = vi.fn().mockResolvedValue(undefined); + renderModal({ + seasonNumber: SEASON_NUMBER, + tmdbMatch: { tmdbId: 225634, seasonNumber: 1 }, + onTmdbMatchChange, + }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(onTmdbMatchChange).toHaveBeenCalledWith({ tmdbId: null, seasonNumber: null }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(2)); + expect(getSeasonPosters.mock.calls[1][0].tmdb_id_override).toBeUndefined(); + }); + + it('offers no correction when there is nothing to save it against', async () => { + renderModal({ seasonNumber: SEASON_NUMBER }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + + expect(screen.queryByLabelText('TMDB series')).toBeNull(); + }); + + it('fills the boxes from a found match but saves nothing', async () => { + const user = userEvent.setup(); + const onTmdbMatchChange = vi.fn().mockResolvedValue(undefined); + const onTmdbMatchFind = vi.fn().mockResolvedValue({ + tmdbId: 225634, + seasonNumber: 1, + reason: 'Matched "The Lyle and Erik Menendez Story" to Monster (9 episodes, 2024)', + }); + renderModal({ seasonNumber: SEASON_NUMBER, onTmdbMatchChange, onTmdbMatchFind }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: 'Find match' })); + + await waitFor(() => expect(screen.getByLabelText('TMDB series')).toHaveValue(225634)); + expect(screen.getByLabelText('Its season')).toHaveValue(1); + expect(screen.getByText(/Menendez Story/)).toBeInTheDocument(); + + expect(onTmdbMatchChange).not.toHaveBeenCalled(); + expect(getSeasonPosters).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Save match' })); + expect(onTmdbMatchChange).toHaveBeenCalledWith({ tmdbId: 225634, seasonNumber: 1 }); + }); + + it('says so when nothing could be resolved', async () => { + const user = userEvent.setup(); + renderModal({ + seasonNumber: SEASON_NUMBER, + onTmdbMatchChange: vi.fn(), + onTmdbMatchFind: vi.fn().mockResolvedValue(null), + }); + await waitFor(() => expect(getSeasonPosters).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole('button', { name: 'Find match' })); + + await waitFor(() => expect(screen.getByText(/No confident match found/)).toBeInTheDocument()); + expect(screen.getByLabelText('TMDB series')).toHaveValue(null); + }); + it('titles a season pick with its season label', async () => { const user = userEvent.setup(); const { onSave } = renderModal({ seasonNumber: SEASON_NUMBER }); diff --git a/affiche-frontend/src/components/library/PosterBrowserModal.tsx b/affiche-frontend/src/components/library/PosterBrowserModal.tsx index 9ec5edb..12862ec 100644 --- a/affiche-frontend/src/components/library/PosterBrowserModal.tsx +++ b/affiche-frontend/src/components/library/PosterBrowserModal.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Loader2 } from 'lucide-react'; +import { errorMessage } from '../../api'; import { Modal } from '../common'; import { useToast } from '../../context/ToastContext'; import { @@ -17,6 +18,7 @@ import { sortPosterCandidates } from './posterSort'; import { PosterPreviewPane } from './PosterPreviewPane'; import { PosterEditPanel } from './PosterEditPanel'; import type { PosterTarget } from './posterTarget'; +import type { TmdbMatch } from '../../hooks/usePosterBrowseQuery'; import type { OverlayOptions, TextOptions } from '../../types'; import styles from './PosterBrowserModal.module.css'; @@ -25,6 +27,12 @@ interface PosterBrowserModalProps { target: PosterTarget; seasonNumber?: number; + + tmdbMatch?: TmdbMatch; + + onTmdbMatchChange?: (match: TmdbMatch) => Promise; + + onTmdbMatchFind?: () => Promise<{ tmdbId: number; seasonNumber: number; reason: string } | null>; onClose: () => void; onSave: ( posterUrl: string, @@ -42,6 +50,9 @@ interface PosterBrowserModalProps { export function PosterBrowserModal({ target, seasonNumber, + tmdbMatch, + onTmdbMatchChange, + onTmdbMatchFind, onClose, onSave, isSaving = false, @@ -60,11 +71,15 @@ export function PosterBrowserModal({ const [selectedPoster, setSelectedPoster] = useState(initialPoster ?? null); const [showEditPanel, setShowEditPanel] = useState(false); const [findOpen, setFindOpen] = useState(false); + const [isSavingMatch, setIsSavingMatch] = useState(false); + const [isFindingMatch, setIsFindingMatch] = useState(false); + const [foundReason, setFoundReason] = useState(undefined); const query = usePosterBrowseQuery({ itemTitle, year, seasonNumber, + tmdbMatch, onSourceChanged: () => setSelectedPoster(null), }); @@ -79,6 +94,8 @@ export function PosterBrowserModal({ tvdbId, collectionId, seasonNumber: isSeason && !query.useShowArt ? query.searchSeasonNumber : undefined, + tmdbIdOverride: query.appliedTmdbMatch.tmdbId ?? undefined, + tmdbSeasonNumberOverride: query.appliedTmdbMatch.seasonNumber ?? undefined, provider: query.provider, language: query.language, }); @@ -91,6 +108,42 @@ export function PosterBrowserModal({ } }; + const findTmdbMatch = async () => { + if (!onTmdbMatchFind) return; + setIsFindingMatch(true); + setFoundReason(undefined); + try { + const found = await onTmdbMatchFind(); + if (!found) { + setFoundReason('No confident match found. Fill the boxes yourself, or leave it as is.'); + return; + } + + query.setTmdbIdDraft(String(found.tmdbId)); + query.setTmdbSeasonDraft(String(found.seasonNumber)); + setFoundReason(`${found.reason}. Press Save match to keep it.`); + } catch (err) { + toast.error(errorMessage(err, 'Could not work out a TMDB match.')); + } finally { + setIsFindingMatch(false); + } + }; + + const persistTmdbMatch = async (match: TmdbMatch) => { + if (!onTmdbMatchChange) return; + setIsSavingMatch(true); + try { + await onTmdbMatchChange(match); + toast.success(match.tmdbId === null + ? 'TMDB match cleared for this season.' + : `Season artwork now comes from TMDB series ${match.tmdbId}.`); + } catch (err) { + toast.error(errorMessage(err, 'Could not save the TMDB match.')); + } finally { + setIsSavingMatch(false); + } + }; + const stageCustom = async (params: { file?: File; url?: string }) => { const staged = await candidates.stageCustom(params); @@ -181,6 +234,24 @@ export function PosterBrowserModal({ useShowArt: query.useShowArt, onUseShowArtChange: query.changeUseShowArt, appliesToSeason: seasonNumber, + tmdbMatch: onTmdbMatchChange + ? { + idDraft: query.tmdbIdDraft, + onIdDraftChange: query.setTmdbIdDraft, + seasonDraft: query.tmdbSeasonDraft, + onSeasonDraftChange: query.setTmdbSeasonDraft, + onApply: () => void persistTmdbMatch(query.applyTmdbMatch()), + onFind: onTmdbMatchFind ? () => void findTmdbMatch() : undefined, + isFinding: isFindingMatch, + foundReason, + isSaving: isSavingMatch, + isSet: query.appliedTmdbMatch.tmdbId !== null, + onClear: () => { + setFoundReason(undefined); + void persistTmdbMatch(query.clearTmdbMatch()); + }, + } + : undefined, } : undefined } @@ -229,13 +300,6 @@ export function PosterBrowserModal({
-

- {!canUpload - ? 'The poster is kept in Affiche.' - : defaultUpload - ? "This library uploads new posters: Save & upload replaces the media server's artwork, keeping its current one for Reset. Save keeps the poster in Affiche only." - : "Save keeps the poster in Affiche. Save & upload also replaces the media server's artwork, keeping its current one for Reset."} -

)} + + {seasonSource?.tmdbMatch && !seasonSource.useShowArt && ( +
+
+ + seasonSource.tmdbMatch?.onIdDraftChange(e.target.value)} + /> +
+
+ + seasonSource.tmdbMatch?.onSeasonDraftChange(e.target.value)} + disabled={!seasonSource.tmdbMatch.idDraft.trim()} + /> +
+ {seasonSource.tmdbMatch.onFind && ( + + )} + + {seasonSource.tmdbMatch.isSet && ( + + )} +

+ {seasonSource.tmdbMatch.foundReason + ?? "TMDB files some anthologies as one show per season. Find match works it out from " + + "TVDB's season names; it fills the boxes but saves nothing until you say so."} +

+
+ )} ); } diff --git a/affiche-frontend/src/components/library/PosterEditPanel.module.css b/affiche-frontend/src/components/library/PosterEditPanel.module.css index cd14fd0..ed5e280 100644 --- a/affiche-frontend/src/components/library/PosterEditPanel.module.css +++ b/affiche-frontend/src/components/library/PosterEditPanel.module.css @@ -39,8 +39,9 @@ .previewRow { display: flex; + flex-direction: column; align-items: center; - gap: 16px; + gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; @@ -48,7 +49,7 @@ .previewWrapper { flex-shrink: 0; - width: 150px; + width: min(260px, 27vh); border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-md); @@ -61,6 +62,7 @@ } .previewPlaceholder { + width: 100%; aspect-ratio: 2/3; display: flex; align-items: center; @@ -74,8 +76,10 @@ } .previewNote { + margin: 0; font-size: var(--text-md); line-height: 1.45; + text-align: center; color: var(--text-secondary); } diff --git a/affiche-frontend/src/components/library/PosterEditPanel.tsx b/affiche-frontend/src/components/library/PosterEditPanel.tsx index 98edb5d..fc4f8e5 100644 --- a/affiche-frontend/src/components/library/PosterEditPanel.tsx +++ b/affiche-frontend/src/components/library/PosterEditPanel.tsx @@ -72,9 +72,6 @@ export function PosterEditPanel({
No poster selected
)} -

- Pick any poster in the grid: it takes this style straight away. -

diff --git a/affiche-frontend/src/components/library/RenameItemModal.module.css b/affiche-frontend/src/components/library/RenameItemModal.module.css new file mode 100644 index 0000000..6b37457 --- /dev/null +++ b/affiche-frontend/src/components/library/RenameItemModal.module.css @@ -0,0 +1,186 @@ +.content { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 20px; + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 24px 8px; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sectionHeading { + display: flex; + align-items: center; + gap: 10px; + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.searching { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + font-weight: 500; + letter-spacing: normal; + text-transform: none; +} + +.current { + font-family: monospace; + font-size: var(--text-sm); + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 34vh; + overflow-y: auto; + overscroll-behavior: contain; +} + +.row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-md); + font-size: var(--text-base); + color: var(--text-primary); + cursor: pointer; + transition: background-color 0.15s; +} + +.row:hover { + background-color: var(--bg-hover); +} + +.row:has(input:disabled) { + color: var(--text-muted); + cursor: not-allowed; +} + +.row input { + accent-color: var(--accent); +} + +.rowTitle { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rowNote { + font-size: var(--text-sm); + color: var(--text-muted); + white-space: nowrap; +} + +.certain { + color: var(--accent); + font-weight: 500; +} + +.empty { + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.fieldLabel { + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.field { + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: var(--text-base); + color: var(--text-primary); + background-color: var(--bg-secondary); +} + +.field:focus { + border-color: var(--accent); + outline: none; +} + +.field:disabled { + opacity: 0.6; +} + +.checkbox { + display: flex; + align-items: flex-start; + gap: 10px; + font-size: var(--text-base); + color: var(--text-primary); + cursor: pointer; +} + +.checkbox input { + margin-top: 3px; + accent-color: var(--accent); +} + +.checkboxNote { + display: block; + margin-top: 2px; + font-size: var(--text-sm); + color: var(--text-muted); +} + +.primary, +.secondary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: var(--text-base); + font-weight: 500; + transition: background-color 0.15s; +} + +.primary { + color: var(--text-on-fill); + background-color: var(--accent-fill); +} + +.primary:hover:not(:disabled) { + background-color: var(--accent-fill-hover); +} + +.secondary { + color: var(--text-secondary); + background-color: var(--bg-tertiary); +} + +.secondary:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--bg-hover); +} + +.primary:disabled, +.secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/affiche-frontend/src/components/library/RenameItemModal.test.tsx b/affiche-frontend/src/components/library/RenameItemModal.test.tsx new file mode 100644 index 0000000..aecad31 --- /dev/null +++ b/affiche-frontend/src/components/library/RenameItemModal.test.tsx @@ -0,0 +1,122 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { RenameItemModal } from './RenameItemModal'; +import { libraryApi } from '../../api'; +import type { ItemTitleSuggestions } from '../../types'; + +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + libraryApi: { ...actual.libraryApi, suggestItemTitles: vi.fn() }, + }; +}); + +const toast = { error: vi.fn(), success: vi.fn(), info: vi.fn(), show: vi.fn() }; +vi.mock('../../context/ToastContext', () => ({ useToast: () => toast })); + +const suggestItemTitles = vi.mocked(libraryApi.suggestItemTitles); + +const suggestions: ItemTitleSuggestions = { + current: 'My.movie.2012.1080p', + matched: { title: 'My Movie', provider: 'tmdb' }, +}; + +const onConfirm = vi.fn(); + +const renderModal = () => + render( + + ); + +const field = () => screen.getByLabelText('Title') as HTMLInputElement; + +beforeEach(() => { + vi.clearAllMocks(); + suggestItemTitles.mockResolvedValue(suggestions); +}); + +describe('RenameItemModal', () => { + it('offers the catalogue name for the id and pre-fills it', async () => { + renderModal(); + + await waitFor(() => expect(field().value).toBe('My Movie')); + expect(screen.getByText('TMDB · matched by id')).toBeInTheDocument(); + }); + + it('says why there is nothing to offer for an item with no id', async () => { + suggestItemTitles.mockResolvedValue({ + current: 'My.movie.2012.1080p', matched: null, reason: 'no_id', + }); + renderModal(); + + await waitFor(() => expect(screen.getByText(/never matched it/)).toBeInTheDocument()); + + expect(screen.queryByText(/matched by id/)).not.toBeInTheDocument(); + expect(field().value).toBe('My.movie.2012.1080p'); + }); + + it('does not blame the media server for an item whose title already agrees', async () => { + suggestItemTitles.mockResolvedValue({ + current: 'A Complete Unknown', matched: null, reason: 'already_correct', + }); + renderModal(); + + await waitFor(() => expect(screen.getByText(/already matches/)).toBeInTheDocument()); + expect(screen.queryByText(/never matched it/)).not.toBeInTheDocument(); + }); + + it('leaves a title the user typed before the suggestions landed alone', async () => { + let resolve: (value: ItemTitleSuggestions) => void = () => {}; + suggestItemTitles.mockReturnValue(new Promise((r) => { resolve = r; })); + renderModal(); + + fireEvent.change(field(), { target: { value: 'What I meant' } }); + resolve(suggestions); + + await waitFor(() => expect(screen.getByText('TMDB · matched by id')).toBeInTheDocument()); + expect(field().value).toBe('What I meant'); + }); + + it('stays usable when the lookup fails', async () => { + suggestItemTitles.mockRejectedValue(new Error('tmdb is down')); + renderModal(); + + await waitFor(() => expect(screen.getByText(/Could not reach the catalogue/)).toBeInTheDocument()); + expect(toast.error).toHaveBeenCalled(); + + fireEvent.change(field(), { target: { value: 'My Movie' } }); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + expect(onConfirm).toHaveBeenCalledWith('My Movie', true); + }); + + it('will not submit a title that changes nothing', async () => { + suggestItemTitles.mockResolvedValue({ + current: 'My.movie.2012.1080p', matched: null, reason: 'no_id', + }); + renderModal(); + + await waitFor(() => expect(screen.getByText(/never matched it/)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Rename' })).toBeDisabled(); + }); + + it('passes the regenerate choice through, trimmed of stray whitespace', async () => { + renderModal(); + await waitFor(() => expect(field().value).toBe('My Movie')); + + fireEvent.change(field(), { target: { value: ' My Movie ' } }); + fireEvent.click(screen.getByLabelText(/Regenerate the poster/)); + fireEvent.click(screen.getByRole('button', { name: 'Rename' })); + + expect(onConfirm).toHaveBeenCalledWith('My Movie', false); + }); +}); diff --git a/affiche-frontend/src/components/library/RenameItemModal.tsx b/affiche-frontend/src/components/library/RenameItemModal.tsx new file mode 100644 index 0000000..f71c6d7 --- /dev/null +++ b/affiche-frontend/src/components/library/RenameItemModal.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { Loader2 } from 'lucide-react'; + +import { errorMessage, libraryApi } from '../../api'; +import { useToast } from '../../context/ToastContext'; +import { Modal } from '../common'; +import { + noSuggestionMessage, preselectedOption, renameOptions, type RenameOption, +} from './renameOptions'; +import styles from './RenameItemModal.module.css'; + +interface RenameItemModalProps { + mediaServerId: number; + libraryId: number; + itemId: number; + + currentTitle: string; + + mediaServerName?: string; + + isBusy?: boolean; + + onConfirm: (title: string, regenerate: boolean) => void; + onClose: () => void; +} + +export function RenameItemModal({ + mediaServerId, + libraryId, + itemId, + currentTitle, + mediaServerName, + isBusy = false, + onConfirm, + onClose, +}: RenameItemModalProps) { + const toast = useToast(); + const [title, setTitle] = useState(currentTitle); + const [regenerate, setRegenerate] = useState(true); + const [options, setOptions] = useState(null); + + const [emptyMessage, setEmptyMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const suggestions = await libraryApi.suggestItemTitles(mediaServerId, libraryId, itemId); + if (cancelled) return; + const loaded = renameOptions(suggestions); + setOptions(loaded); + setEmptyMessage(noSuggestionMessage(suggestions)); + + const preselected = preselectedOption(loaded); + if (preselected) setTitle((typed) => (typed === currentTitle ? preselected.title : typed)); + } catch (error) { + if (cancelled) return; + + setOptions([]); + setEmptyMessage('Could not reach the catalogue just now. Type the title you want below.'); + toast.error(errorMessage(error, 'Could not look up this title.'), { title: 'Rename' }); + } + }; + load(); + return () => { cancelled = true; }; + }, [mediaServerId, libraryId, itemId, currentTitle, toast]); + + const trimmed = title.trim(); + const canRename = !!trimmed && trimmed !== currentTitle && !isBusy; + const serverName = mediaServerName || 'your media server'; + + return ( + + Affiche writes the new title to {serverName} and locks the field, so a + metadata refresh will not undo it. Files on disk are not touched. + + } + isBusy={isBusy} + onClose={onClose} + footer={ + <> + + + + } + > +
+
+

Now

+

{currentTitle}

+
+ +
+

+ Suggestion + {options === null && ( + + + )} +

+ {options !== null && options.length === 0 ? ( +

{emptyMessage}

+ ) : ( +
+ {(options ?? []).map((option) => ( + + ))} +
+ )} +
+ +
+ + setTitle(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && canRename) onConfirm(trimmed, regenerate); }} + disabled={isBusy} + autoFocus + /> +
+ + +
+
+ ); +} diff --git a/affiche-frontend/src/components/library/TitleCleanupPanel.module.css b/affiche-frontend/src/components/library/TitleCleanupPanel.module.css new file mode 100644 index 0000000..0300a4e --- /dev/null +++ b/affiche-frontend/src/components/library/TitleCleanupPanel.module.css @@ -0,0 +1,244 @@ +.content { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: 14px; + overflow-y: auto; + + overscroll-behavior: contain; + padding: 4px 24px 16px; +} + +.loading, +.empty { + display: flex; + align-items: center; + gap: 10px; + padding: 24px 0; + font-size: var(--text-base); + color: var(--text-secondary); +} + +.empty { + display: block; + line-height: 1.5; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; +} + +.checkButton { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 7px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-primary); + background-color: var(--bg-secondary); + transition: background-color 0.15s; +} + +.checkButton:hover:not(:disabled) { + background-color: var(--bg-hover); +} + +.checkButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.progressLine { + font-size: var(--text-sm); + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +.progressTrack { + height: 3px; + border-radius: 2px; + overflow: hidden; + background-color: var(--bg-tertiary); +} + +.progressBar { + height: 100%; + background-color: var(--accent-fill); + transition: width 0.3s ease-out; +} + +.selectAll { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); + background-color: var(--bg-secondary); + cursor: pointer; +} + +.selectAll input { + accent-color: var(--accent); +} + +.selectCount { + margin-left: auto; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} + +.list { + display: flex; + flex-direction: column; + gap: 2px; + list-style: none; + margin: 0; + padding: 0; +} + +.row { + border-radius: var(--radius-md); +} + +.row:hover { + background-color: var(--bg-hover); +} + +.rowMain { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px; + cursor: pointer; +} + +.rowMain input { + margin-top: 2px; + accent-color: var(--accent); +} + +.rowMain svg { + flex: none; + margin-top: 2px; +} + +.renamed { + color: var(--success); +} + +.failed { + color: var(--error); +} + +.titles { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.current { + font-family: monospace; + font-size: var(--text-sm); + color: var(--text-muted); + overflow-wrap: anywhere; +} + +.proposed { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--text-base); + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.provider { + flex: none; + margin-top: 2px; + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.rowError { + margin: 0 10px 8px 36px; + font-size: var(--text-sm); + color: var(--error); +} + +.checkbox { + display: flex; + align-items: flex-start; + gap: 10px; + padding-top: 4px; + font-size: var(--text-base); + color: var(--text-primary); + cursor: pointer; +} + +.checkbox input { + margin-top: 3px; + accent-color: var(--accent); +} + +.checkboxNote { + display: block; + margin-top: 2px; + font-size: var(--text-sm); + color: var(--text-muted); +} + +.footerNote { + margin-right: auto; + font-size: var(--text-sm); + color: var(--text-secondary); +} + +.primary, +.secondary { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: var(--text-base); + font-weight: 500; + transition: background-color 0.15s; +} + +.primary { + color: var(--text-on-fill); + background-color: var(--accent-fill); +} + +.primary:hover:not(:disabled) { + background-color: var(--accent-fill-hover); +} + +.secondary { + color: var(--text-secondary); + background-color: var(--bg-tertiary); +} + +.secondary:hover:not(:disabled) { + color: var(--text-primary); + background-color: var(--bg-hover); +} + +.primary:disabled, +.secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/affiche-frontend/src/components/library/TitleCleanupPanel.test.tsx b/affiche-frontend/src/components/library/TitleCleanupPanel.test.tsx new file mode 100644 index 0000000..1ebbbc5 --- /dev/null +++ b/affiche-frontend/src/components/library/TitleCleanupPanel.test.tsx @@ -0,0 +1,196 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { TitleCleanupPanel } from './TitleCleanupPanel'; +import { libraryApi } from '../../api'; +import type { TitleProposal } from '../../types'; + +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + libraryApi: { + ...actual.libraryApi, + getTitleCleanupState: vi.fn(), + applyTitleCleanup: vi.fn(), + checkLibraryTitles: vi.fn(), + }, + }; +}); + +const toast = { error: vi.fn(), success: vi.fn(), info: vi.fn(), show: vi.fn() }; +vi.mock('../../context/ToastContext', () => ({ useToast: () => toast })); + +const getTitleCleanupState = vi.mocked(libraryApi.getTitleCleanupState); +const applyTitleCleanup = vi.mocked(libraryApi.applyTitleCleanup); +const checkLibraryTitles = vi.mocked(libraryApi.checkLibraryTitles); + +const idle = { running: false, checked: 0, total: 0, mismatched: 0 }; +const state = (proposals: TitleProposal[], check = idle) => ({ proposals, check }); + +const row = (item_id: number, overrides: Partial = {}): TitleProposal => ({ + item_id, + current_title: `My.movie.${item_id}.1080p`, + proposed_title: `My Movie ${item_id}`, + provider: 'tmdb', + status: 'pending', + ...overrides, +}); + +const onStarted = vi.fn(); +const onClose = vi.fn(); + +const renderPanel = () => + render( + + ); + +const approve = () => screen.getByRole('button', { name: /^Rename/ }); + +beforeEach(() => { + vi.clearAllMocks(); + getTitleCleanupState.mockResolvedValue(state([row(1), row(2), row(3)])); + applyTitleCleanup.mockResolvedValue({ task_id: 'task-1', status: 'running', message: '' }); + checkLibraryTitles.mockResolvedValue({ task_id: 'check-1', status: 'running', message: '' }); +}); + +describe('TitleCleanupPanel', () => { + it('lists every proposed rename with both titles', async () => { + renderPanel(); + + await waitFor(() => expect(screen.getByText('My.movie.1.1080p')).toBeInTheDocument()); + expect(screen.getByText('My Movie 1')).toBeInTheDocument(); + expect(screen.getByText(/3 titles in Films FR/)).toBeInTheDocument(); + }); + + it('starts with every row ticked, and counts them on the button', async () => { + renderPanel(); + + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + }); + + it('submits only the rows still ticked', async () => { + renderPanel(); + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + + fireEvent.click(screen.getByLabelText('Rename My.movie.2.1080p')); + expect(approve()).toHaveTextContent('Rename 2 items'); + fireEvent.click(approve()); + + await waitFor(() => expect(applyTitleCleanup).toHaveBeenCalledWith(1, 3, [1, 3], true)); + }); + + it('passes the regenerate choice through', async () => { + renderPanel(); + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + + fireEvent.click(screen.getByLabelText(/Regenerate posters/)); + fireEvent.click(approve()); + + await waitFor(() => + expect(applyTitleCleanup).toHaveBeenCalledWith(1, 3, [1, 2, 3], false)); + }); + + it('cannot approve once every row is unticked', async () => { + renderPanel(); + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + + fireEvent.click(screen.getByLabelText('Select all')); + + expect(approve()).toBeDisabled(); + }); + + it('hands the task id back and closes, rather than blocking on the run', async () => { + renderPanel(); + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + + fireEvent.click(approve()); + + await waitFor(() => expect(onStarted).toHaveBeenCalledWith('task-1')); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows a reopened run’s finished rows with their outcome, not as work to approve', async () => { + getTitleCleanupState.mockResolvedValue(state([ + row(1, { status: 'renamed' }), + row(2, { status: 'failed', error: 'Plex would not.' }), + row(3), + ])); + renderPanel(); + + await waitFor(() => expect(screen.getByLabelText('Renamed')).toBeInTheDocument()); + expect(screen.getByLabelText('Could not rename')).toBeInTheDocument(); + expect(screen.getByText('Plex would not.')).toBeInTheDocument(); + + expect(approve()).toHaveTextContent('Rename 1 item'); + }); + + it('says plainly when there is nothing to review', async () => { + getTitleCleanupState.mockResolvedValue(state([])); + renderPanel(); + + await waitFor(() => expect(screen.getByText(/Nothing to review/)).toBeInTheDocument()); + expect(approve()).toBeDisabled(); + }); + + it('never applies anything just by opening', async () => { + renderPanel(); + + await waitFor(() => expect(getTitleCleanupState).toHaveBeenCalled()); + expect(applyTitleCleanup).not.toHaveBeenCalled(); + }); + + it('starts a check when asked, and shows it running', async () => { + getTitleCleanupState.mockResolvedValue(state([])); + renderPanel(); + await waitFor(() => expect(screen.getByText(/Nothing to review/)).toBeInTheDocument()); + + getTitleCleanupState.mockResolvedValue( + state([], { running: true, checked: 12, total: 40, mismatched: 2 })); + fireEvent.click(screen.getByRole('button', { name: /Check titles/ })); + + await waitFor(() => expect(checkLibraryTitles).toHaveBeenCalledWith(1, 3)); + await waitFor(() => + expect(screen.getByText(/Checking 12 of 40 — 2 to fix so far/)).toBeInTheDocument()); + }); + + it('ticks rows that appear mid-check but keeps an opt-out already made', async () => { + renderPanel(); + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + + fireEvent.click(screen.getByLabelText('Rename My.movie.2.1080p')); + expect(approve()).toHaveTextContent('Rename 2 items'); + + getTitleCleanupState.mockResolvedValue( + state([row(1), row(2), row(3), row(4)], { running: true, checked: 4, total: 4, mismatched: 4 })); + fireEvent.click(screen.getByRole('button', { name: /Check titles/ })); + + await waitFor(() => expect(approve()).toHaveTextContent('Rename 3 items')); + fireEvent.click(approve()); + await waitFor(() => expect(applyTitleCleanup).toHaveBeenCalledWith(1, 3, [1, 3, 4], true)); + }); + + it('cannot start a second check while one is running', async () => { + getTitleCleanupState.mockResolvedValue( + state([], { running: true, checked: 1, total: 9, mismatched: 0 })); + renderPanel(); + + await waitFor(() => + expect(screen.getByRole('button', { name: /Check titles/ })).toBeDisabled()); + }); + + it('reports a failure to load rather than showing an empty list as success', async () => { + getTitleCleanupState.mockRejectedValue(new Error('down')); + renderPanel(); + + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(screen.getByText(/Nothing to review/)).toBeInTheDocument(); + }); +}); diff --git a/affiche-frontend/src/components/library/TitleCleanupPanel.tsx b/affiche-frontend/src/components/library/TitleCleanupPanel.tsx new file mode 100644 index 0000000..6da6839 --- /dev/null +++ b/affiche-frontend/src/components/library/TitleCleanupPanel.tsx @@ -0,0 +1,288 @@ +import { useEffect, useState } from 'react'; +import { AlertTriangle, ArrowRight, Check, Loader2, SearchCheck } from 'lucide-react'; + +import { errorMessage, libraryApi } from '../../api'; +import { useToast } from '../../context/ToastContext'; +import type { TitleCheckProgress, TitleProposal } from '../../types'; +import { Modal } from '../common'; +import { + approveLabel, checkFraction, checkProgressLine, initialSelection, isPending, pendingCount, + runSummary, summaryLine, toggle, toggleAll, +} from './titleCleanup'; +import styles from './TitleCleanupPanel.module.css'; + +interface TitleCleanupPanelProps { + mediaServerId: number; + libraryId: number; + + libraryName: string; + + mediaServerName?: string; + + onStarted: (taskId: string) => void; + onClose: () => void; +} + +export function TitleCleanupPanel({ + mediaServerId, + libraryId, + libraryName, + mediaServerName, + onStarted, + onClose, +}: TitleCleanupPanelProps) { + const toast = useToast(); + const [proposals, setProposals] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [regenerate, setRegenerate] = useState(true); + const [isApplying, setIsApplying] = useState(false); + const [check, setCheck] = useState( + { running: false, checked: 0, total: 0, mismatched: 0 }); + const [isStartingCheck, setIsStartingCheck] = useState(false); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType | undefined; + + const load = async () => { + try { + const state = await libraryApi.getTitleCleanupState(mediaServerId, libraryId); + if (cancelled) return; + setProposals((previous) => { + + setSelected((current) => (previous === null + ? initialSelection(state.proposals) + : withNewRowsSelected(current, previous, state.proposals))); + return state.proposals; + }); + setCheck(state.check); + if (state.check.running) timer = setTimeout(load, POLL_MS); + } catch (error) { + if (cancelled) return; + setProposals((previous) => previous ?? []); + toast.error(errorMessage(error, 'Could not load the proposed renames.'), + { title: 'Clean up titles' }); + } + }; + load(); + return () => { cancelled = true; if (timer) clearTimeout(timer); }; + }, [mediaServerId, libraryId, toast]); + + const startCheck = async () => { + setIsStartingCheck(true); + try { + await libraryApi.checkLibraryTitles(mediaServerId, libraryId); + + setCheck({ running: true, checked: 0, total: 0, mismatched: 0 }); + pollUntilIdle(); + } catch (error) { + toast.error(errorMessage(error, 'Could not start the check.'), { title: 'Clean up titles' }); + } finally { + setIsStartingCheck(false); + } + }; + + const pollUntilIdle = async () => { + try { + const state = await libraryApi.getTitleCleanupState(mediaServerId, libraryId); + setProposals((previous) => { + setSelected((current) => (previous === null + ? initialSelection(state.proposals) + : withNewRowsSelected(current, previous, state.proposals))); + return state.proposals; + }); + setCheck(state.check); + if (state.check.running) setTimeout(pollUntilIdle, POLL_MS); + } catch {} + }; + + const apply = async () => { + setIsApplying(true); + try { + const task = await libraryApi.applyTitleCleanup( + mediaServerId, libraryId, [...selected], regenerate); + onStarted(task.task_id); + onClose(); + } catch (error) { + toast.error(errorMessage(error, 'Could not start the renames.'), + { title: 'Clean up titles' }); + setIsApplying(false); + } + }; + + const rows = proposals ?? []; + const pending = pendingCount(rows); + const finished = runSummary(rows); + const progressLine = checkProgressLine(check); + const serverName = mediaServerName || 'your media server'; + + return ( + + {summaryLine(rows, libraryName)} + {pending > 0 && ( + <> Approving renames them on {serverName} and locks the field. Files + on disk are not touched. + )} + + ) + } + isBusy={isApplying} + onClose={onClose} + footer={ + <> + {finished && {finished}} + + + + } + > +
+
+ + {progressLine && {progressLine}} +
+ {check.running && ( +
+
+
+ )} + {proposals === null ? ( +
+
+ ) : rows.length === 0 ? ( +

+ {check.running + ? 'Checking every title against the catalogue. Anything that disagrees will appear here.' + : 'Nothing to review. Titles are checked at the end of every sync — press Check titles ' + + 'to look again now.'} +

+ ) : ( + <> + {pending > 0 && ( + + )} + +
    + {rows.map((proposal) => ( +
  • + + {proposal.error &&

    {proposal.error}

    } +
  • + ))} +
+ + {pending > 0 && ( + + )} + + )} +
+ + ); +} + +const POLL_MS = 1500; + +function withNewRowsSelected( + current: Set, + previous: TitleProposal[], + next: TitleProposal[] +): Set { + const known = new Set(previous.map((proposal) => proposal.item_id)); + const updated = new Set(current); + for (const proposal of next) { + if (!known.has(proposal.item_id) && isPending(proposal)) updated.add(proposal.item_id); + } + + for (const proposal of next) { + if (!isPending(proposal)) updated.delete(proposal.item_id); + } + return updated; +} + +function StatusIcon({ status }: { status: TitleProposal['status'] }) { + return status === 'renamed' ? ( + + ) : ( + + ); +} diff --git a/affiche-frontend/src/components/library/index.ts b/affiche-frontend/src/components/library/index.ts index 254ed7d..9ba2142 100644 --- a/affiche-frontend/src/components/library/index.ts +++ b/affiche-frontend/src/components/library/index.ts @@ -10,6 +10,8 @@ export { ItemDetail } from './ItemDetail'; export { EpisodeList } from './EpisodeList'; export { PosterBrowserModal } from './PosterBrowserModal'; export { PosterCompareModal } from './PosterCompareModal'; +export { RenameItemModal } from './RenameItemModal'; +export { TitleCleanupPanel } from './TitleCleanupPanel'; export { posterTargetFromCollection, posterTargetFromItem, type PosterTarget } from './posterTarget'; export { parseViewMode, DEFAULT_VIEW_MODE } from './viewMode'; export { providerFilterOptions, type ProviderFilterOption } from './providerFilter'; diff --git a/affiche-frontend/src/components/library/listingFilters.ts b/affiche-frontend/src/components/library/listingFilters.ts index 061f128..91f3a1a 100644 --- a/affiche-frontend/src/components/library/listingFilters.ts +++ b/affiche-frontend/src/components/library/listingFilters.ts @@ -12,6 +12,8 @@ export const STATUS_FILTER_OPTIONS: { { value: 'uploaded', label: 'On server', count: 'uploaded' }, { value: 'errors', label: 'Failed', count: 'errors' }, { value: 'locked', label: 'Locked', count: 'locked' }, + + { value: 'mismatched_titles', label: 'Title mismatch', count: 'mismatched_titles' }, ]; export interface FilterChip { diff --git a/affiche-frontend/src/components/library/renameOptions.test.ts b/affiche-frontend/src/components/library/renameOptions.test.ts new file mode 100644 index 0000000..f25f704 --- /dev/null +++ b/affiche-frontend/src/components/library/renameOptions.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { noSuggestionMessage, preselectedOption, renameOptions } from './renameOptions'; + +const suggestions = (overrides: Partial[0]> = {}) => ({ + current: 'My.movie.2012.1080p', + matched: { title: 'My Movie', provider: 'tmdb' }, + ...overrides, +}); + +describe('renameOptions', () => { + it('offers the catalogue name for the id, labelled with who gave it', () => { + expect(renameOptions(suggestions())).toEqual([ + { title: 'My Movie', source: 'TMDB · matched by id' }, + ]); + }); + + it('names whichever catalogue answered', () => { + const [option] = renameOptions(suggestions({ matched: { title: 'X', provider: 'tvdb' } })); + expect(option.source).toBe('TVDB · matched by id'); + }); + + it('offers nothing when no id could answer', () => { + + expect(renameOptions(suggestions({ matched: null }))).toEqual([]); + }); + + it('drops a row that would change nothing', () => { + const options = renameOptions(suggestions({ + current: 'My Movie', + matched: { title: 'My Movie', provider: 'tmdb' }, + })); + expect(options).toEqual([]); + }); +}); + +describe('preselectedOption', () => { + it('takes the id match when there is one', () => { + expect(preselectedOption(renameOptions(suggestions()))?.title).toBe('My Movie'); + }); + + it('pre-fills nothing when no catalogue answered', () => { + expect(preselectedOption(renameOptions(suggestions({ matched: null })))).toBeNull(); + }); +}); + +describe('noSuggestionMessage', () => { + it('says nothing when there is a suggestion to show', () => { + expect(noSuggestionMessage(suggestions())).toBeNull(); + }); + + it('blames the media server only when the item really has no id', () => { + const message = noSuggestionMessage(suggestions({ matched: null, reason: 'no_id' })); + expect(message).toMatch(/never matched it/); + }); + + it('tells an item that already agrees with the catalogue that nothing is wrong', () => { + + const message = noSuggestionMessage(suggestions({ matched: null, reason: 'already_correct' })); + expect(message).toMatch(/already matches/); + expect(message).not.toMatch(/never matched it/); + }); + + it('points at settings when no provider is configured', () => { + const message = noSuggestionMessage(suggestions({ matched: null, reason: 'not_configured' })); + expect(message).toMatch(/Settings/); + expect(message).not.toMatch(/never matched it/); + }); + + it('says the id was not recognised rather than absent', () => { + const message = noSuggestionMessage(suggestions({ matched: null, reason: 'not_found' })); + expect(message).toMatch(/has an id/); + expect(message).not.toMatch(/never matched it/); + }); + + it('falls back to a sentence rather than nothing on an unknown reason', () => { + expect(noSuggestionMessage(suggestions({ matched: null, reason: null }))).toBeTruthy(); + }); +}); diff --git a/affiche-frontend/src/components/library/renameOptions.ts b/affiche-frontend/src/components/library/renameOptions.ts new file mode 100644 index 0000000..133137b --- /dev/null +++ b/affiche-frontend/src/components/library/renameOptions.ts @@ -0,0 +1,40 @@ +import type { ItemTitleSuggestions, NoSuggestionReason } from '../../types'; + +export interface RenameOption { + + title: string; + + source: string; +} + +export function renameOptions(suggestions: ItemTitleSuggestions): RenameOption[] { + const { matched } = suggestions; + if (!matched || matched.title === suggestions.current.trim()) return []; + + return [{ + title: matched.title, + source: `${matched.provider.toUpperCase()} · matched by id`, + }]; +} + +export function preselectedOption(options: RenameOption[]): RenameOption | null { + return options[0] ?? null; +} + +export function noSuggestionMessage( + suggestions: ItemTitleSuggestions +): string | null { + if (suggestions.matched) return null; + + const messages: Record = { + no_id: 'No TMDB or TVDB id on this item, so there is nothing to look it up by — your media ' + + 'server never matched it. Type the title you want below.', + not_configured: 'No poster provider is configured, so no catalogue can be asked. Add one in ' + + 'Settings, or type the title you want below.', + not_found: 'This item has an id, but no configured catalogue recognised it. Type the title you ' + + 'want below.', + already_correct: 'This title already matches what the catalogue calls it — there is nothing to ' + + 'correct. You can still change it below.', + }; + return messages[suggestions.reason ?? 'no_id'] ?? messages.no_id; +} diff --git a/affiche-frontend/src/components/library/titleCleanup.test.ts b/affiche-frontend/src/components/library/titleCleanup.test.ts new file mode 100644 index 0000000..62f2715 --- /dev/null +++ b/affiche-frontend/src/components/library/titleCleanup.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import type { TitleProposal } from '../../types'; +import { + approveLabel, checkFraction, checkProgressLine, initialSelection, pendingCount, runSummary, + summaryLine, toggle, toggleAll, +} from './titleCleanup'; + +const row = (item_id: number, status: TitleProposal['status'] = 'pending'): TitleProposal => ({ + item_id, + current_title: `Current ${item_id}`, + proposed_title: `Proposed ${item_id}`, + provider: 'tmdb', + status, +}); + +describe('initialSelection', () => { + it('ticks every pending row, because the list is the answer not the question', () => { + expect([...initialSelection([row(1), row(2)])]).toEqual([1, 2]); + }); + + it('never ticks a row a run has already finished', () => { + const selected = initialSelection([row(1), row(2, 'renamed'), row(3, 'failed')]); + expect([...selected]).toEqual([1]); + }); +}); + +describe('toggle', () => { + it('unticks a ticked row and ticks an unticked one', () => { + expect([...toggle(new Set([1, 2]), 2)]).toEqual([1]); + expect([...toggle(new Set([1]), 2)]).toEqual([1, 2]); + }); + + it('returns a new set, so the panel re-renders', () => { + const before = new Set([1]); + expect(toggle(before, 2)).not.toBe(before); + expect([...before]).toEqual([1]); + }); +}); + +describe('toggleAll', () => { + const proposals = [row(1), row(2), row(3, 'renamed')]; + + it('clears the selection when everything pending is already ticked', () => { + expect([...toggleAll(proposals, new Set([1, 2]))]).toEqual([]); + }); + + it('ticks every pending row from a partial selection', () => { + expect([...toggleAll(proposals, new Set([1]))]).toEqual([1, 2]); + }); + + it('does not tick finished rows when selecting all', () => { + expect(toggleAll(proposals, new Set()).has(3)).toBe(false); + }); +}); + +describe('approveLabel', () => { + it('counts what is selected, in words', () => { + expect(approveLabel(0)).toBe('Rename'); + expect(approveLabel(1)).toBe('Rename 1 item'); + expect(approveLabel(6)).toBe('Rename 6 items'); + }); +}); + +describe('summaryLine', () => { + it('names the library and counts the disagreements', () => { + expect(summaryLine([row(1), row(2)], 'Films FR')).toContain('2 titles in Films FR'); + }); + + it('reads as a sentence for a single row', () => { + expect(summaryLine([row(1)], 'Films FR')).toContain('1 title in Films FR'); + }); + + it('says plainly when nothing disagrees', () => { + expect(summaryLine([], 'Films FR')).toMatch(/^No titles in Films FR/); + }); + + it('does not count rows a run has finished as still disagreeing', () => { + expect(summaryLine([row(1, 'renamed')], 'Films FR')).toMatch(/^No titles/); + }); +}); + +describe('runSummary', () => { + it('is silent until something has run', () => { + expect(runSummary([row(1), row(2)])).toBeNull(); + }); + + it('reports what a finished run did', () => { + expect(runSummary([row(1, 'renamed'), row(2, 'renamed')])).toBe('2 renamed'); + }); + + it('reports failures beside the renames', () => { + expect(runSummary([row(1, 'renamed'), row(2, 'failed')])).toBe('1 renamed, 1 could not be'); + }); +}); + +describe('pendingCount', () => { + it('counts only what can still be approved', () => { + expect(pendingCount([row(1), row(2, 'renamed'), row(3, 'failed')])).toBe(1); + }); +}); + +describe('checkProgressLine', () => { + const running = { running: true, checked: 12, total: 40, mismatched: 2 }; + + it('is silent when no check is running', () => { + expect(checkProgressLine({ ...running, running: false })).toBeNull(); + }); + + it('counts both how far it has got and what it has found', () => { + expect(checkProgressLine(running)).toBe('Checking 12 of 40 — 2 to fix so far'); + }); + + it('reads as a sentence for a single find', () => { + expect(checkProgressLine({ ...running, mismatched: 1 })).toContain('1 to fix so far'); + }); +}); + +describe('checkFraction', () => { + it('is zero before a total is known, rather than NaN', () => { + expect(checkFraction({ running: true, checked: 0, total: 0, mismatched: 0 })).toBe(0); + }); + + it('is the share checked so far', () => { + expect(checkFraction({ running: true, checked: 10, total: 40, mismatched: 0 })).toBe(0.25); + }); + + it('never exceeds one', () => { + expect(checkFraction({ running: true, checked: 50, total: 40, mismatched: 0 })).toBe(1); + }); +}); diff --git a/affiche-frontend/src/components/library/titleCleanup.ts b/affiche-frontend/src/components/library/titleCleanup.ts new file mode 100644 index 0000000..b3340e7 --- /dev/null +++ b/affiche-frontend/src/components/library/titleCleanup.ts @@ -0,0 +1,59 @@ +import type { TitleCheckProgress, TitleProposal } from '../../types'; + +export function initialSelection(proposals: TitleProposal[]): Set { + return new Set(proposals.filter(isPending).map((proposal) => proposal.item_id)); +} + +export function isPending(proposal: TitleProposal): boolean { + return proposal.status === 'pending'; +} + +export function toggle(selected: Set, itemId: number): Set { + const next = new Set(selected); + if (!next.delete(itemId)) next.add(itemId); + return next; +} + +export function toggleAll(proposals: TitleProposal[], selected: Set): Set { + return selected.size === pendingCount(proposals) + ? new Set() + : initialSelection(proposals); +} + +export function pendingCount(proposals: TitleProposal[]): number { + return proposals.filter(isPending).length; +} + +export function approveLabel(count: number): string { + if (count === 0) return 'Rename'; + return count === 1 ? 'Rename 1 item' : `Rename ${count} items`; +} + +export function summaryLine(proposals: TitleProposal[], libraryName: string): string { + const pending = pendingCount(proposals); + if (pending === 0) { + return `No titles in ${libraryName} disagree with the catalogue your media server matched them to.`; + } + const subject = pending === 1 ? '1 title' : `${pending} titles`; + return `${subject} in ${libraryName} disagree with the catalogue your media server matched them to.`; +} + +export function runSummary(proposals: TitleProposal[]): string | null { + const renamed = proposals.filter((proposal) => proposal.status === 'renamed').length; + const failed = proposals.filter((proposal) => proposal.status === 'failed').length; + if (!renamed && !failed) return null; + + const parts = [`${renamed} renamed`]; + if (failed) parts.push(`${failed} could not be`); + return parts.join(', '); +} + +export function checkProgressLine(check: TitleCheckProgress): string | null { + if (!check.running) return null; + const found = check.mismatched === 1 ? '1 to fix' : `${check.mismatched} to fix`; + return `Checking ${check.checked} of ${check.total} — ${found} so far`; +} + +export function checkFraction(check: TitleCheckProgress): number { + return check.total > 0 ? Math.min(check.checked / check.total, 1) : 0; +} diff --git a/affiche-frontend/src/hooks/useOpenItem.ts b/affiche-frontend/src/hooks/useOpenItem.ts index c0abb0e..304b832 100644 --- a/affiche-frontend/src/hooks/useOpenItem.ts +++ b/affiche-frontend/src/hooks/useOpenItem.ts @@ -98,15 +98,17 @@ export function useOpenItem({ errorFallback, setBusy, message, - }: ItemActionSpec) => { - if (!item || !library) return; + }: ItemActionSpec): Promise => { + if (!item || !library) return false; setBusy?.(true); if (message) setPageMessage(message); try { onSuccess(await request(library.media_server_id, item.library_id, item.id)); + return true; } catch (error) { toast.error(errorMessage(error, errorFallback), { title: errorTitle }); + return false; } finally { setBusy?.(false); if (message) setPageMessage(null); @@ -168,6 +170,23 @@ export function useOpenItem({ errorFallback: 'Failed to change the lock on this item.', }); + const rename = async (title: string, regenerate: boolean) => { + const renamed = await run({ + request: (mediaServerId, libraryId, itemId) => + libraryApi.renameItem(mediaServerId, libraryId, itemId, title), + onSuccess: (updated) => { + setItem((prev) => (prev && prev.id === updated.id ? { ...prev, ...updated } : prev)); + refreshListing(true); + }, + errorTitle: 'Rename failed', + errorFallback: 'Failed to rename this item.', + setBusy: setPageBusy, + message: 'Renaming…', + }); + if (renamed && regenerate) await generatePoster(); + return renamed; + }; + const uploadPoster = () => run({ request: libraryApi.uploadItemPoster, @@ -195,5 +214,6 @@ export function useOpenItem({ resetPoster, uploadPoster, toggleLock, + rename, }; } diff --git a/affiche-frontend/src/hooks/usePosterBrowseQuery.ts b/affiche-frontend/src/hooks/usePosterBrowseQuery.ts index 99d9728..9bf405a 100644 --- a/affiche-frontend/src/hooks/usePosterBrowseQuery.ts +++ b/affiche-frontend/src/hooks/usePosterBrowseQuery.ts @@ -3,12 +3,26 @@ import { useState } from 'react'; import { TEXTLESS } from '../constants/languages'; import type { PosterSort } from '../components/library/posterSort'; +export interface TmdbMatch { + tmdbId: number | null; + seasonNumber: number | null; +} + +const NO_MATCH: TmdbMatch = { tmdbId: null, seasonNumber: null }; + +function parsed(draft: string): number | null { + const value = Number.parseInt(draft.trim(), 10); + return Number.isNaN(value) ? null : value; +} + interface PosterBrowseQueryOptions { itemTitle: string; year?: number; seasonNumber?: number; + tmdbMatch?: TmdbMatch; + onSourceChanged: () => void; } @@ -16,6 +30,7 @@ export function usePosterBrowseQuery({ itemTitle, year, seasonNumber, + tmdbMatch, onSourceChanged, }: PosterBrowseQueryOptions) { const [searchTitle, setSearchTitle] = useState(itemTitle); @@ -31,6 +46,20 @@ export function usePosterBrowseQuery({ const [searchSeasonNumber, setSearchSeasonNumber] = useState(seasonNumber ?? 0); const [useShowArt, setUseShowArt] = useState(false); + const [tmdbIdDraft, setTmdbIdDraft] = useState( + tmdbMatch?.tmdbId != null ? String(tmdbMatch.tmdbId) : ''); + const [tmdbSeasonDraft, setTmdbSeasonDraft] = useState( + tmdbMatch?.seasonNumber != null ? String(tmdbMatch.seasonNumber) : ''); + const [appliedTmdbMatch, setAppliedTmdbMatch] = useState(tmdbMatch ?? NO_MATCH); + + const clearTmdbMatch = (): TmdbMatch => { + setTmdbIdDraft(''); + setTmdbSeasonDraft(''); + setAppliedTmdbMatch(NO_MATCH); + onSourceChanged(); + return NO_MATCH; + }; + return { searchTitle, setSearchTitle, @@ -61,5 +90,23 @@ export function usePosterBrowseQuery({ setUseShowArt(value); onSourceChanged(); }, + + tmdbIdDraft, + setTmdbIdDraft, + tmdbSeasonDraft, + setTmdbSeasonDraft, + appliedTmdbMatch, + + applyTmdbMatch: (): TmdbMatch => { + + const tmdbId = parsed(tmdbIdDraft); + if (tmdbId === null) return clearTmdbMatch(); + const match = { tmdbId, seasonNumber: parsed(tmdbSeasonDraft) }; + setAppliedTmdbMatch(match); + onSourceChanged(); + return match; + }, + + clearTmdbMatch, }; } diff --git a/affiche-frontend/src/hooks/usePosterCandidates.ts b/affiche-frontend/src/hooks/usePosterCandidates.ts index 8340bfd..1f5581c 100644 --- a/affiche-frontend/src/hooks/usePosterCandidates.ts +++ b/affiche-frontend/src/hooks/usePosterCandidates.ts @@ -12,6 +12,9 @@ interface UsePosterCandidatesOptions { seasonNumber?: number; + tmdbIdOverride?: number; + tmdbSeasonNumberOverride?: number; + provider: string; language: string; @@ -23,6 +26,8 @@ export function usePosterCandidates({ tvdbId, collectionId, seasonNumber, + tmdbIdOverride, + tmdbSeasonNumberOverride, provider, language, }: UsePosterCandidatesOptions) { @@ -55,6 +60,8 @@ export function usePosterCandidates({ season_number: seasonNumber, tmdb_id: tmdbId, tvdb_id: tvdbId, + tmdb_id_override: tmdbIdOverride, + tmdb_season_number_override: tmdbSeasonNumberOverride, provider: provider || undefined, language: language || undefined, }) @@ -79,7 +86,8 @@ export function usePosterCandidates({ useEffect(() => { refetch(); - }, [seasonNumber, provider, language, canBrowse, collectionId]); + }, [seasonNumber, tmdbIdOverride, tmdbSeasonNumberOverride, provider, language, canBrowse, + collectionId]); const search = async (name: string, year?: number) => { setIsSearching(true); diff --git a/affiche-frontend/src/pages/CollectionsPage.test.tsx b/affiche-frontend/src/pages/CollectionsPage.test.tsx index 9b51cb4..775d282 100644 --- a/affiche-frontend/src/pages/CollectionsPage.test.tsx +++ b/affiche-frontend/src/pages/CollectionsPage.test.tsx @@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor, within } from '@testing-librar import { CollectionsPage } from './CollectionsPage'; import { collectionsApi, libraryApi, postersApi } from '../api'; -import type { Collection, Library } from '../types'; +import type { Collection, Library, PosterCandidate } from '../types'; vi.mock('../api', async (importOriginal) => { const actual = await importOriginal(); @@ -70,10 +70,16 @@ const renderPage = (props: Partial[0]> = {}) /> ); +const MATCHED = collection({ tmdb_collection_id: 8091 }); +const CANDIDATE: PosterCandidate = { url: 'https://cdn/a.jpg', provider: 'tmdb', rank: 0, rank_score: 1 }; + beforeEach(() => { toast.error.mockReset(); vi.mocked(collectionsApi.resolveIds).mockClear(); + vi.mocked(postersApi.applyCollectionPoster).mockClear(); + vi.mocked(postersApi.getCollectionPosters).mockClear(); + vi.mocked(postersApi.getCollectionPosters).mockResolvedValue([]); vi.mocked(libraryApi.getLibrarySettings).mockResolvedValue( { upload_enabled: true } as Awaited>); getCollections.mockClear(); @@ -194,33 +200,36 @@ describe('CollectionsPage', () => { it('offers Save & upload as the default for a library that uploads', async () => { getCollections.mockResolvedValue({ - collections: [collection()], total: 1, page: 0, page_size: 50, + collections: [MATCHED], total: 1, page: 0, page_size: 50, }); - vi.mocked(collectionsApi.getCollection).mockResolvedValue({ ...collection(), members: [] }); + vi.mocked(collectionsApi.getCollection).mockResolvedValue({ ...MATCHED, members: [] }); + vi.mocked(postersApi.getCollectionPosters).mockResolvedValue([CANDIDATE]); renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); - await screen.findByRole('dialog'); + fireEvent.doubleClick(await screen.findByRole('button', { name: /^Poster \d+ from / })); - expect(await screen.findByText(/This library uploads new posters/)).toBeInTheDocument(); + await waitFor(() => expect(postersApi.applyCollectionPoster) + .toHaveBeenCalledWith(expect.objectContaining({ upload: true }))); }); it('keeps Save as the default for a library that does not upload', async () => { getCollections.mockResolvedValue({ - collections: [collection()], total: 1, page: 0, page_size: 50, + collections: [MATCHED], total: 1, page: 0, page_size: 50, }); - vi.mocked(collectionsApi.getCollection).mockResolvedValue({ ...collection(), members: [] }); + vi.mocked(collectionsApi.getCollection).mockResolvedValue({ ...MATCHED, members: [] }); vi.mocked(libraryApi.getLibrarySettings).mockResolvedValue( { upload_enabled: false } as Awaited>); + vi.mocked(postersApi.getCollectionPosters).mockResolvedValue([CANDIDATE]); renderPage(); fireEvent.click(await screen.findByText('Alien Saga')); fireEvent.click(await screen.findByRole('button', { name: /choose artwork/i })); - await screen.findByRole('dialog'); + fireEvent.doubleClick(await screen.findByRole('button', { name: /^Poster \d+ from / })); - expect(await screen.findByText(/Save keeps the poster in Affiche\./)).toBeInTheDocument(); - expect(screen.queryByText(/This library uploads new posters/)).not.toBeInTheDocument(); + await waitFor(() => expect(postersApi.applyCollectionPoster) + .toHaveBeenCalledWith(expect.objectContaining({ upload: false }))); }); it('does not browse providers for a collection no catalogue matched', async () => { diff --git a/affiche-frontend/src/pages/LibraryPage.tsx b/affiche-frontend/src/pages/LibraryPage.tsx index 0ae3592..a4059f3 100644 --- a/affiche-frontend/src/pages/LibraryPage.tsx +++ b/affiche-frontend/src/pages/LibraryPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useEffectEvent } from 'react'; import { useNavigate } from 'react-router-dom'; import { Info } from 'lucide-react'; import { Header } from '../components/layout'; -import { ItemGrid, ItemTable, ItemDetail, EpisodeList, LibraryRows, LibraryStage, PosterBrowserModal, AlphabetIndex, SelectionBar, posterTargetFromItem, type StagePoster } from '../components/library'; +import { ItemGrid, ItemTable, ItemDetail, EpisodeList, LibraryRows, LibraryStage, PosterBrowserModal, RenameItemModal, TitleCleanupPanel, AlphabetIndex, SelectionBar, posterTargetFromItem, type StagePoster } from '../components/library'; import { ConfirmModal } from '../components/common'; import { errorMessage, libraryApi } from '../api'; import { @@ -23,6 +23,7 @@ import { useTaskTracking, } from '../hooks'; import { useToast } from '../context/ToastContext'; +import type { TmdbMatch } from '../hooks/usePosterBrowseQuery'; import type { Library, LibraryItem, TaskKind } from '../types'; import { libraryPath } from '../routes'; import { LIBRARY_ACTIONS, type LibraryActionName } from './libraryActions'; @@ -295,6 +296,9 @@ export function LibraryPage({ setResetIncludeUnprocessed(false); setConfirmAction('reset'); }; + const handleMatchSeasonsClick = () => setConfirmAction('season-match'); + + const [isCleaningTitles, setIsCleaningTitles] = useState(false); const runLibraryAction = async (action: LibraryActionName) => { setConfirmAction(null); @@ -382,7 +386,41 @@ export function LibraryPage({ } }; + const [isRenaming, setIsRenaming] = useState(false); + + const handleRenameConfirm = async (title: string, regenerate: boolean) => { + if (await openItem.rename(title, regenerate)) setIsRenaming(false); + }; + const handleItemGeneratePosterClick = () => openItem.generatePoster(); + + const findSeasonTmdbMatch = async () => { + const item = openItem.item; + const season = posterBrowser.season; + if (!item || !season || !openItem.library) return null; + try { + const found = await libraryApi.suggestSeasonTmdbMatch( + openItem.library.media_server_id, item.library_id, item.id, season.season_number); + return { + tmdbId: found.tmdb_id, + seasonNumber: found.tmdb_season_number, + reason: found.reason, + }; + } catch { + return null; + } + }; + + const saveSeasonTmdbMatch = async (match: TmdbMatch) => { + const item = openItem.item; + const season = posterBrowser.season; + if (!item || !season || !openItem.library) return; + await libraryApi.setSeasonTmdbMatch( + openItem.library.media_server_id, item.library_id, item.id, season.season_number, + match.tmdbId, match.seasonNumber, + ); + openItem.refreshImages(); + }; const handleItemResetClick = () => setConfirmAction('item-reset'); const confirmed = (run: () => void) => () => { @@ -394,6 +432,7 @@ export function LibraryPage({ 'generate': () => runLibraryAction('generate'), 'upload': () => runLibraryAction('upload'), 'reset': () => runLibraryAction('reset'), + 'season-match': () => runLibraryAction('season-match'), 'item-reset': confirmed(openItem.resetPoster), 'selection-generate': confirmed(selection.generate), 'selection-upload': confirmed(selection.upload), @@ -509,6 +548,7 @@ export function LibraryPage({ onSelectPoster={(posterUrl) => posterBrowser.open(null, posterUrl)} onUpload={openItem.uploadPoster} onToggleLock={openItem.toggleLock} + onRename={openItem.library ? () => setIsRenaming(true) : undefined} onSeasonSelectPoster={(season) => posterBrowser.open(season)} onSeasonClick={openItem.openSeason} isLoading={isActionLoading} @@ -520,6 +560,12 @@ export function LibraryPage({ )} + {isRenaming && openItem.library && ( + setIsRenaming(false)} + /> + )} {confirmModalProps && ( setIsCleaningTitles(true) : undefined} onRefreshItems={handleRefreshItems} onEmptyTrash={handleEmptyTrashClick} onStopTask={stopTask} @@ -657,6 +717,16 @@ export function LibraryPage({ {alphabet.isEnabled && alphabet.entries.length > 0 && ( )} + {isCleaningTitles && selectedLibrary && mediaServerId && ( + startTaskTracking(taskId)} + onClose={() => setIsCleaningTitles(false)} + /> + )} {confirmModalProps && ( = { errorTitle: 'Sync failed', errorFallback: 'Could not start the library sync.', }, + 'season-match': { + + request: ({ library }) => + library + ? libraryApi.resolveSeasonTmdbMatches(library.media_server_id, library.id) + : Promise.reject(new Error('Open a library to match its seasons.')), + errorTitle: 'Season matching failed', + errorFallback: 'Could not start the season matching.', + }, generate: { request: ({ mediaServerId, library }) => library diff --git a/affiche-frontend/src/pages/libraryConfirmations.test.ts b/affiche-frontend/src/pages/libraryConfirmations.test.ts index b816bbf..8418310 100644 --- a/affiche-frontend/src/pages/libraryConfirmations.test.ts +++ b/affiche-frontend/src/pages/libraryConfirmations.test.ts @@ -16,7 +16,7 @@ describe('confirmationCopy', () => { it('marks exactly the destructive actions as danger', () => { const actions: ConfirmAction[] = [ - 'generate', 'upload', 'reset', 'item-reset', + 'generate', 'upload', 'reset', 'season-match', 'item-reset', 'selection-generate', 'selection-upload', 'selection-reset', 'empty-trash', ]; const danger = actions.filter((a) => confirmationCopy(a, ctx).variant === 'danger'); @@ -24,6 +24,16 @@ describe('confirmationCopy', () => { expect(danger).toEqual(['reset', 'item-reset', 'selection-reset', 'empty-trash']); }); + it('says the season match corrects some seasons and leaves the rest', () => { + const { message, variant } = confirmationCopy('season-match', ctx); + + expect(message).toContain('Movies'); + + expect(message).toMatch(/left for you|by hand/); + expect(message).toContain('already matched are not touched'); + expect(variant).toBeUndefined(); + }); + it('offers the unprocessed opt-in only on the library-wide reset', () => { expect(confirmationCopy('reset', ctx).checkboxLabel).toBe('Also reset items with no poster yet'); expect(confirmationCopy('item-reset', ctx).checkboxLabel).toBeUndefined(); diff --git a/affiche-frontend/src/pages/libraryConfirmations.ts b/affiche-frontend/src/pages/libraryConfirmations.ts index e9f4b7b..df0a6ce 100644 --- a/affiche-frontend/src/pages/libraryConfirmations.ts +++ b/affiche-frontend/src/pages/libraryConfirmations.ts @@ -1,5 +1,5 @@ export type ConfirmAction = - | 'generate' | 'upload' | 'reset' + | 'generate' | 'upload' | 'reset' | 'season-match' | 'item-reset' | 'selection-generate' | 'selection-upload' | 'selection-reset' | 'empty-trash'; @@ -90,6 +90,12 @@ export function confirmationCopy( confirmLabel: `Reset ${selectionCount}`, variant: 'danger', }; + case 'season-match': + return { + title: 'Match seasons', + message: `TMDB files some anthologies as one show per season, so a season's artwork can sit under a different entry than the show's. This checks every show in ${libraryName} against TMDB and corrects the seasons it can identify with confidence; the rest are left for you to set by hand. Seasons you have already matched are not touched.`, + confirmLabel: 'Match', + }; case 'empty-trash': return { title: 'Empty trash', diff --git a/affiche-frontend/src/types/index.ts b/affiche-frontend/src/types/index.ts index e8618a9..1b0d6f7 100644 --- a/affiche-frontend/src/types/index.ts +++ b/affiche-frontend/src/types/index.ts @@ -36,7 +36,10 @@ export interface Library { enabled?: boolean; } -export type ItemStatusFilter = 'unprocessed' | 'errors' | 'locked' | 'ready' | 'uploaded'; +export type ItemStatusFilter = + | 'unprocessed' | 'errors' | 'locked' | 'ready' | 'uploaded' + + | 'mismatched_titles'; export const NO_PROVIDER = 'none'; @@ -134,6 +137,9 @@ export interface ItemSeason { imdb_id?: string; tmdb_id?: string; tvdb_id?: string; + + tmdb_id_override?: number | null; + tmdb_season_number_override?: number | null; poster_url?: string; poster_provider?: string | null; @@ -145,6 +151,52 @@ export interface ItemSeason { source_poster_version?: string | null; } +export interface SeasonTmdbMatchSuggestion { + tmdb_id: number; + tmdb_season_number: number; + series_name: string; + reason: string; +} + +export interface TitleCheckProgress { + running: boolean; + checked: number; + total: number; + mismatched: number; +} + +export interface TitleCleanupState { + proposals: TitleProposal[]; + check: TitleCheckProgress; +} + +export interface TitleProposal { + item_id: number; + current_title: string; + proposed_title: string; + provider?: string | null; + status: 'pending' | 'renamed' | 'failed'; + error?: string | null; +} + +export interface MatchedTitle { + title: string; + provider: string; +} + +export interface ItemTitleSuggestions { + current: string; + matched?: MatchedTitle | null; + + reason?: NoSuggestionReason | null; +} + +export type NoSuggestionReason = + | 'no_id' + | 'not_configured' + | 'not_found' + | 'already_correct'; + export interface LibraryItemWithSeasons extends LibraryItem { seasons: ItemSeason[]; } @@ -205,6 +257,8 @@ export interface ItemStatusCounts { ready?: number; uploaded?: number; + + mismatched_titles?: number; } export interface ItemStats {