feat (ui): toevoegen van banner en series info
This commit is contained in:
@@ -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", {}),
|
|
||||||
}
|
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
searchBtn: document.getElementById("searchBtn"),
|
searchBtn: document.getElementById("searchBtn"),
|
||||||
searchResults: document.getElementById("searchResults"),
|
searchResults: document.getElementById("searchResults"),
|
||||||
seriesInfo: document.getElementById("seriesInfo"),
|
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"),
|
refreshEpisodesBtn: document.getElementById("refreshEpisodesBtn"),
|
||||||
episodesList: document.getElementById("episodesList"),
|
episodesList: document.getElementById("episodesList"),
|
||||||
episodeMeta: document.getElementById("episodeMeta"),
|
episodeMeta: document.getElementById("episodeMeta"),
|
||||||
@@ -104,6 +111,56 @@
|
|||||||
if (btn) btn.disabled = busy;
|
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() {
|
function updateMeta() {
|
||||||
const epCount = state.selectedEpisodes.length;
|
const epCount = state.selectedEpisodes.length;
|
||||||
const fileCount = state.selectedFiles.length;
|
const fileCount = state.selectedFiles.length;
|
||||||
@@ -206,6 +263,7 @@
|
|||||||
right.appendChild(makeBtn("Select", async () => {
|
right.appendChild(makeBtn("Select", async () => {
|
||||||
state.selectedSeries = item;
|
state.selectedSeries = item;
|
||||||
el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`;
|
el.seriesInfo.textContent = `Selected: ${item.display_name || item.name}`;
|
||||||
|
renderSelectedSeriesDetails();
|
||||||
await loadEpisodes();
|
await loadEpisodes();
|
||||||
}));
|
}));
|
||||||
li.appendChild(left);
|
li.appendChild(left);
|
||||||
@@ -521,6 +579,7 @@
|
|||||||
async function init() {
|
async function init() {
|
||||||
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
|
el.sessionMeta.textContent = `session_id: ${state.sessionId}`;
|
||||||
el.modalRecursiveInput.checked = true;
|
el.modalRecursiveInput.checked = true;
|
||||||
|
renderSelectedSeriesDetails();
|
||||||
bindEvents();
|
bindEvents();
|
||||||
await loadSelectedEpisodes();
|
await loadSelectedEpisodes();
|
||||||
await loadSelectedFiles();
|
await loadSelectedFiles();
|
||||||
|
|||||||
@@ -293,6 +293,61 @@ button.secondary {
|
|||||||
margin-bottom: 8px;
|
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 {
|
#outputBox {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: #0b1220;
|
background: #0b1220;
|
||||||
|
|||||||
@@ -21,6 +21,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="seriesInfo" class="muted"></div>
|
<div id="seriesInfo" class="muted"></div>
|
||||||
<ul id="searchResults" class="list"></ul>
|
<ul id="searchResults" class="list"></ul>
|
||||||
|
<div id="seriesDetails" class="series-details hidden">
|
||||||
|
<div class="series-media">
|
||||||
|
<img id="seriesPoster" alt="Series poster" />
|
||||||
|
</div>
|
||||||
|
<div class="series-meta">
|
||||||
|
<div><span>First Aired:</span> <span id="seriesFirstAired">-</span></div>
|
||||||
|
<div><span>Network:</span> <span id="seriesNetwork">-</span></div>
|
||||||
|
<div><span>Status:</span> <span id="seriesStatus">-</span></div>
|
||||||
|
</div>
|
||||||
|
<p id="seriesOverview" class="series-overview"></p>
|
||||||
|
<a id="seriesTvdbLink" class="series-link" href="#" target="_blank" rel="noopener noreferrer">Open TVDB page</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" id="panelEpisodes">
|
<section class="panel" id="panelEpisodes">
|
||||||
|
|||||||
Reference in New Issue
Block a user