Skip to content

Commit 434ca27

Browse files
committed
feat(sync): support TVDB targets when remapping unmatched seasons (#178)
1 parent f357b58 commit 434ca27

5 files changed

Lines changed: 158 additions & 28 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""add tvdb target to show_season_overrides
2+
3+
Revision ID: d1e2f3a4b5c6
4+
Revises: c8d9e0f1a2b3
5+
Create Date: 2026-08-15
6+
"""
7+
8+
from alembic import op
9+
10+
11+
revision = "d1e2f3a4b5c6"
12+
down_revision = "c8d9e0f1a2b3"
13+
branch_labels = None
14+
depends_on = None
15+
16+
17+
def upgrade() -> None:
18+
# A remap target is now either a TMDB show or a TVDB show (#178) - exactly
19+
# one of target_show_tmdb_id/target_show_tvdb_id is set, enforced at the
20+
# API layer rather than a DB constraint (matches Show.tmdb_id/tvdb_id,
21+
# which use the same either-or convention with no DB-level check).
22+
op.execute("ALTER TABLE show_season_overrides ALTER COLUMN target_show_tmdb_id DROP NOT NULL")
23+
op.execute("ALTER TABLE show_season_overrides ADD COLUMN IF NOT EXISTS target_show_tvdb_id INTEGER")
24+
25+
26+
def downgrade() -> None:
27+
op.execute("DELETE FROM show_season_overrides WHERE target_show_tmdb_id IS NULL")
28+
op.execute("ALTER TABLE show_season_overrides DROP COLUMN IF EXISTS target_show_tvdb_id")
29+
op.execute("ALTER TABLE show_season_overrides ALTER COLUMN target_show_tmdb_id SET NOT NULL")

backend/models/season_override.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
from typing import Optional
23
from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func
34
from sqlalchemy.orm import Mapped, mapped_column
45
from .base import Base
@@ -10,10 +11,14 @@ class ShowSeasonOverride(Base):
1011
UniqueConstraint("user_id", "source_show_tmdb_id", "source_season_number", name="uq_season_override"),
1112
)
1213

13-
id : Mapped[int] = mapped_column(Integer, primary_key=True)
14-
user_id : Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
15-
source_show_tmdb_id : Mapped[int] = mapped_column(Integer, nullable=False)
16-
source_season_number : Mapped[int] = mapped_column(Integer, nullable=False)
17-
target_show_tmdb_id : Mapped[int] = mapped_column(Integer, nullable=False)
18-
target_season_number : Mapped[int] = mapped_column(Integer, nullable=False)
19-
created_at : Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
14+
id : Mapped[int] = mapped_column(Integer, primary_key=True)
15+
user_id : Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
16+
source_show_tmdb_id : Mapped[int] = mapped_column(Integer, nullable=False)
17+
source_season_number : Mapped[int] = mapped_column(Integer, nullable=False)
18+
# Exactly one of target_show_tmdb_id/target_show_tvdb_id is set (#178) -
19+
# same either-or convention as Show.tmdb_id/tvdb_id, validated at the API
20+
# layer rather than a DB constraint.
21+
target_show_tmdb_id : Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
22+
target_show_tvdb_id : Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
23+
target_season_number : Mapped[int] = mapped_column(Integer, nullable=False)
24+
created_at : Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)

backend/routers/sync.py

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import re
44
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
5-
from pydantic import BaseModel
5+
from pydantic import BaseModel, model_validator
66
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
77
from sqlalchemy import select, update, delete, func, cast
88
from sqlalchemy.orm import selectinload
@@ -5928,9 +5928,18 @@ async def _stamp_matched_show_warnings(db: AsyncSession, user_id: int, warnings:
59285928
class SeasonOverrideBody(BaseModel):
59295929
source_show_tmdb_id: int
59305930
source_season_number: int
5931-
target_show_tmdb_id: int
5931+
# Exactly one of these two must be set - the target is either a TMDB show
5932+
# or a TVDB show (#178).
5933+
target_show_tmdb_id: int | None = None
5934+
target_show_tvdb_id: int | None = None
59325935
target_season_number: int
59335936

5937+
@model_validator(mode="after")
5938+
def _exactly_one_target(self) -> "SeasonOverrideBody":
5939+
if bool(self.target_show_tmdb_id) == bool(self.target_show_tvdb_id):
5940+
raise ValueError("Exactly one of target_show_tmdb_id or target_show_tvdb_id must be set")
5941+
return self
5942+
59345943

59355944
@router.get("/season-overrides")
59365945
async def list_season_overrides(
@@ -5942,24 +5951,36 @@ async def list_season_overrides(
59425951
)
59435952
overrides = result.scalars().all()
59445953

5945-
# Resolve show titles for all distinct TMDB IDs referenced by overrides
5946-
all_tmdb_ids = {o.source_show_tmdb_id for o in overrides} | {o.target_show_tmdb_id for o in overrides}
5947-
show_title_map: dict[int, str] = {}
5954+
# Resolve show titles for all distinct TMDB/TVDB IDs referenced by overrides
5955+
all_tmdb_ids = {o.source_show_tmdb_id for o in overrides} | {o.target_show_tmdb_id for o in overrides if o.target_show_tmdb_id}
5956+
all_tvdb_ids = {o.target_show_tvdb_id for o in overrides if o.target_show_tvdb_id}
5957+
tmdb_title_map: dict[int, str] = {}
5958+
tvdb_title_map: dict[int, str] = {}
59485959
if all_tmdb_ids:
59495960
shows_res = await db.execute(select(Show.tmdb_id, Show.title).where(Show.tmdb_id.in_(list(all_tmdb_ids))))
59505961
for tmdb_id, title in shows_res.all():
59515962
if tmdb_id is not None:
5952-
show_title_map[tmdb_id] = title
5963+
tmdb_title_map[tmdb_id] = title
5964+
if all_tvdb_ids:
5965+
shows_res = await db.execute(select(Show.tvdb_id, Show.title).where(Show.tvdb_id.in_(list(all_tvdb_ids))))
5966+
for tvdb_id, title in shows_res.all():
5967+
if tvdb_id is not None:
5968+
tvdb_title_map[tvdb_id] = title
59535969

59545970
return [
59555971
{
59565972
"id": o.id,
59575973
"source_show_tmdb_id": o.source_show_tmdb_id,
59585974
"source_season_number": o.source_season_number,
5959-
"source_show_title": show_title_map.get(o.source_show_tmdb_id),
5975+
"source_show_title": tmdb_title_map.get(o.source_show_tmdb_id),
59605976
"target_show_tmdb_id": o.target_show_tmdb_id,
5977+
"target_show_tvdb_id": o.target_show_tvdb_id,
5978+
"target_source": "tvdb" if o.target_show_tvdb_id else "tmdb",
59615979
"target_season_number": o.target_season_number,
5962-
"target_show_title": show_title_map.get(o.target_show_tmdb_id),
5980+
"target_show_title": (
5981+
tvdb_title_map.get(o.target_show_tvdb_id) if o.target_show_tvdb_id
5982+
else tmdb_title_map.get(o.target_show_tmdb_id)
5983+
),
59635984
}
59645985
for o in overrides
59655986
]
@@ -5980,14 +6001,19 @@ async def create_season_override(
59806001
)
59816002
override = existing.scalar_one_or_none()
59826003
if override:
6004+
# Clear whichever target field isn't set - editing a TMDB-targeted
6005+
# remap into a TVDB one (or vice versa) must not leave a stale id
6006+
# from the previous target behind.
59836007
override.target_show_tmdb_id = body.target_show_tmdb_id
6008+
override.target_show_tvdb_id = body.target_show_tvdb_id
59846009
override.target_season_number = body.target_season_number
59856010
else:
59866011
override = ShowSeasonOverride(
59876012
user_id=current_user.id,
59886013
source_show_tmdb_id=body.source_show_tmdb_id,
59896014
source_season_number=body.source_season_number,
59906015
target_show_tmdb_id=body.target_show_tmdb_id,
6016+
target_show_tvdb_id=body.target_show_tvdb_id,
59916017
target_season_number=body.target_season_number,
59926018
)
59936019
db.add(override)
@@ -5998,6 +6024,7 @@ async def create_season_override(
59986024
"source_show_tmdb_id": override.source_show_tmdb_id,
59996025
"source_season_number": override.source_season_number,
60006026
"target_show_tmdb_id": override.target_show_tmdb_id,
6027+
"target_show_tvdb_id": override.target_show_tvdb_id,
60016028
"target_season_number": override.target_season_number,
60026029
}
60036030

@@ -6070,6 +6097,65 @@ async def apply_season_override(
60706097
if not episodes:
60716098
return {"status": "ok", "remapped": 0}
60726099

6100+
if override.target_show_tvdb_id:
6101+
# ── TVDB target (#178) - some shows have season/episode structures
6102+
# that only line up under TVDB's numbering, not TMDB's, so the remap
6103+
# target needs to be able to point at a TVDB show too. ──────────────
6104+
from core import tvdb as tvdb_client
6105+
from routers.shows import get_user_tvdb_key
6106+
6107+
tvdb_api_key = await get_user_tvdb_key(db, current_user.id)
6108+
if not tvdb_api_key:
6109+
raise HTTPException(status_code=400, detail="TVDB API key required")
6110+
tvdb_lang = tvdb_client.tvdb_language(await get_user_metadata_language(db, current_user.id))
6111+
6112+
target_show_result = await db.execute(select(Show).where(Show.tvdb_id == override.target_show_tvdb_id))
6113+
target_show = target_show_result.scalar_one_or_none()
6114+
if not target_show:
6115+
try:
6116+
raw_series = await tvdb_client.get_series(override.target_show_tvdb_id, tvdb_api_key)
6117+
except Exception as e:
6118+
raise HTTPException(status_code=502, detail=f"Could not fetch target show from TVDB: {e}")
6119+
show_fmt = tvdb_client.format_series(raw_series, language=tvdb_lang)
6120+
target_show = Show(
6121+
tvdb_id=override.target_show_tvdb_id,
6122+
tmdb_id=None,
6123+
title=show_fmt.get("title") or f"TVDB #{override.target_show_tvdb_id}",
6124+
original_title=show_fmt.get("original_title"),
6125+
overview=show_fmt.get("overview"),
6126+
poster_path=show_fmt.get("poster_path"),
6127+
backdrop_path=show_fmt.get("backdrop_path"),
6128+
status=show_fmt.get("status"),
6129+
first_air_date=show_fmt.get("first_air_date"),
6130+
last_air_date=show_fmt.get("last_air_date"),
6131+
tmdb_data={"seasons": show_fmt.get("seasons", []), "genres": show_fmt.get("genres", []), "source": "tvdb"},
6132+
)
6133+
db.add(target_show)
6134+
await db.flush()
6135+
6136+
try:
6137+
raw_eps = await tvdb_client.get_series_episodes(
6138+
override.target_show_tvdb_id, override.target_season_number, tvdb_api_key, language=tvdb_lang
6139+
)
6140+
except Exception as e:
6141+
raise HTTPException(status_code=502, detail=f"Could not fetch target season from TVDB: {e}")
6142+
tvdb_ep_map = {e.get("number"): e for e in raw_eps}
6143+
6144+
async def remap_episode_tvdb(media: Media, raw_ep: dict | None) -> None:
6145+
media.show_id = target_show.id
6146+
media.season_number = override.target_season_number
6147+
if raw_ep:
6148+
await enrich_episode_from_tvdb(media, tvdb_client.format_episode(raw_ep))
6149+
6150+
for media in episodes:
6151+
raw_ep = tvdb_ep_map.get(media.episode_number)
6152+
await apply_media_change_safely(
6153+
db, media, lambda media=media, raw_ep=raw_ep: remap_episode_tvdb(media, raw_ep)
6154+
)
6155+
6156+
await db.commit()
6157+
return {"status": "ok", "remapped": len(episodes)}
6158+
60736159
# Find or create the target Show
60746160
target_show_result = await db.execute(
60756161
select(Show).where(Show.tmdb_id == override.target_show_tmdb_id)

frontend/src/lib/api.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,9 @@ export interface ShowSeasonOverride {
245245
source_show_tmdb_id: number;
246246
source_season_number: number;
247247
source_show_title: string | null;
248-
target_show_tmdb_id: number;
248+
target_show_tmdb_id: number | null;
249+
target_show_tvdb_id: number | null;
250+
target_source: "tmdb" | "tvdb";
249251
target_season_number: number;
250252
target_show_title: string | null;
251253
}

frontend/src/pages/connections.astro

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -458,8 +458,11 @@ const sonarrConfigured = !!(settings.sonarr_url && settings.sonarr_token);
458458
<span class="text-zinc-200 font-medium">{o.source_show_title ?? `TMDB #${o.source_show_tmdb_id}`}</span>
459459
<span class="ml-1.5 text-zinc-500">Season {o.source_season_number}</span>
460460
<span class="mx-1.5 text-zinc-600">→</span>
461-
<span class="text-zinc-300">{o.target_show_title ?? `TMDB #${o.target_show_tmdb_id}`}</span>
461+
<span class="text-zinc-300">{o.target_show_title ?? (o.target_source === 'tvdb' ? `TVDB #${o.target_show_tvdb_id}` : `TMDB #${o.target_show_tmdb_id}`)}</span>
462462
<span class="ml-1.5 text-zinc-500">Season {o.target_season_number}</span>
463+
<span class={`ml-1.5 text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${o.target_source === 'tvdb' ? 'bg-blue-500/15 text-blue-400' : 'bg-emerald-500/15 text-emerald-400'}`}>
464+
{o.target_source}
465+
</span>
463466
</div>
464467
<button
465468
type="button"
@@ -4828,7 +4831,8 @@ const sonarrConfigured = !!(settings.sonarr_url && settings.sonarr_token);
48284831
seasonsList.innerHTML = '';
48294832
seasonsLoading.classList.remove('hidden');
48304833
try {
4831-
const showRes = await fetch(`/api/proxy/shows/${tmdbId}`, {
4834+
const showUrl = tvdbId ? `/api/proxy/shows/tvdb/${tvdbId}` : `/api/proxy/shows/${tmdbId}`;
4835+
const showRes = await fetch(showUrl, {
48324836
headers: { 'Authorization': `Bearer ${token}` },
48334837
});
48344838
const showData = showRes.ok ? await showRes.json() : null;
@@ -5001,18 +5005,20 @@ const sonarrConfigured = !!(settings.sonarr_url && settings.sonarr_token);
50015005
}
50025006

50035007
// Remap mode
5004-
if (!targetTmdbId || !targetSeasonNumber) return;
5008+
if ((!targetTmdbId && !targetTvdbId) || !targetSeasonNumber) return;
50055009
saveBtn.textContent = 'Saving…';
50065010
try {
5011+
const remapBody: any = {
5012+
source_show_tmdb_id: srcTmdbId,
5013+
source_season_number: srcSeason,
5014+
target_season_number: targetSeasonNumber,
5015+
};
5016+
if (targetTvdbId) remapBody.target_show_tvdb_id = targetTvdbId;
5017+
else remapBody.target_show_tmdb_id = targetTmdbId;
50075018
const createRes = await fetch('/api/proxy/sync/season-overrides', {
50085019
method: 'POST',
50095020
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
5010-
body: JSON.stringify({
5011-
source_show_tmdb_id: srcTmdbId,
5012-
source_season_number: srcSeason,
5013-
target_show_tmdb_id: targetTmdbId,
5014-
target_season_number: targetSeasonNumber,
5015-
}),
5021+
body: JSON.stringify(remapBody),
50165022
});
50175023
if (!createRes.ok) throw new Error(await createRes.text());
50185024
const override = await createRes.json();
@@ -5041,13 +5047,15 @@ const sonarrConfigured = !!(settings.sonarr_url && settings.sonarr_token);
50415047
(document.getElementById('remap-modal-title') as HTMLElement).textContent = 'Remap Season';
50425048
(document.getElementById('remap-source-label') as HTMLElement).textContent = `"${title}" — Season ${season}`;
50435049
(document.getElementById('remap-tmdb-label') as HTMLElement).textContent = 'Search for the correct TMDB show';
5044-
(document.getElementById('remap-tmdb-section') as HTMLElement).classList.remove('hidden');
5045-
(document.getElementById('remap-tvdb-section') as HTMLElement).classList.add('hidden');
50465050
(document.getElementById('remap-seasons-area') as HTMLElement).classList.remove('hidden');
50475051
(document.getElementById('remap-save') as HTMLButtonElement).textContent = 'Save & Apply';
5052+
// TMDB/TVDB toggle (#178) - some shows' season structure only exists on
5053+
// TVDB, so the remap target needs the same TMDB/TVDB choice the "Match"
5054+
// flow already has, not just TMDB search.
5055+
(document.getElementById('match-tab-bar') as HTMLElement).classList.remove('hidden');
50485056
modal.classList.remove('hidden');
50495057
modal.classList.add('flex');
5050-
searchInput.focus();
5058+
setMatchTab('tmdb');
50515059
};
50525060

50535061
(window as any).openMatchModal = (seriesName: string) => {

0 commit comments

Comments
 (0)