- tmdb.py : recherche TMDB par title+subtitle, matching fuzzy, cache SQLite 30 jours (table tmdb_cache dans arte_dl.db) - arte_api.py : enrichissement concurrent (5 workers) après résolution des IDs ; ajoute tmdb_id, tmdb_poster, tmdb_backdrop au concert - app.js : backdrop TMDB utilisé comme thumbnail de carte quand dispo ; subtitle affiché sous le titre de carte ; poster dans la modal ; lien direct vers la fiche TMDB - docker-compose.yml : passage de TMDB_API_KEY au container Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+12
@@ -6,6 +6,7 @@ import urllib.request
|
||||
import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import quote_plus
|
||||
import tmdb as _tmdb
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -109,6 +110,17 @@ def _fetch_all_sync() -> list[dict]:
|
||||
concerts = _resolve_ids(all_ids)
|
||||
for c in concerts:
|
||||
c["categories"] = id_cats.get(c["id"], [])
|
||||
|
||||
# TMDB enrichment (concurrent, results cached in SQLite)
|
||||
def _enrich(c: dict) -> dict:
|
||||
t = _tmdb.lookup(c["id"], c.get("title", ""), c.get("subtitle", ""))
|
||||
if t:
|
||||
c.update(t)
|
||||
return c
|
||||
|
||||
with ThreadPoolExecutor(max_workers=5) as pool:
|
||||
concerts = list(pool.map(_enrich, concerts))
|
||||
|
||||
concerts.sort(key=lambda c: c.get("expiry") or "", reverse=True)
|
||||
return concerts
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ services:
|
||||
- /mnt/user/appdata/arte-dl:/app/data
|
||||
environment:
|
||||
- TZ=Europe/Paris
|
||||
- TMDB_API_KEY=${TMDB_API_KEY}
|
||||
|
||||
+25
-3
@@ -102,12 +102,14 @@ function renderConcerts(data) {
|
||||
statusText.textContent = `${total} concert${total > 1 ? 's' : ''} · page ${state.page} / ${pages}`;
|
||||
|
||||
grid.innerHTML = concerts.map(c => {
|
||||
const thumb = c.thumbnail
|
||||
? `<img class="card-thumb" src="${c.thumbnail}" alt="" loading="lazy" />`
|
||||
const imgSrc = c.tmdb_backdrop || c.thumbnail;
|
||||
const thumb = imgSrc
|
||||
? `<img class="card-thumb" src="${imgSrc}" alt="" loading="lazy" />`
|
||||
: `<div class="card-thumb" style="background:#1a1a1a"></div>`;
|
||||
const dur = c.duration ? `<span class="card-duration">${fmtDuration(c.duration)}</span>` : '';
|
||||
const dl = state.downloadedUrls.has(c.url) ? `<span class="card-downloaded">✓ Téléchargé</span>` : '';
|
||||
const date = fmtDate(c.upload_date);
|
||||
const sub = c.subtitle ? `<div class="card-subtitle">${c.subtitle}</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="card" data-id="${c.id}" tabindex="0" role="button" aria-label="${c.title}">
|
||||
@@ -116,6 +118,7 @@ function renderConcerts(data) {
|
||||
</div>
|
||||
<div class="card-info">
|
||||
<div class="card-title">${c.title}</div>
|
||||
${sub}
|
||||
${date ? `<div class="card-date">${date}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -175,9 +178,20 @@ pagination.addEventListener('click', e => {
|
||||
function openModal(concert) {
|
||||
state.current = concert;
|
||||
|
||||
$('modal-thumb').src = concert.thumbnail || '';
|
||||
const modalThumbEl = $('modal-thumb');
|
||||
modalThumbEl.src = concert.tmdb_backdrop || concert.thumbnail || '';
|
||||
|
||||
const posterEl = $('modal-poster');
|
||||
if (concert.tmdb_poster) {
|
||||
posterEl.src = concert.tmdb_poster;
|
||||
posterEl.hidden = false;
|
||||
} else {
|
||||
posterEl.hidden = true;
|
||||
}
|
||||
|
||||
$('modal-title').textContent = concert.title;
|
||||
$('modal-meta').textContent = [
|
||||
concert.subtitle || '',
|
||||
concert.duration ? fmtDuration(concert.duration) : '',
|
||||
concert.upload_date ? fmtDate(concert.upload_date) : '',
|
||||
].filter(Boolean).join(' · ');
|
||||
@@ -185,6 +199,14 @@ function openModal(concert) {
|
||||
$('modal-dur-badge').textContent = concert.duration ? fmtDuration(concert.duration) : '';
|
||||
$('btn-watch').href = concert.url || '#';
|
||||
|
||||
const btnTmdb = $('btn-tmdb');
|
||||
if (concert.tmdb_id) {
|
||||
btnTmdb.href = `https://www.themoviedb.org/movie/${concert.tmdb_id}`;
|
||||
btnTmdb.hidden = false;
|
||||
} else {
|
||||
btnTmdb.hidden = true;
|
||||
}
|
||||
|
||||
const btnDl = $('btn-download');
|
||||
const alreadyDone = state.downloadedUrls.has(concert.url);
|
||||
btnDl.textContent = alreadyDone ? '✓ Déjà téléchargé' : 'Télécharger';
|
||||
|
||||
+31
-2
@@ -337,6 +337,16 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 3px;
|
||||
font-size: 11.5px;
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
@@ -483,6 +493,26 @@ body {
|
||||
background: linear-gradient(to bottom, transparent 50%, var(--surface) 100%);
|
||||
}
|
||||
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-head-text { flex: 1; min-width: 0; }
|
||||
|
||||
.modal-poster {
|
||||
width: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
object-fit: cover;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.modal-poster[hidden] { display: none; }
|
||||
|
||||
.modal-duration-badge {
|
||||
position: absolute;
|
||||
bottom: 12px; right: 12px;
|
||||
@@ -505,14 +535,13 @@ body {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.modal-meta {
|
||||
font-size: 12.5px;
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.modal-desc {
|
||||
|
||||
+14
-2
@@ -86,8 +86,13 @@
|
||||
<span class="modal-duration-badge" id="modal-dur-badge"></span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<h2 class="modal-title" id="modal-title"></h2>
|
||||
<p class="modal-meta" id="modal-meta"></p>
|
||||
<div class="modal-head">
|
||||
<div class="modal-head-text">
|
||||
<h2 class="modal-title" id="modal-title"></h2>
|
||||
<p class="modal-meta" id="modal-meta"></p>
|
||||
</div>
|
||||
<img class="modal-poster" id="modal-poster" src="" alt="" hidden />
|
||||
</div>
|
||||
<p class="modal-desc" id="modal-desc"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-download" id="btn-download">
|
||||
@@ -104,6 +109,13 @@
|
||||
</svg>
|
||||
Voir sur Arte
|
||||
</a>
|
||||
<a class="btn-watch" id="btn-tmdb" href="#" target="_blank" rel="noopener" hidden>
|
||||
<svg viewBox="0 0 20 20" fill="none" width="14" height="14">
|
||||
<circle cx="10" cy="10" r="7" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M7 10h6M10 7v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
TMDB
|
||||
</a>
|
||||
</div>
|
||||
<div class="dl-progress-wrap" id="dl-progress-wrap" hidden>
|
||||
<div class="dl-progress-bar">
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import difflib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_DAYS = 30
|
||||
_THRESHOLD = 0.45
|
||||
_DB = "arte_dl.db"
|
||||
_IMG_BASE = "https://image.tmdb.org/t/p"
|
||||
_SEARCH_URL = "https://api.themoviedb.org/3/search/movie"
|
||||
|
||||
_key: str = ""
|
||||
|
||||
|
||||
def _get_key() -> str:
|
||||
global _key
|
||||
if not _key:
|
||||
_key = os.environ.get("TMDB_API_KEY", "")
|
||||
return _key
|
||||
|
||||
|
||||
def _init_db():
|
||||
with sqlite3.connect(_DB) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tmdb_cache (
|
||||
arte_id TEXT PRIMARY KEY,
|
||||
tmdb_id INTEGER,
|
||||
poster TEXT,
|
||||
backdrop TEXT,
|
||||
cached_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def _search(query: str) -> list[dict]:
|
||||
url = f"{_SEARCH_URL}?api_key={_get_key()}&query={urllib.parse.quote_plus(query)}&language=fr"
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url), timeout=8) as r:
|
||||
return json.loads(r.read()).get("results", [])
|
||||
except Exception as e:
|
||||
logger.debug("TMDB search failed for %r: %s", query, e)
|
||||
return []
|
||||
|
||||
|
||||
def _best_match(results: list[dict], title: str, subtitle: str) -> dict | None:
|
||||
q = f"{title} {subtitle}".strip().lower().replace("-", " ").replace(":", "")
|
||||
best, best_score = None, 0.0
|
||||
for r in results[:10]:
|
||||
rt = (r.get("title") or "").lower().replace("-", " ").replace(":", "")
|
||||
score = difflib.SequenceMatcher(None, q, rt).ratio()
|
||||
# bonus when both artist name and subtitle start appear in the TMDB title
|
||||
if title.lower() in rt and (not subtitle or subtitle.lower()[:6] in rt):
|
||||
score = min(score + 0.2, 1.0)
|
||||
if score > best_score:
|
||||
best_score, best = score, r
|
||||
return best if best and best_score >= _THRESHOLD else None
|
||||
|
||||
|
||||
def lookup(arte_id: str, title: str, subtitle: str) -> dict | None:
|
||||
if not _get_key():
|
||||
return None
|
||||
|
||||
_init_db()
|
||||
|
||||
# Return cached result if fresh enough
|
||||
cutoff = (datetime.now() - timedelta(days=_CACHE_DAYS)).isoformat()
|
||||
with sqlite3.connect(_DB) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT tmdb_id, poster, backdrop FROM tmdb_cache WHERE arte_id=? AND cached_at>?",
|
||||
(arte_id, cutoff),
|
||||
).fetchone()
|
||||
|
||||
if row is not None:
|
||||
tmdb_id, poster, backdrop = row
|
||||
if tmdb_id is None:
|
||||
return None # cached "no match"
|
||||
return _build(tmdb_id, poster, backdrop)
|
||||
|
||||
# Query TMDB
|
||||
query = f"{title} {subtitle}".strip()
|
||||
results = _search(query) or (_search(title) if subtitle else [])
|
||||
match = _best_match(results, title, subtitle)
|
||||
|
||||
tmdb_id = match["id"] if match else None
|
||||
poster = match.get("poster_path") if match else None
|
||||
backdrop = match.get("backdrop_path") if match else None
|
||||
|
||||
with sqlite3.connect(_DB) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO tmdb_cache VALUES (?,?,?,?,?)",
|
||||
(arte_id, tmdb_id, poster, backdrop, datetime.now().isoformat()),
|
||||
)
|
||||
|
||||
return _build(tmdb_id, poster, backdrop) if tmdb_id else None
|
||||
|
||||
|
||||
def _build(tmdb_id: int, poster: str | None, backdrop: str | None) -> dict:
|
||||
return {
|
||||
"tmdb_id": tmdb_id,
|
||||
"tmdb_poster": f"{_IMG_BASE}/w500{poster}" if poster else None,
|
||||
"tmdb_backdrop": f"{_IMG_BASE}/w1280{backdrop}" if backdrop else None,
|
||||
}
|
||||
|
||||
|
||||
def poster_url(path: str | None, size: str = "w500") -> str | None:
|
||||
return f"{_IMG_BASE}/{size}{path}" if path else None
|
||||
Reference in New Issue
Block a user