diff --git a/app/services/01_tvdb_auth_service.py b/app/services/01_tvdb_auth_service.py deleted file mode 100644 index 5142711..0000000 --- a/app/services/01_tvdb_auth_service.py +++ /dev/null @@ -1,142 +0,0 @@ -import json -from pathlib import Path -import httpx - -from app.config import ( - TVDB_API_KEY, - TVDB_PIN, - TVDB_BASE_URL, - TVDB_AUTH_STATE_FILE, - TVDB_TOKEN_RENEW_MARGIN_SECONDS, - TVDB_REQUEST_TIMEOUT_SECONDS, -) -from app.utils.jwt_utils import decode_jwt_payload, unix_to_iso_utc, now_utc_ts - - -class TvdbAuthService: - def __init__(self, auth_file: Path = TVDB_AUTH_STATE_FILE): - self.auth_file = auth_file - - def load_state(self) -> dict: - if not self.auth_file.exists(): - return self.empty_state() - try: - return json.loads(self.auth_file.read_text(encoding="utf-8")) - except Exception: - return self.empty_state() - - def save_state(self, state: dict) -> None: - self.auth_file.write_text( - json.dumps(state, indent=2, ensure_ascii=False), - encoding="utf-8", - ) - - @staticmethod - def empty_state() -> dict: - return { - "token": None, - "token_type": "Bearer", - "issued_at": None, - "expires_at": None, - "expires_at_unix": None, - "renew_after": None, - "renew_after_unix": None, - "last_login_attempt_at": None, - "last_login_success_at": None, - "last_login_status": None, - "last_login_error": None, - "jwt_payload": {}, - } - - def login_and_store_token(self) -> dict: - if not TVDB_API_KEY: - raise RuntimeError("TVDB_API_KEY is not configured") - - state = self.load_state() - state["last_login_attempt_at"] = unix_to_iso_utc(now_utc_ts()) - state["last_login_status"] = "pending" - state["last_login_error"] = None - self.save_state(state) - - payload = {"apikey": TVDB_API_KEY} - if TVDB_PIN: - payload["pin"] = TVDB_PIN - - with httpx.Client(timeout=TVDB_REQUEST_TIMEOUT_SECONDS) as client: - response = client.post(f"{TVDB_BASE_URL}/login", json=payload) - response.raise_for_status() - data = response.json() - - token = data["data"]["token"] - claims = decode_jwt_payload(token) - - exp = claims.get("exp") - iat = claims.get("iat") - - if not exp: - raise RuntimeError("JWT exp claim is missing") - - renew_after_unix = int(exp) - TVDB_TOKEN_RENEW_MARGIN_SECONDS - - new_state = { - "token": token, - "token_type": "Bearer", - "issued_at": unix_to_iso_utc(iat), - "expires_at": unix_to_iso_utc(exp), - "expires_at_unix": int(exp), - "renew_after": unix_to_iso_utc(renew_after_unix), - "renew_after_unix": renew_after_unix, - "last_login_attempt_at": state["last_login_attempt_at"], - "last_login_success_at": unix_to_iso_utc(now_utc_ts()), - "last_login_status": "ok", - "last_login_error": None, - "jwt_payload": { - "exp": claims.get("exp"), - "iat": claims.get("iat"), - }, - } - self.save_state(new_state) - return new_state - - def get_valid_token(self) -> str: - state = self.load_state() - now_ts = now_utc_ts() - - token = state.get("token") - exp = state.get("expires_at_unix") - renew_after = state.get("renew_after_unix") - - if not token or not exp or not renew_after: - return self.login_and_store_token()["token"] - - if now_ts >= int(exp): - return self.login_and_store_token()["token"] - - if now_ts >= int(renew_after): - return self.login_and_store_token()["token"] - - return token - - def auth_status(self) -> dict: - state = self.load_state() - now_ts = now_utc_ts() - - exp = state.get("expires_at_unix") - renew_after = state.get("renew_after_unix") - - return { - "configured": bool(TVDB_API_KEY), - "has_token": bool(state.get("token")), - "issued_at": state.get("issued_at"), - "expires_at": state.get("expires_at"), - "expires_at_unix": exp, - "renew_after": state.get("renew_after"), - "renew_after_unix": renew_after, - "is_expired": bool(exp and now_ts >= int(exp)), - "is_renewal_due": bool(renew_after and now_ts >= int(renew_after)), - "last_login_attempt_at": state.get("last_login_attempt_at"), - "last_login_success_at": state.get("last_login_success_at"), - "last_login_status": state.get("last_login_status"), - "last_login_error": state.get("last_login_error"), - "jwt_payload": state.get("jwt_payload", {}), - } diff --git a/app/static/app.js b/app/static/app.js index 0523bd1..863be99 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -22,6 +22,13 @@ searchBtn: document.getElementById("searchBtn"), searchResults: document.getElementById("searchResults"), seriesInfo: document.getElementById("seriesInfo"), + seriesDetails: document.getElementById("seriesDetails"), + seriesPoster: document.getElementById("seriesPoster"), + seriesFirstAired: document.getElementById("seriesFirstAired"), + seriesNetwork: document.getElementById("seriesNetwork"), + seriesStatus: document.getElementById("seriesStatus"), + seriesOverview: document.getElementById("seriesOverview"), + seriesTvdbLink: document.getElementById("seriesTvdbLink"), refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"), episodesList: document.getElementById("episodesList"), episodeMeta: document.getElementById("episodeMeta"), @@ -104,6 +111,56 @@ if (btn) btn.disabled = busy; } + function fallbackText(value) { + const text = (value || "").toString().trim(); + return text || "-"; + } + + function buildTvdbUrl(item) { + const raw = item.raw || {}; + const slug = (raw.slug || "").toString().trim(); + if (slug) return `https://www.thetvdb.com/series/${encodeURIComponent(slug)}`; + + const tvdbId = (raw.tvdb_id || item.id || "").toString().trim(); + if (tvdbId) return `https://www.thetvdb.com/series/${encodeURIComponent(tvdbId)}`; + return ""; + } + + function renderSelectedSeriesDetails() { + const item = state.selectedSeries; + if (!item) { + el.seriesDetails.classList.add("hidden"); + return; + } + const raw = item.raw || {}; + const imageUrl = (raw.image_url || "").toString().trim(); + const overview = (raw.overview || "").toString().trim(); + const tvdbUrl = buildTvdbUrl(item); + + if (imageUrl) { + el.seriesPoster.src = imageUrl; + el.seriesPoster.classList.remove("hidden"); + } else { + el.seriesPoster.removeAttribute("src"); + el.seriesPoster.classList.add("hidden"); + } + + el.seriesFirstAired.textContent = fallbackText(raw.first_air_time); + el.seriesNetwork.textContent = fallbackText(raw.network); + el.seriesStatus.textContent = fallbackText(raw.status); + el.seriesOverview.textContent = overview || "No overview available."; + + if (tvdbUrl) { + el.seriesTvdbLink.href = tvdbUrl; + el.seriesTvdbLink.classList.remove("hidden"); + } else { + el.seriesTvdbLink.removeAttribute("href"); + el.seriesTvdbLink.classList.add("hidden"); + } + + el.seriesDetails.classList.remove("hidden"); + } + function updateMeta() { const epCount = state.selectedEpisodes.length; const fileCount = state.selectedFiles.length; @@ -206,6 +263,7 @@ right.appendChild(makeBtn("Select", async () => { state.selectedSeries = item; el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`; + renderSelectedSeriesDetails(); await loadEpisodes(); })); li.appendChild(left); @@ -521,6 +579,7 @@ async function init() { el.sessionMeta.textContent = `session_id: ${state.sessionId}`; el.modalRecursiveInput.checked = true; + renderSelectedSeriesDetails(); bindEvents(); await loadSelectedEpisodes(); await loadSelectedFiles(); diff --git a/app/static/styles.css b/app/static/styles.css index 94d45f9..510490b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -293,6 +293,61 @@ button.secondary { margin-bottom: 8px; } +.hidden { + display: none !important; +} + +#panelSearch #searchResults { + margin-bottom: 10px; +} + +.series-details { + border-top: 1px solid #e4eaf2; + padding-top: 10px; +} + +.series-media { + margin-bottom: 8px; +} + +.series-media img { + width: 96px; + height: 144px; + object-fit: cover; + border-radius: 6px; + border: 1px solid #d7dee9; + background: #f8fafc; +} + +.series-meta { + font-size: 12px; + color: #334155; + display: grid; + gap: 4px; + margin-bottom: 8px; +} + +.series-meta span:first-child { + color: #64748b; +} + +.series-overview { + margin: 0 0 8px; + font-size: 12px; + line-height: 1.35; + color: #1e293b; +} + +.series-link { + font-size: 12px; + color: #64748b; + text-decoration: none; +} + +.series-link:hover { + text-decoration: underline; +} + #outputBox { margin: 0; background: #0b1220; diff --git a/app/templates/index.html b/app/templates/index.html index 999be2c..fd99bbf 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -21,6 +21,18 @@
+