Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Publish image

on:
push:
branches: [main]
branches: [main, develop]
tags: ["v*"]
paths-ignore: ["**.md", "docs/**", "LICENSE"]
workflow_dispatch:
Expand Down Expand Up @@ -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}}

Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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')
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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')
170 changes: 170 additions & 0 deletions affiche-backend/affiche/api/routers/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions affiche-backend/affiche/api/routers/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -151,19 +151,30 @@ 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)
) -> List[PosterCandidate]:
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")
Expand Down
Loading
Loading