feat: intégration TMDB — poster, backdrop, lien fiche
Docker / docker (push) Successful in 1m21s

- 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:
dev
2026-04-26 13:15:27 +02:00
parent 16736e2e7a
commit 9a5e356238
6 changed files with 195 additions and 7 deletions
+12
View File
@@ -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
+1
View File
@@ -10,3 +10,4 @@ services:
- /mnt/user/appdata/arte-dl:/app/data
environment:
- TZ=Europe/Paris
- TMDB_API_KEY=${TMDB_API_KEY}
+25 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+112
View File
@@ -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