22import logging
33import re
44from fastapi import APIRouter , Depends , Query , HTTPException , BackgroundTasks
5- from pydantic import BaseModel
5+ from pydantic import BaseModel , model_validator
66from sqlalchemy .ext .asyncio import AsyncSession , async_sessionmaker
77from sqlalchemy import select , update , delete , func , cast
88from sqlalchemy .orm import selectinload
@@ -5928,9 +5928,18 @@ async def _stamp_matched_show_warnings(db: AsyncSession, user_id: int, warnings:
59285928class 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" )
59365945async 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 )
0 commit comments