From 39b4b10868fb15e60ddd390fce1e3b825db6f12b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Jun 2026 10:48:51 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(tmdb):=20rework=20poster=20matching=20?= =?UTF-8?q?for=20film/s=C3=A9rie/anime=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matching adapté au type de média et résilient aux noms de release crades. - tmdb_match: boucle word-removal (raccourcit la requête mot par mot, garde la plus longue au-dessus du seuil), année en paramètre TMDB typé, fusion bilingue fr/en des titres par id, flag transitoire vs no-match pour ne pas brûler match_attempts sur un 5xx passager - recherche TOUJOURS movie ET tv, le scoring tranche (corrige les séries qui matchaient un film homonyme : "Breaking Bad" -> film "Breaking Bad Wolf") - preferTv structurel: >=2 vidéos ou sous-dossiers saison/numérotés -> série, plus fiable que le nom de fichier; bonus de type TV renforcé dans ce cas - regex épisode élargie aux numéros 3-4 chiffres (s\d{1,2}e?\d{0,4}) - worker délègue à tmdb_match; sleep uniquement sur appel réseau réel - démo: lance le vrai worker au démarrage (suppression du seed en dur), worker ajouté au cron Docker; médias de démo enrichis + cas limites - durcissement: media dir passé via variable exportée (pas d'interpolation -c) - docs/TMDB_POSTERS.md; tests tmdb_match + extraction (épisodes 3-4 chiffres) Lien démo README mis à jour vers le nouveau serveur. --- .gitignore | 3 + Dockerfile | 2 +- Dockerfile.nvidia | 2 +- README.md | 4 +- docker/demo-bootstrap.php | 58 ++++++++++ docker/demo-data.sh | 17 ++- docker/entrypoint-nvidia.sh | 15 ++- docker/entrypoint.sh | 23 +++- docker/seed-tmdb.php | 198 -------------------------------- docs/TMDB_POSTERS.md | 176 ++++++++++++++++++++++++++++ functions.php | 180 ++++++++++++++++++++--------- tests/StreamingHandlersTest.php | 35 ++++-- tests/TmdbMatchTest.php | 125 ++++++++++++++++++++ tools/tmdb-worker.php | 86 ++++++-------- 14 files changed, 593 insertions(+), 331 deletions(-) create mode 100644 docker/demo-bootstrap.php delete mode 100644 docker/seed-tmdb.php create mode 100644 docs/TMDB_POSTERS.md create mode 100644 tests/TmdbMatchTest.php diff --git a/.gitignore b/.gitignore index 0f279b4..05a5c1c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ test_router.php *.mov *.avi *.mkv + +# Benchmark / workspace d'analyse (corpus privé de torrents + artefacts) — local only +bench/ diff --git a/Dockerfile b/Dockerfile index a0795ee..c63efac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf COPY docker/entrypoint.sh /entrypoint.sh COPY docker/demo-data.sh /docker/demo-data.sh -COPY docker/seed-tmdb.php /docker/seed-tmdb.php +COPY docker/demo-bootstrap.php /docker/demo-bootstrap.php RUN chmod +x /docker/demo-data.sh COPY . /app diff --git a/Dockerfile.nvidia b/Dockerfile.nvidia index cee46ec..60ed780 100644 --- a/Dockerfile.nvidia +++ b/Dockerfile.nvidia @@ -19,7 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/entrypoint-nvidia.sh /entrypoint.sh COPY docker/demo-data.sh /docker/demo-data.sh -COPY docker/seed-tmdb.php /docker/seed-tmdb.php +COPY docker/demo-bootstrap.php /docker/demo-bootstrap.php RUN chmod +x /entrypoint.sh /docker/demo-data.sh COPY . /app diff --git a/README.md b/README.md index a2a015f..c2d3332 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ So I built ShareBox -- a single PHP app with zero external dependencies. No fram ## Live Demo -**[Try the demo](https://dn40904.seedhost.eu:8282/dl/browse?p=Films&view=grid)** -- no install needed. Netflix-style grid with TMDB posters, series, movies, and anime. +**[Try the demo](http://199.231.187.166:8282/dl/browse?p=Films&view=grid)** -- no install needed. Netflix-style grid with TMDB posters, series, movies, and anime. -[Admin panel](https://dn40904.seedhost.eu:8282/share/) -- `admin` / `demo2026` +[Admin panel](http://199.231.187.166:8282/share/) -- `admin` / `demo2026` ## How it compares diff --git a/docker/demo-bootstrap.php b/docker/demo-bootstrap.php new file mode 100644 index 0000000..3d64403 --- /dev/null +++ b/docker/demo-bootstrap.php @@ -0,0 +1,58 @@ + + */ + +require '/app/db.php'; +require_once '/app/functions.php'; + +$mediaDir = rtrim(($argv[1] ?? '') ?: '/media', '/'); // défaut robuste si l'argument est vide + +if (!defined('TMDB_API_KEY') || !TMDB_API_KEY) { + fwrite(STDERR, "demo-bootstrap: TMDB_API_KEY absent, rien à faire.\n"); + exit(0); +} + +$db = get_db(); + +// Dossiers de la démo affichés en mode "films" (fichiers vidéo à plat). +// Tout le reste (Series, Anime, saisons) est en "series" par défaut et découvert +// récursivement par le worker via discover_folders(). +$movieDirs = ['Films']; +$videoExts = ['mp4','mkv','avi','m4v','mov','wmv','flv','webm','ts','m2ts','mpg','mpeg']; + +// 1. Config admin + enqueue des fichiers vidéo (= ce que fait POST ?folder_type_set +// puis le browse ?posters). Sans folder_type='movies', les fichiers vidéo ne sont +// jamais enqueués (cf. handlers/tmdb.php, branche $isMovies). +$setType = $db->prepare("INSERT INTO folder_posters (path, folder_type) VALUES (:p, 'movies') + ON CONFLICT(path) DO UPDATE SET folder_type = 'movies'"); +$enqueue = $db->prepare("INSERT OR IGNORE INTO folder_posters (path) VALUES (:p)"); + +foreach ($movieDirs as $d) { + $dirPath = realpath("$mediaDir/$d"); + if (!$dirPath || !is_dir($dirPath)) continue; + $setType->execute([':p' => $dirPath]); + foreach (scandir($dirPath) as $f) { + if ($f[0] === '.' || is_dir("$dirPath/$f")) continue; + if (in_array(strtolower(pathinfo($f, PATHINFO_EXTENSION)), $videoExts, true)) { + $enqueue->execute([':p' => "$dirPath/$f"]); + } + } +} + +echo "demo-bootstrap: dossiers films marqués + fichiers enqueués, lancement du worker…\n"; + +// 2. Le vrai worker : discover (Series/Anime/saisons) + match (tmdb_match) + +// auto-verify + propagation parent→enfant + posters de saison. +passthru(escapeshellarg(find_php_cli()) . ' /app/tools/tmdb-worker.php'); diff --git a/docker/demo-data.sh b/docker/demo-data.sh index c7086c8..4336487 100755 --- a/docker/demo-data.sh +++ b/docker/demo-data.sh @@ -106,8 +106,15 @@ make_episodes "$MEDIA_DIR/Series/Stranger Things/Season 1" "Stranger.Things.S01E make_episodes "$MEDIA_DIR/Series/Stranger Things/Season 2" "Stranger.Things.S02E" 1 3 make_episodes "$MEDIA_DIR/Series/The Mandalorian/Season 1" "The.Mandalorian.S01E" 1 3 make_episodes "$MEDIA_DIR/Series/The Mandalorian/Season 2" "The.Mandalorian.S02E" 1 3 +make_episodes "$MEDIA_DIR/Series/The Witcher/Season 1" "The.Witcher.S01E" 1 3 +# Série AVEC année dans le nom de release (réel) → teste l'extraction année + preferTv +make_episodes "$MEDIA_DIR/Series/The Flash/Season 5" "The.Flash.2014.S05E" 1 4 # ── Films ── +# Le bloc bas teste volontairement les cas tordus du matching : homonyme à +# départager par l'année (Dune 1984 vs 2021), titre qui EST un nombre (1917), +# tag de site + crochets, titre long à raccourcir (LOTR), titre original japonais +# (Your Name / Kimi no Na wa), et séquelle numérotée (John Wick Chapter 4). mkdir -p "$MEDIA_DIR/Films" for f in \ "Inception.2010.1080p.BluRay.x264.mkv" \ @@ -121,7 +128,13 @@ for f in \ "The.Shawshank.Redemption.1994.1080p.mkv" \ "Spirited.Away.2001.JAPANESE.BluRay.mkv" \ "Blade.Runner.2049.2017.MULTI.2160p.HDR.mkv" \ - "Dune.2021.IMAX.1080p.WEB-DL.mkv" + "Dune.2021.IMAX.1080p.WEB-DL.mkv" \ + "Dune.1984.MULTI.1080p.BluRay.x264.mkv" \ + "1917.2019.MULTI.1080p.BluRay.mkv" \ + "[Torrent911.com].Avatar.2009.TRUEFRENCH.1080p.mkv" \ + "The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.EXTENDED.2160p.mkv" \ + "Your.Name.2016.JAPANESE.VOSTFR.1080p.mkv" \ + "John.Wick.Chapter.4.2023.MULTI.2160p.mkv" do cp "$CLIP" "$MEDIA_DIR/Films/$f" done @@ -131,6 +144,8 @@ make_episodes "$MEDIA_DIR/Anime/Attack on Titan/Season 1" "Attack.on.Titan.S01E" make_episodes "$MEDIA_DIR/Anime/Attack on Titan/Season 2" "Attack.on.Titan.S02E" 1 3 make_episodes "$MEDIA_DIR/Anime/Death Note/Season 1" "Death.Note.S01E" 1 4 make_episodes "$MEDIA_DIR/Anime/One Piece/Season 1" "One.Piece.S01E" 1 5 +make_episodes "$MEDIA_DIR/Anime/Demon Slayer Kimetsu no Yaiba/Season 1" "Demon.Slayer.Kimetsu.no.Yaiba.S01E" 1 4 +make_episodes "$MEDIA_DIR/Anime/Jujutsu Kaisen/Season 1" "Jujutsu.Kaisen.S01E" 1 3 # Clean up temp files rm -f "$CLIP" /tmp/sub_en.srt /tmp/sub_fr.srt diff --git a/docker/entrypoint-nvidia.sh b/docker/entrypoint-nvidia.sh index e708f47..d45eb3d 100644 --- a/docker/entrypoint-nvidia.sh +++ b/docker/entrypoint-nvidia.sh @@ -101,17 +101,18 @@ if [ "${SHAREBOX_AUTO_SHARE:-}" = "yes" ]; then fi # ── Demo data (optional) ──────────────────────────────────────────────────── +# Crée uniquement les médias d'exemple ; les affiches sont matchées par le vrai worker +# (lancé en tâche de fond après le démarrage) — même chemin de code qu'une install réelle. if [ "${SHAREBOX_DEMO_DATA:-}" = "true" ]; then /bin/bash /docker/demo-data.sh "$MEDIA_DIR" - if [ -n "${SHAREBOX_TMDB_API_KEY:-}" ]; then - php /docker/seed-tmdb.php "$MEDIA_DIR" "${SHAREBOX_TMDB_API_KEY}" || true - fi fi # ── Cron (bandwidth history + hourly DB backup) ────────────────────────────── { echo "* * * * * www-data php /app/cron/record_netspeed.php" echo "0 * * * * www-data php /app/cron/backup_db.php" + # Worker affiches TMDB : découvre les nouveaux dossiers et matche via TMDB. + [ -n "${SHAREBOX_TMDB_API_KEY:-}" ] && echo "*/10 * * * * www-data php /app/tools/tmdb-worker.php >> /data/tmdb-worker.log 2>&1" } > /etc/cron.d/sharebox chmod 644 /etc/cron.d/sharebox cron @@ -135,4 +136,12 @@ fi # ── Start services ─────────────────────────────────────────────────────────── php-fpm8.3 --daemonize + +# Bootstrap démo : peuple les affiches via le vrai worker, en tâche de fond (www-data). +if [ "${SHAREBOX_DEMO_DATA:-}" = "true" ] && [ -n "${SHAREBOX_TMDB_API_KEY:-}" ]; then + # $MEDIA_DIR via variable exportée (pas d'interpolation dans -c) → pas d'injection shell. + export _DEMO_MEDIA_DIR="$MEDIA_DIR" + su -s /bin/sh www-data -c 'php /docker/demo-bootstrap.php "$_DEMO_MEDIA_DIR" >> /data/tmdb-worker.log 2>&1' & +fi + exec nginx -g 'daemon off;' diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index b73ad77..99547a8 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -86,12 +86,11 @@ if [ "${SHAREBOX_AUTO_SHARE:-}" = "yes" ]; then fi # ── Demo data (optional) ──────────────────────────────────────────────────── +# Crée uniquement les médias d'exemple. Le matching des affiches est délégué au VRAI +# worker (lancé en tâche de fond après le démarrage des services) : la démo emprunte +# ainsi le même chemin de code qu'une install réelle, au lieu d'un seed en dur. if [ "${SHAREBOX_DEMO_DATA:-}" = "true" ]; then /bin/sh /docker/demo-data.sh "$MEDIA_DIR" - # Seed TMDB posters/overviews/ratings if API key is set - if [ -n "${SHAREBOX_TMDB_API_KEY:-}" ]; then - php /docker/seed-tmdb.php "$MEDIA_DIR" "${SHAREBOX_TMDB_API_KEY}" || true - fi fi # ── PHP limits (streaming + large uploads) ────────────────────────────────── @@ -107,6 +106,10 @@ PHPINI { echo "* * * * * php /app/cron/record_netspeed.php" echo "0 * * * * php /app/cron/backup_db.php" + # Worker affiches TMDB : découvre les nouveaux dossiers et matche via TMDB. + # Absent par défaut de l'image jusqu'ici → un install Docker n'avait jamais de + # cron poster (seul le ?posters au browse alimentait la grille). + [ -n "${SHAREBOX_TMDB_API_KEY:-}" ] && echo "*/10 * * * * php /app/tools/tmdb-worker.php >> /data/tmdb-worker.log 2>&1" } > /etc/crontabs/www-data crond -b -l 8 @@ -120,5 +123,17 @@ chown www-data:www-data /data/share.db /data/share.db-wal /data/share.db-shm /da # ── Start services ─────────────────────────────────────────────────────────── php-fpm -D + +# ── Bootstrap démo : peuple les affiches via le vrai worker ────────────────── +# En tâche de fond (www-data, propriétaire de la DB) pour ne pas bloquer le démarrage — +# les affiches apparaissent en ~1 min, exactement comme le premier passage de cron +# sur une install fraîche. Pas de seed en dur : même chemin de code que la prod. +if [ "${SHAREBOX_DEMO_DATA:-}" = "true" ] && [ -n "${SHAREBOX_TMDB_API_KEY:-}" ]; then + # $MEDIA_DIR passé via une variable exportée (pas d'interpolation dans la chaîne -c) → + # aucune injection shell possible même si l'env contient des guillemets/backticks. + export _DEMO_MEDIA_DIR="$MEDIA_DIR" + su -s /bin/sh www-data -c 'php /docker/demo-bootstrap.php "$_DEMO_MEDIA_DIR" >> /data/tmdb-worker.log 2>&1' & +fi + echo "ShareBox ready — admin: http://localhost/share" exec nginx -g 'daemon off;' diff --git a/docker/seed-tmdb.php b/docker/seed-tmdb.php deleted file mode 100644 index 5759fb7..0000000 --- a/docker/seed-tmdb.php +++ /dev/null @@ -1,198 +0,0 @@ -query("SELECT COUNT(*) FROM folder_posters WHERE poster_url IS NOT NULL AND poster_url != '' AND poster_url != '__none__'")->fetchColumn(); -if ($existing > 5) { - echo "seed-tmdb: already seeded ($existing entries), skipping.\n"; - exit(0); -} - -// ── Mapping: folder path => TMDB search query + type ── -$entries = [ - // Series - ['path' => "$mediaDir/Series/Breaking Bad", 'query' => 'Breaking Bad', 'type' => 'tv'], - ['path' => "$mediaDir/Series/Game of Thrones", 'query' => 'Game of Thrones', 'type' => 'tv'], - ['path' => "$mediaDir/Series/Stranger Things", 'query' => 'Stranger Things', 'type' => 'tv'], - ['path' => "$mediaDir/Series/The Mandalorian", 'query' => 'The Mandalorian', 'type' => 'tv'], - // Films - ['path' => "$mediaDir/Films/Inception.2010.1080p.BluRay.x264.mkv", 'query' => 'Inception', 'type' => 'movie', 'year' => 2010], - ['path' => "$mediaDir/Films/The.Dark.Knight.2008.MULTI.1080p.mkv", 'query' => 'The Dark Knight', 'type' => 'movie', 'year' => 2008], - ['path' => "$mediaDir/Films/Interstellar.2014.FRENCH.BDRip.mkv", 'query' => 'Interstellar', 'type' => 'movie', 'year' => 2014], - ['path' => "$mediaDir/Films/Pulp.Fiction.1994.REMASTERED.720p.mkv", 'query' => 'Pulp Fiction', 'type' => 'movie', 'year' => 1994], - ['path' => "$mediaDir/Films/The.Matrix.1999.UHD.2160p.x265.mkv", 'query' => 'The Matrix', 'type' => 'movie', 'year' => 1999], - ['path' => "$mediaDir/Films/Parasite.2019.KOREAN.VOSTFR.BluRay.mkv", 'query' => 'Parasite', 'type' => 'movie', 'year' => 2019], - ['path' => "$mediaDir/Films/Fight.Club.1999.MULTI.1080p.DTS.mkv", 'query' => 'Fight Club', 'type' => 'movie', 'year' => 1999], - ['path' => "$mediaDir/Films/Gladiator.2000.FRENCH.720p.x264.mkv", 'query' => 'Gladiator', 'type' => 'movie', 'year' => 2000], - ['path' => "$mediaDir/Films/The.Shawshank.Redemption.1994.1080p.mkv", 'query' => 'The Shawshank Redemption','type' => 'movie', 'year' => 1994], - ['path' => "$mediaDir/Films/Spirited.Away.2001.JAPANESE.BluRay.mkv", 'query' => 'Spirited Away', 'type' => 'movie', 'year' => 2001], - ['path' => "$mediaDir/Films/Blade.Runner.2049.2017.MULTI.2160p.HDR.mkv", 'query' => 'Blade Runner 2049', 'type' => 'movie', 'year' => 2017], - ['path' => "$mediaDir/Films/Dune.2021.IMAX.1080p.WEB-DL.mkv", 'query' => 'Dune', 'type' => 'movie', 'year' => 2021], - // Anime - ['path' => "$mediaDir/Anime/Attack on Titan", 'query' => 'Attack on Titan', 'type' => 'tv'], - ['path' => "$mediaDir/Anime/Death Note", 'query' => 'Death Note', 'type' => 'tv'], - ['path' => "$mediaDir/Anime/One Piece", 'query' => 'One Piece', 'type' => 'tv'], - // Category folders (no poster, just folder_type) - ['path' => "$mediaDir/Anime", 'folder_type' => 'series', 'skip_tmdb' => true], - ['path' => "$mediaDir/Series", 'folder_type' => 'series', 'skip_tmdb' => true], - ['path' => "$mediaDir/Films", 'folder_type' => 'movies', 'skip_tmdb' => true], -]; - -// Season folders: fetched in a second pass after series are seeded (needs tmdb_id) -$seasons = [ - ['path' => "$mediaDir/Series/Breaking Bad/Season 1", 'parent' => "$mediaDir/Series/Breaking Bad", 'season' => 1], - ['path' => "$mediaDir/Series/Breaking Bad/Season 2", 'parent' => "$mediaDir/Series/Breaking Bad", 'season' => 2], - ['path' => "$mediaDir/Series/Game of Thrones/Season 1", 'parent' => "$mediaDir/Series/Game of Thrones", 'season' => 1], - ['path' => "$mediaDir/Series/Game of Thrones/Season 2", 'parent' => "$mediaDir/Series/Game of Thrones", 'season' => 2], - ['path' => "$mediaDir/Series/Stranger Things/Season 1", 'parent' => "$mediaDir/Series/Stranger Things", 'season' => 1], - ['path' => "$mediaDir/Series/Stranger Things/Season 2", 'parent' => "$mediaDir/Series/Stranger Things", 'season' => 2], - ['path' => "$mediaDir/Series/The Mandalorian/Season 1", 'parent' => "$mediaDir/Series/The Mandalorian", 'season' => 1], - ['path' => "$mediaDir/Series/The Mandalorian/Season 2", 'parent' => "$mediaDir/Series/The Mandalorian", 'season' => 2], - ['path' => "$mediaDir/Anime/Attack on Titan/Season 1", 'parent' => "$mediaDir/Anime/Attack on Titan", 'season' => 1], - ['path' => "$mediaDir/Anime/Attack on Titan/Season 2", 'parent' => "$mediaDir/Anime/Attack on Titan", 'season' => 2], - ['path' => "$mediaDir/Anime/Death Note/Season 1", 'parent' => "$mediaDir/Anime/Death Note", 'season' => 1], - ['path' => "$mediaDir/Anime/One Piece/Season 1", 'parent' => "$mediaDir/Anime/One Piece", 'season' => 1], -]; - -$stmt = $db->prepare("INSERT INTO folder_posters (path, poster_url, tmdb_id, title, overview, tmdb_rating, tmdb_year, tmdb_type, folder_type, verified, match_attempts) - VALUES (:path, :poster_url, :tmdb_id, :title, :overview, :rating, :year, :tmdb_type, :folder_type, 1, 1) - ON CONFLICT(path) DO UPDATE SET - poster_url = COALESCE(:poster_url, folder_posters.poster_url), - tmdb_id = COALESCE(:tmdb_id, folder_posters.tmdb_id), - title = COALESCE(:title, folder_posters.title), - overview = COALESCE(:overview, folder_posters.overview), - tmdb_rating = COALESCE(:rating, folder_posters.tmdb_rating), - tmdb_year = COALESCE(:year, folder_posters.tmdb_year), - tmdb_type = COALESCE(:tmdb_type, folder_posters.tmdb_type), - folder_type = COALESCE(:folder_type, folder_posters.folder_type), - verified = 1, - match_attempts = 1"); - -$seeded = 0; -$failed = 0; - -foreach ($entries as $entry) { - $path = $entry['path']; - $folderType = $entry['folder_type'] ?? (($entry['type'] ?? '') === 'movie' ? 'movies' : 'series'); - - if (!empty($entry['skip_tmdb'])) { - $stmt->execute([ - ':path' => $path, - ':poster_url' => null, - ':tmdb_id' => null, - ':title' => null, - ':overview' => null, - ':rating' => null, - ':year' => null, - ':tmdb_type' => null, - ':folder_type'=> $folderType, - ]); - continue; - } - - $query = urlencode($entry['query']); - $tmdbType = $entry['type']; - $yearParam = isset($entry['year']) ? "&year={$entry['year']}" : ''; - $url = "https://api.themoviedb.org/3/search/$tmdbType?api_key=$apiKey&query=$query$yearParam&language=en-US&page=1"; - - $json = @file_get_contents($url); - if (!$json) { - echo " FAIL: {$entry['query']}\n"; - $failed++; - continue; - } - - $data = json_decode($json, true); - $results = $data['results'] ?? []; - if (empty($results)) { - echo " NO RESULT: {$entry['query']}\n"; - $failed++; - continue; - } - - $r = $results[0]; - $posterUrl = $r['poster_path'] ? "https://image.tmdb.org/t/p/w500{$r['poster_path']}" : null; - $title = $r['title'] ?? $r['name'] ?? $entry['query']; - $overview = $r['overview'] ?? ''; - $rating = $r['vote_average'] ?? null; - $tmdbId = $r['id'] ?? null; - $releaseDate = $r['release_date'] ?? $r['first_air_date'] ?? ''; - $year = $releaseDate ? substr($releaseDate, 0, 4) : ($entry['year'] ?? null); - - $stmt->execute([ - ':path' => $path, - ':poster_url' => $posterUrl, - ':tmdb_id' => $tmdbId, - ':title' => $title, - ':overview' => $overview, - ':rating' => $rating, - ':year' => $year, - ':tmdb_type' => $tmdbType, - ':folder_type'=> $folderType, - ]); - - echo " OK: $title ($year) - " . ($rating ?? '?') . "/10\n"; - $seeded++; - - // Rate limit: TMDB allows ~40 req/10s - usleep(300000); -} - -// ── Second pass: fetch season posters via /tv/{id}/season/{n} ── -$seasonSeeded = 0; -foreach ($seasons as $s) { - // Look up parent tmdb_id - $pStmt = $db->prepare("SELECT tmdb_id FROM folder_posters WHERE path = :p AND tmdb_id IS NOT NULL"); - $pStmt->execute([':p' => $s['parent']]); - $pRow = $pStmt->fetch(); - if (!$pRow) continue; - - $tmdbId = (int)$pRow['tmdb_id']; - $seasonNum = $s['season']; - $url = "https://api.themoviedb.org/3/tv/$tmdbId/season/$seasonNum?api_key=$apiKey&language=en-US"; - - $json = @file_get_contents($url); - if (!$json) { continue; } - - $data = json_decode($json, true); - $posterPath = $data['poster_path'] ?? null; - $posterUrl = $posterPath ? "https://image.tmdb.org/t/p/w500$posterPath" : null; - $overview = $data['overview'] ?? ''; - $name = $data['name'] ?? "Season $seasonNum"; - - $stmt->execute([ - ':path' => $s['path'], - ':poster_url' => $posterUrl, - ':tmdb_id' => $tmdbId, - ':title' => $name, - ':overview' => $overview, - ':rating' => null, - ':year' => null, - ':tmdb_type' => 'tv', - ':folder_type'=> 'series', - ]); - - if ($posterUrl) { - echo " SEASON OK: $name\n"; - $seasonSeeded++; - } - usleep(300000); -} - -echo "seed-tmdb: done. Seeded $seeded titles + $seasonSeeded seasons, failed $failed.\n"; diff --git a/docs/TMDB_POSTERS.md b/docs/TMDB_POSTERS.md new file mode 100644 index 0000000..75dde7c --- /dev/null +++ b/docs/TMDB_POSTERS.md @@ -0,0 +1,176 @@ +# TMDB Posters — flux complet + +Carte du système d'affiches : comment un nom de fichier/dossier devient une fiche TMDB +(affiche + note + synopsis) dans la grille. À lire avant d'investiguer, modifier ou +améliorer le matching — évite de tout redécouvrir à chaque fois. + +## Vue d'ensemble (qui appelle quoi) + +``` +Navigation grille (browser) + │ GET ?posters=1&path=… + ▼ +handlers/tmdb.php ── découvre les items, lit le cache, INSÈRE des lignes NULL, + │ NE FAIT AUCUN appel TMDB synchrone, puis auto-démarre le worker + │ exec(tmdb-worker.php &) + ▼ +tools/tmdb-worker.php ── le MOTEUR : discover → match (word-removal) → verify → + │ propagation parent→enfant → posters de saison → GC → refresh + │ pour chaque ligne NULL : + ▼ +functions.php : extract_title_year → tmdb_match → tmdb_search_candidates → + tmdb_score_candidate → tmdb_score_to_verified + │ appels HTTP : + ▼ +tmdb_fetch_cached → tmdb_fetch (cURL, retry/backoff) → api.themoviedb.org +``` + +Principe clé : **le web n'appelle jamais TMDB en synchrone** (latence + rate-limit). Il +ne fait qu'inscrire des lignes `poster_url IS NULL` dans `folder_posters` et lancer le +worker, qui fait tout le travail réseau en tâche de fond. Le front **poll** ensuite. + +## Points d'entrée + +| Déclencheur | Fichier | Rôle | +|---|---|---| +| `GET ?posters=1` | `handlers/tmdb.php:43` | Découverte/enqueue des dossiers + fichiers vidéo, lecture cache, auto-start worker | +| Cron / auto-start | `tools/tmdb-worker.php` | Le moteur de matching (toutes les phases) | +| `POST ?folder_type_set` | `handlers/tmdb.php:478` | Admin bascule un dossier `series`↔`movies` ; **reset les enfants** pour re-match | +| `GET ?tmdb_search=` | `handlers/tmdb.php` | Recherche manuelle (picker admin) | +| `POST ?tmdb_set` | `handlers/tmdb.php` | Choix humain → `verified=100` (jamais écrasé) | +| Skill `/tmdb-scan` | (IA) | Re-vérifie les entrées `verified` faible (40–60) | +| `docker/demo-bootstrap.php` | Docker démo | Marque les dossiers films + enqueue, puis lance le **vrai** worker (zéro mapping en dur) | + +## Modèle de données : table `folder_posters` + +| Colonne | Sens | +|---|---| +| `path` | Chemin absolu (PK). Peut être un **dossier** OU un **fichier vidéo** (dossiers type `movies`) | +| `poster_url` | URL affiche TMDB (w300/w500) ; `NULL` = à traiter ; `__none__` = masqué par l'user | +| `tmdb_id`, `title`, `overview`, `tmdb_year`, `tmdb_type`, `tmdb_rating` | Métadonnées TMDB | +| `verified` | **Score de confiance** (voir ci-dessous) | +| `match_attempts` | Compteur de tentatives ratées (borne `< 3`). **N'incrémente que sur un vrai « rien trouvé »** | +| `ia_checked` | `1` = traité (évite le spinner « IA pending ») | +| `folder_type` | `series` (défaut) ou `movies` — décide l'affichage ET l'enqueue des fichiers vidéo | +| `updated_at` | Refresh TTL (7 j) | + +### Niveaux `verified` + +| Valeur | Signification | +|---|---| +| `0` | Cherché, rien trouvé (sous le seuil) | +| `40` / `60` | Auto-match (`tmdb_score_to_verified` : score ≥ 35 / ≥ 55) | +| `70` | Auto-vérifié en masse (≥ 3 entrées même `tmdb_id` dans un dossier) ou propagation parent | +| `80` | Auto-match fort (score ≥ 80) | +| `100` | **Choix humain** — jamais écrasé par le worker ni le refresh | +| `-1` | L'user a demandé une re-vérification IA | +| `poster_url='__none__'` | Affiche masquée par l'user (def. si `verified=100`) | + +## Pipeline de matching (`functions.php`) + +Du nom brut au candidat retenu : + +1. **`extract_title_year($name)` — `functions.php:178`** + Nettoie le nom : retire crochets, normalise séparateurs, extrait l'année (gère les + ranges type `1997-2003` → première année), retire marqueurs saison + mots-bruit + (`integrale`, `collection`…), **coupe au 1ᵉʳ tag technique** (`1080p|x264|MULTI|…`). + Retour `['title' => …, 'year' => int|null]`. Guards : un code `S01`/`Films` nu → titre vide. + +2. **`tmdb_match($title, $year, $preferTv, $apiKey, $ctx, &$responded)` — `functions.php:619`** + **Boucle word-removal** : retire les mots **depuis la fin**, un par un (≤ 5 essais), + re-cherche, **garde la requête la plus LONGUE qui passe le seuil 55**. Score contre la + requête réellement utilisée (pas le titre brut). Si rien ≥ 35 **et** année fournie → + **2ᵉ passe sans filtre année** (le scoring garde le bonus année). Retourne + `['candidate','score']` ou `null`. `$responded` (out) = TMDB a-t-il répondu au moins une fois. + +3. **`tmdb_search_candidates($title, $year, $apiKey, $ctx, $limit, $preferTv, &$responded)` — `functions.php:561`** + Interroge **TOUJOURS les deux types** `movie` ET `tv` (pas seulement le type probable) × + langues **`fr` + `en-US`** : c'est le SCORING qui tranche. Se limiter à un type matchait un + film homonyme pour toute série (Breaking Bad → un film « Breaking Bad Wolf »). Année en VRAI + paramètre TMDB (`&year=` / `&first_air_date_year=`). **Fusion par `id`** : un même titre vu dans + 2 langues agrège ses variantes dans `titles[]` (clé pour l'anime : on cherche en anglais, TMDB + renvoie le titre fr/jp). Via `tmdb_fetch_cached` ; sleep 300 ms uniquement sur appel réseau réel. + +4. **`tmdb_score_candidate($title, $year, $candidate, $preferTv)` — `functions.php` (≈ 660)** + Score 0–100 : similarité `similar_text` ×0.65 sur **toutes les variantes de titre** (`titles[]` + fr/en/original), pénalité longueur anti « One »/« One Piece », bonus substring (≤8), + année exacte +15 / ±1 +10, **cohérence type : +18 pour la TV quand `preferTv`** (dossier à + saisons = série quasi-sûre) sinon film +10 / tv +3, popularité `vote_count` +2…+12. + +5. **`tmdb_score_to_verified($score)`** : `≥80→80`, `≥55→60`, `≥35→40`, sinon `0`. + +## Robustesse réseau + +- **`tmdb_fetch` — `functions.php:420`** : cURL (share handle DNS/SSL), `connect=3s`/`timeout=8s`, + **retry + backoff exponentiel** (500ms→1s→2s) sur 5xx/réseau, respecte `Retry-After` sur 429, + abandon immédiat sur 401/404/4xx. +- **`tmdb_fetch_cached` — `functions.php:508`** : cache SQLite (`tmdb_cache`), TTL 7 j, GC + probabiliste. **Ne cache QUE les succès** → un échec transitoire est réessayé. +- **Espacement** : `usleep(300000)` entre appels dans `tmdb_search_candidates`. + +### Transient vs no-match (important) + +Un **502 TMDB** (Bad Gateway) est un échec d'infra **transitoire**, pas un « pas de match ». +Le flag `$responded` permet au worker de **ne PAS consommer `match_attempts`** quand TMDB +était injoignable (`tmdb-worker.php`, branche `elseif ($responded)`). Sans ça, une rafale de +502 épuisait les 3 tentatives et laissait un dossier sans affiche **pour toujours**. +Désormais : transitoire → on n'incrémente pas → le prochain passage réessaie. + +## Phases du worker (`tools/tmdb-worker.php`) + +1. **Phase 0 — `discover_folders` (`:42`)** : crawle `BASE_PATH`, insère les **DOSSIERS** seulement + (pas les fichiers — les fichiers vidéo sont enqueués par `?posters` côté web, branche `movies`). +2. **Match** (`:151`) : `SELECT … WHERE poster_url IS NULL AND match_attempts < 3 LIMIT 200`. + Groupe les fichiers par titre extrait (dédoublonne les appels), appelle `tmdb_match`. + **Détection série vs film (`preferTv`)** : signal **structurel** d'abord — un dossier est une + série s'il contient ≥ 2 vidéos OU des sous-dossiers saison/numérotés (`Season 1`, `01`, `02`), + un film n'a qu'une vidéo. Plus fiable que de parser le nom (`SxxExx`). `preferTv` ordonne la + recherche et **bonifie** le score TV (+18) — il ne RESTREINT plus à un seul type. +3. **Auto-verify** (`:432`) : ≥ 3 entrées même `tmdb_id` dans un dossier (verified 50–60) → 70. +4. **Propagation parent→enfant** (`:459`, `:502`) : un parent `verified≥60` donne son affiche aux + enfants sans poster (saisons, etc.). +5. **Posters de saison** (`:526`) : via `tmdb_id` parent + `/tv/{id}/season/{n}`. +6. **GC** (`:640`) : supprime les lignes dont le `path` n'existe plus. +7. **Refresh TTL** (`:670`) : rafraîchit `overview`/`rating` des entrées vieilles de > 7 j. + +Boucle externe `do/while` : relance des passes tant qu'il y a du progrès ; s'arrête sur +stagnation (`pendingCount:attemptsSum` inchangé) — donc si TMDB est down, le worker s'arrête +au lieu de boucler, et reprend au prochain cron. + +## Dépendance clé : `folder_type = 'movies'` + +Les **fichiers vidéo** (dossier de films à plat) ne reçoivent une ligne `folder_posters` +**que si** le dossier parent est marqué `folder_type='movies'` (`handlers/tmdb.php:214`). +Posé par l'admin via `POST ?folder_type_set` (`:478`), qui **reset les enfants** pour re-match. +Sans ce flag, un dossier est traité en `series` (seuls les sous-dossiers reçoivent des affiches). + +## Démo Docker = vrai install + +La démo doit utiliser les **mêmes chemins de code** qu'une install réelle (pas un seed naïf) : +1. `docker/demo-data.sh` crée les médias d'exemple. +2. Les dossiers de films sont marqués `movies` via le vrai endpoint `?folder_type_set`. +3. On `curl ?posters=1` sur les dossiers de la grille (= 1ᵉʳ visiteur) → enqueue + auto-start worker. +4. Le worker matche tout via `tmdb_match` (retry/cache/scoring identiques à la prod). + +→ La démo devient un **smoke test réel** du matching. Toute amélioration du pipeline profite +automatiquement à la démo. Cron Docker : le worker est planifié (toutes les ~10 min) si +`SHAREBOX_TMDB_API_KEY` est défini. + +## Où changer quoi (aide-mémoire) + +| Je veux… | Fichier:fonction | +|---|---| +| Améliorer le nettoyage des noms de release | `functions.php:extract_title_year` | +| Changer la stratégie de recherche (endpoints, langues, année) | `functions.php:tmdb_search_candidates` | +| Régler la boucle word-removal / fallback | `functions.php:tmdb_match` | +| Ajuster les poids du score / seuils | `functions.php:tmdb_score_candidate`, `tmdb_score_to_verified` | +| Toucher au retry/backoff/cache réseau | `functions.php:tmdb_fetch`, `tmdb_fetch_cached` | +| Changer l'enqueue web / l'auto-start worker | `handlers/tmdb.php` (bloc `?posters`) | +| Modifier les phases batch (verify, propagation, saisons, GC) | `tools/tmdb-worker.php` | +| Logs | canal `poster` → `dirname(STREAM_LOG)/poster.log` (`poster_log()`) | + +## Tests + +- `tests/TmdbMatchTest.php` — `extract_title_year` sur noms crades, sémantique word-removal, + bonus année, garde-fous source (year-param typé, usage du cache). +- Lancer : `php8.2 vendor/bin/phpunit` (le `php` par défaut peut manquer `pdo_sqlite`). diff --git a/functions.php b/functions.php index c534278..8e5504b 100644 --- a/functions.php +++ b/functions.php @@ -206,7 +206,7 @@ function extract_title_year(string $name): array { // Remove "HD Remasted" pattern $clean = preg_replace('/\bHD\s+Remast\w*/i', '', $clean); // Couper au premier tag technique - $title = preg_replace('/\b(multi|vff|vfq|truefrench|french|english|vostfr|vost|subfrench|dual|bluray|blu-ray|bdrip|brrip|webrip|web-?dl|hdtv|dvdrip|hdrip|x264|x265|h264|h265|hevc|avc|xvid|divx|avi|mpeg|mpg|10bit|remux|2160p|1080p|720p|480p|uhd|4k|hdr|hdr10|dts|truehd|atmos|aac|ac3|flac|ddp?\d|proper\d?|repack|internal|extended|unrated|directors?-?cut|complete|s\d{2}e?\d{0,2}|e\d{2,4})\b.*/i', '', $clean); + $title = preg_replace('/\b(multi|vff|vfq|truefrench|french|english|vostfr|vost|subfrench|dual|bluray|blu-ray|bdrip|brrip|webrip|web-?dl|hdtv|dvdrip|hdrip|x264|x265|h264|h265|hevc|avc|xvid|divx|avi|mpeg|mpg|10bit|remux|2160p|1080p|720p|480p|uhd|4k|hdr|hdr10|dts|truehd|atmos|aac|ac3|flac|ddp?\d|proper\d?|repack|internal|extended|unrated|directors?-?cut|complete|s\d{1,2}e?\d{0,4}|e\d{2,4})\b.*/i', '', $clean); // "DC" = Directors Cut — retire seulement en fin de titre (évite de couper "DC Comics" au début) $title = preg_replace('/\s+dc\s*$/i', '', $title); // Si on a coupé à l'année, la retirer du titre aussi @@ -505,7 +505,8 @@ function tmdb_fetch(string $url, $ctx = null, int $maxRetries = 2): ?array { * Mettre à 0 pour bypass le cache (force refresh). * @return array|null Decoded JSON ou null si echec et pas en cache. */ -function tmdb_fetch_cached(string $url, int $ttlSec = 604800): ?array { +function tmdb_fetch_cached(string $url, int $ttlSec = 604800, bool &$fromCache = null): ?array { + $fromCache = false; if ($ttlSec <= 0) return tmdb_fetch($url); static $cacheStmt = null, $writeStmt = null; @@ -529,7 +530,7 @@ function tmdb_fetch_cached(string $url, int $ttlSec = 604800): ?array { $hit = $cacheStmt->fetchColumn(); if ($hit !== false) { $decoded = json_decode($hit, true); - if (is_array($decoded)) return $decoded; + if (is_array($decoded)) { $fromCache = true; return $decoded; } } // Miss → fetch @@ -558,42 +559,112 @@ function tmdb_fetch_cached(string $url, int $ttlSec = 604800): ?array { * Cherche un titre sur TMDB et retourne TOUS les candidats (pour le pick IA). * @return array[] Array of {id, title, year, type, overview, poster} */ -function tmdb_search_candidates(string $title, ?int $year, string $apiKey, $ctx, int $limit = 15): array { +function tmdb_search_candidates(string $title, ?int $year, string $apiKey, $ctx, int $limit = 15, bool $preferTv = false, bool &$responded = null): array { $candidates = []; $seenIds = []; + $responded = false; // true dès qu'un appel TMDB renvoie une réponse valide (≠ échec réseau/5xx) $encoded = urlencode($title); - $urls = [ - "https://api.themoviedb.org/3/search/multi?api_key={$apiKey}&query={$encoded}&language=fr&page=1", - "https://api.themoviedb.org/3/search/tv?api_key={$apiKey}&query={$encoded}&language=fr&page=1", - "https://api.themoviedb.org/3/search/movie?api_key={$apiKey}&query={$encoded}&language=fr&page=1", - ]; - if ($year) { - $urls[] = "https://api.themoviedb.org/3/search/multi?api_key={$apiKey}&query=" . urlencode($title . ' ' . $year) . "&language=fr&page=1"; - } - foreach ($urls as $searchUrl) { - $data = tmdb_fetch($searchUrl, $ctx); - if (!$data || empty($data['results'])) continue; - foreach ($data['results'] as $r) { - if (empty($r['poster_path']) || isset($seenIds[$r['id']])) continue; - $seenIds[$r['id']] = true; - $candidates[] = [ - 'id' => $r['id'], - 'title' => $r['title'] ?? $r['name'] ?? '?', - 'original_title' => $r['original_title'] ?? $r['original_name'] ?? null, - 'year' => substr($r['release_date'] ?? $r['first_air_date'] ?? '', 0, 4), - 'type' => $r['media_type'] ?? ($r['first_air_date'] ?? false ? 'tv' : 'movie'), - 'overview' => substr($r['overview'] ?? '', 0, 150), - 'poster' => 'https://image.tmdb.org/t/p/w300' . $r['poster_path'], - 'rating' => round((float)($r['vote_average'] ?? 0), 1), - 'vote_count' => (int)($r['vote_count'] ?? 0), - ]; - if (count($candidates) >= $limit) break 2; + + // On interroge TOUJOURS les deux types (movie ET tv) : c'est le SCORING (popularité + + // cohérence de type + similarité) qui tranche. Se limiter au type "probable" matchait un + // film homonyme pour toute série dont preferTv n'avait pas été détecté (Breaking Bad → un + // film "Breaking Bad Wolf", etc.). preferTv ne sert plus qu'à ordonner et bonifier le score. + // Année en VRAI paramètre TMDB (year / first_air_date_year), filtré côté serveur. + $endpoints = $preferTv ? ['tv', 'movie'] : ['movie', 'tv']; + $perCall = max(6, (int)ceil($limit / 2)); // borne par appel → garantit la diversité de type + + foreach ($endpoints as $endpoint) { + foreach (['fr', 'en-US'] as $lang) { + $yearParam = $year ? '&' . ($endpoint === 'tv' ? 'first_air_date_year' : 'year') . '=' . $year : ''; + $url = "https://api.themoviedb.org/3/search/{$endpoint}?api_key={$apiKey}&query={$encoded}&language={$lang}&page=1{$yearParam}"; + $fromCache = false; + $data = tmdb_fetch_cached($url, 604800, $fromCache); + if ($data !== null) $responded = true; // TMDB a répondu (même 0 résultat) — pas un échec réseau + if (!$fromCache) usleep(300000); // rate-limit : uniquement sur appel réseau réel + if (!$data || empty($data['results'])) continue; + $taken = 0; + foreach ($data['results'] as $r) { + if (empty($r['poster_path'])) continue; + $locTitle = $r['title'] ?? $r['name'] ?? null; + // Déjà vu (autre langue) → on agrège juste la variante de titre pour le scoring. + if (isset($seenIds[$r['id']])) { + if ($locTitle) $candidates[$seenIds[$r['id']]]['titles'][] = $locTitle; + continue; + } + $origTitle = $r['original_title'] ?? $r['original_name'] ?? null; + $seenIds[$r['id']] = count($candidates); + $candidates[] = [ + 'id' => $r['id'], + 'title' => $locTitle ?? '?', + 'titles' => array_values(array_filter([$locTitle, $origTitle])), // variantes fr/en/original + 'original_title' => $origTitle, + 'year' => substr($r['release_date'] ?? $r['first_air_date'] ?? '', 0, 4), + 'type' => $r['media_type'] ?? ($endpoint === 'tv' ? 'tv' : 'movie'), + 'overview' => substr($r['overview'] ?? '', 0, 150), + 'poster' => 'https://image.tmdb.org/t/p/w300' . $r['poster_path'], + 'rating' => round((float)($r['vote_average'] ?? 0), 1), + 'vote_count' => (int)($r['vote_count'] ?? 0), + ]; + if (++$taken >= $perCall) break; + } } - usleep(300000); } + return $candidates; } +/** + * Cherche le meilleur match TMDB pour un titre, en raccourcissant la requête + * mot par mot (depuis la fin) jusqu'à dépasser le seuil de confiance. + * Les releases collent souvent un sous-titre/seconde langue après le vrai titre + * (« 1BR The Apartement » → « 1BR ») : on garde la requête la PLUS LONGUE qui passe + * le seuil, et on score contre la requête réellement utilisée pour éviter les dérives. + * + * $responded (out) indique si TMDB a répondu au moins une fois : permet à l'appelant de + * distinguer un vrai « rien trouvé » (on peut abandonner) d'un échec réseau/5xx transitoire + * (à réessayer plus tard, sans brûler de tentative). + * + * @return array|null ['candidate' => array, 'score' => int] ou null si < 35 (seuil bas tmdb_score_to_verified) + */ +function tmdb_match(string $title, ?int $year, bool $preferTv, string $apiKey, $ctx = null, bool &$responded = null): ?array { + $responded = false; + $words = explode(' ', trim($title)); + $minWords = max(1, count($words) - 4); // borne la récursion à 5 essais max + + // Une passe word-removal pour une année de recherche donnée (filtre TMDB). + // Le scoring utilise toujours la VRAIE année (bonus), même quand on relâche le filtre. + $run = function (?int $searchYear) use ($words, $minWords, $year, $preferTv, $apiKey, $ctx, &$responded): array { + $best = null; + $bestScore = 0; + for ($n = count($words); $n >= $minWords; $n--) { + $query = implode(' ', array_slice($words, 0, $n)); + if (mb_strlen($query) < 2) break; + + $r = false; + foreach (tmdb_search_candidates($query, $searchYear, $apiKey, $ctx, 15, $preferTv, $r) as $cand) { + $score = tmdb_score_candidate($query, $year, $cand, $preferTv); // score contre la requête utilisée + if ($score > $bestScore) { + $bestScore = $score; + $best = $cand; + } + } + if ($r) $responded = true; + if ($bestScore >= 55) break; // match fiable (palier "verified 60") → on arrête de tronquer + } + return [$best, $bestScore]; + }; + + [$best, $bestScore] = $run($year); + // Filtre année trop strict (0 candidat retenu) : on relâche le filtre côté recherche, + // le scoring conserve le bonus d'année. Évite qu'une année off-by-one tue le match. + if ($bestScore < 35 && $year !== null) { + [$best, $bestScore] = $run(null); + } + + // 35 = seuil bas de tmdb_score_to_verified (verified=40) : en dessous, aucun match retourné. + return $bestScore >= 35 ? ['candidate' => $best, 'score' => $bestScore] : null; +} + /** * Score a TMDB candidate against an extracted title/year. * Returns 0-100 reflecting match confidence. @@ -623,31 +694,24 @@ function tmdb_score_candidate(string $extractedTitle, ?int $extractedYear, array if (mb_strlen($a) > 80) $a = mb_substr($a, 0, 80); $lenA = mb_strlen($a); - // Score against localized title - $b = $norm($candidate['title'] ?? ''); + // Score contre TOUTES les variantes de titre (fr + en + original), agrégées par id lors de + // la recherche bilingue. Crucial pour l'anime/contenu étranger : on cherche en anglais mais + // TMDB peut renvoyer un titre fr ou japonais ; il suffit qu'UNE variante matche. + $variants = $candidate['titles'] ?? []; + foreach ([$candidate['title'] ?? null, $candidate['original_title'] ?? null] as $extra) { + if ($extra !== null && $extra !== '') $variants[] = $extra; + } $bestPct = 0; - $bestB = $b; - if ($b !== '') { + $bestB = ''; + foreach ($variants as $variant) { + $b = $norm((string)$variant); + if ($b === '') continue; similar_text($a, $b, $pct); // Penalize length divergence to avoid short-title false positives ("One" vs "One Piece") $lenB = mb_strlen($b); $lenRatio = min($lenA, $lenB) / max($lenA, $lenB); if ($lenRatio < 0.5) $pct *= $lenRatio * 1.5; - $bestPct = $pct; - } - - // Also try original title (handles anime, non-English media) - $bOrig = isset($candidate['original_title']) ? $norm($candidate['original_title']) : ''; - if ($bOrig !== '' && $bOrig !== $b) { - similar_text($a, $bOrig, $pctOrig); - // Same length penalty for original title - $lenBO = mb_strlen($bOrig); - $lenRatioO = min($lenA, $lenBO) / max($lenA, $lenBO); - if ($lenRatioO < 0.5) $pctOrig *= $lenRatioO * 1.5; - if ($pctOrig > $bestPct) { - $bestPct = $pctOrig; - $bestB = $bOrig; - } + if ($pct > $bestPct) { $bestPct = $pct; $bestB = $b; } } if ($bestB === '') return 0; @@ -672,14 +736,18 @@ function tmdb_score_candidate(string $extractedTitle, ?int $extractedYear, array } } - // ── Type coherence (0-10 points) ── + // ── Type coherence (0-18 points) ── + // preferTv n'est vrai que si le dossier contient des saisons/épisodes : signal FORT de + // série. On bonifie alors lourdement la TV (et on ne bonifie pas le film) pour battre un + // film homonyme au titre exact (ex. "Demon Slayer" le film vs la série au sous-titre long). + // Sans signal de saison (films à plat), on garde la préférence film modérée. $cType = $candidate['type'] ?? ''; - if ($preferTv && $cType === 'tv') { - $score += 10; - } elseif (!$preferTv && $cType === 'movie') { + if ($preferTv) { + $score += ($cType === 'tv') ? 18 : 0; + } elseif ($cType === 'movie') { $score += 10; - } elseif ($cType === 'tv' || $cType === 'movie') { - $score += 3; // wrong preference but still valid media + } elseif ($cType === 'tv') { + $score += 3; // pas de signal de saison mais média valide } // ── Popularity (0-12 points) — finer granularity to break ties ── diff --git a/tests/StreamingHandlersTest.php b/tests/StreamingHandlersTest.php index 0012dae..f2f5240 100644 --- a/tests/StreamingHandlersTest.php +++ b/tests/StreamingHandlersTest.php @@ -1059,31 +1059,44 @@ public function testWorkerLockFileChmod0644(): void ); } - // ── Worker : word truncation garde min 3 mots ─────────────────────── + // ── Worker : matching délégué à tmdb_match (boucle word-removal) ───── - public function testWorkerWordTruncationKeepsMinThreeWords(): void + public function testWorkerUsesTmdbMatch(): void { $source = file_get_contents(__DIR__ . '/../tools/tmdb-worker.php'); - // Le retry attempt 1 doit garder au minimum 3 mots + // La recherche du candidat passe désormais par tmdb_match (word-removal + cache). $this->assertStringContainsString( - 'max(3', + 'tmdb_match(', $source, - 'Le retry attempt 1 doit garder au minimum 3 mots lors du truncation' + 'Le worker doit déléguer la recherche du candidat à tmdb_match' ); } - // ── Worker : rate limit TMDB ≥ 250ms ──────────────────────────────── + // ── Recherche TMDB : word truncation garde au moins 1 mot ─────────── - public function testWorkerRateLimitTmdb(): void + public function testTmdbMatchKeepsAtLeastOneWord(): void { - $source = file_get_contents(__DIR__ . '/../tools/tmdb-worker.php'); - // Le usleep entre les requêtes TMDB doit être ≥ 250ms (250000µs) + $source = file_get_contents(__DIR__ . '/../functions.php'); + // La boucle word-removal de tmdb_match borne le minimum à au moins 1 mot. + $this->assertStringContainsString( + 'max(1, count($words) - 4)', + $source, + 'tmdb_match doit garder au minimum 1 mot et borner la récursion à 5 essais' + ); + } + + // ── Recherche TMDB : rate limit ≥ 250ms (dans tmdb_search_candidates) ─ + + public function testTmdbSearchRateLimit(): void + { + $source = file_get_contents(__DIR__ . '/../functions.php'); + // Le usleep entre les requêtes TMDB vit maintenant dans tmdb_search_candidates. preg_match_all('/usleep\((\d+)\)/', $source, $matches); - $this->assertNotEmpty($matches[1], 'Le worker doit avoir des usleep pour le rate limit'); + $this->assertNotEmpty($matches[1], 'functions.php doit avoir des usleep pour le rate limit'); $apiSleeps = array_filter($matches[1], fn($us) => (int)$us >= 250000); $this->assertNotEmpty( $apiSleeps, - 'Le worker doit avoir au moins un usleep ≥ 250ms pour le rate limit TMDB API' + 'tmdb_search_candidates doit avoir au moins un usleep ≥ 250ms pour le rate limit TMDB' ); } diff --git a/tests/TmdbMatchTest.php b/tests/TmdbMatchTest.php new file mode 100644 index 0000000..3ecbf32 --- /dev/null +++ b/tests/TmdbMatchTest.php @@ -0,0 +1,125 @@ +assertSame($expectedTitle, $r['title'], "Titre attendu pour: $name"); + $this->assertSame($expectedYear, $r['year'], "Année attendue pour: $name"); + } + + public static function releaseNameProvider(): array + { + return [ + 'movie classic' => ['Movie.Name.2021.1080p.BluRay.x264-GROUP', 'Movie Name', 2021], + 'multi 2160p' => ['Batman.Begins.2005.MULTI.2160p.UHD.BluRay.x265-GROUP', 'Batman Begins', 2005], + 'french bdrip' => ['Interstellar.2014.FRENCH.BDRip.XviD-ABC', 'Interstellar', 2014], + 'no year' => ['One.Piece.S01.VOSTFR.1080p', 'One Piece', null], + 'webdl' => ['Dune.2021.IMAX.1080p.WEB-DL.DDP5.1', 'Dune IMAX', 2021], + // Numéros d'épisode à 3-4 chiffres (anime/soap) — motivent s\d{1,2}e?\d{0,4} + 'episode 4 digits' => ['One.Piece.S01E1164.VOSTFR.1080p', 'One Piece', null], + 'episode 3 digits' => ['Naruto.Shippuden.S01E500.VOSTFR', 'Naruto Shippuden', null], + 'episode + year' => ['Breaking.Bad.S05E14.2013.1080p', 'Breaking Bad', 2013], + ]; + } + + // ── tmdb_match : titre vide → null sans réseau ──────────────────────────── + + public function testEmptyTitleReturnsNullWithoutNetwork(): void + { + // Un titre vide produit une query < 2 chars dès la 1re itération → break immédiat, + // donc pas d'appel réseau et retour null. + $this->assertNull(tmdb_match('', null, false, 'fake-key')); + $this->assertNull(tmdb_match(' ', null, false, 'fake-key')); + + // Pas d'appel réseau → $responded doit rester false (sinon le worker brûlerait + // une tentative match_attempts à tort sur un titre non exploitable). + $responded = true; + tmdb_match('', null, false, 'fake-key', null, $responded); + $this->assertFalse($responded, 'Titre vide → aucun appel TMDB → responded=false'); + } + + // ── tmdb_score_candidate : la requête raccourcie matche mieux ───────────── + // Cœur de la sémantique word-removal : « 1BR The Apartement » score mal contre + // le candidat « 1BR », alors que la requête raccourcie « 1BR » score parfaitement. + + public function testShortenedQueryScoresBetterThanFullReleaseTitle(): void + { + $candidate = [ + 'id' => 1, 'title' => '1BR', 'original_title' => '1BR', + 'year' => '2019', 'type' => 'movie', 'vote_count' => 800, + ]; + $scoreFull = tmdb_score_candidate('1BR The Apartement', null, $candidate); + $scoreShort = tmdb_score_candidate('1BR', null, $candidate); + $this->assertGreaterThan( + $scoreFull, + $scoreShort, + 'La requête raccourcie au vrai titre doit scorer mieux que le nom de release complet' + ); + } + + // ── tmdb_score_candidate : bonus année exact ────────────────────────────── + + public function testExactYearAddsBonus(): void + { + $candidate = [ + 'id' => 1, 'title' => 'Inception', 'original_title' => 'Inception', + 'year' => '2010', 'type' => 'movie', 'vote_count' => 5000, + ]; + $withYear = tmdb_score_candidate('Inception', 2010, $candidate); + $noYear = tmdb_score_candidate('Inception', null, $candidate); + $this->assertGreaterThan($noYear, $withYear, 'Une année exacte doit ajouter un bonus de score'); + } + + // ── tmdb_search_candidates : année en VRAI paramètre TMDB typé (source) ─── + + public function testSearchCandidatesUsesTypedYearParam(): void + { + $source = file_get_contents(__DIR__ . '/../functions.php'); + $this->assertStringContainsString( + 'first_air_date_year', + $source, + 'Le endpoint tv doit filtrer par first_air_date_year' + ); + $this->assertStringNotContainsString( + "query=\" . urlencode(\$title . ' ' . \$year)", + $source, + "L'année ne doit plus être concaténée dans la query string" + ); + } + + // ── tmdb_search_candidates : utilise le cache SQLite ────────────────────── + + public function testSearchCandidatesUsesCachedFetch(): void + { + $source = file_get_contents(__DIR__ . '/../functions.php'); + // La boucle word-removal re-tape souvent les mêmes requêtes → cache obligatoire. + $this->assertMatchesRegularExpression( + '/function tmdb_search_candidates.*?tmdb_fetch_cached/s', + $source, + 'tmdb_search_candidates doit utiliser tmdb_fetch_cached pour le cache inter-passes' + ); + } +} diff --git a/tools/tmdb-worker.php b/tools/tmdb-worker.php index 3d7cdf6..921376a 100755 --- a/tools/tmdb-worker.php +++ b/tools/tmdb-worker.php @@ -342,62 +342,36 @@ function (SplFileInfo $current, string $key, RecursiveDirectoryIterator $iterato $searchTitle = $first['title']; $searchYear = $first['year']; - // Per-entry TV preference: if the folder name itself contains S01-S99 pattern - $entryPreferTv = $preferTv || preg_match('/\bS\d{1,2}\b/i', $first['name']); - - // ── Retry strategy based on attempt level ── - // Attempt 0: full title, FR, multi+tv candidates - // Attempt 1: short title (first half of words), FR, multi+tv+movie - // Attempt 2: full title, no language (English fallback) - if ($attempt === 1) { - $words = explode(' ', $searchTitle); - if (count($words) > 3) { - $searchTitle = implode(' ', array_slice($words, 0, max(3, (int)ceil(count($words) / 2)))); - } - } - - $candidates = []; - if ($attempt <= 1) { - $candidates = tmdb_search_candidates($searchTitle, $searchYear, $TMDB_API_KEY, $ctx, 8); - } else { - // Attempt 2: English fallback — direct fetch without language param - $encoded = urlencode($searchTitle); - $urls = [ - "https://api.themoviedb.org/3/search/multi?api_key={$TMDB_API_KEY}&query={$encoded}&page=1", - ]; - foreach ($urls as $u) { - $data = tmdb_fetch($u, $ctx); - if ($data && !empty($data['results'])) { - foreach ($data['results'] as $r) { - if (empty($r['poster_path'])) continue; - $candidates[] = [ - 'id' => $r['id'], - 'title' => $r['title'] ?? $r['name'] ?? '?', - 'original_title' => $r['original_title'] ?? $r['original_name'] ?? null, - 'year' => substr($r['release_date'] ?? $r['first_air_date'] ?? '', 0, 4), - 'type' => $r['media_type'] ?? ($r['first_air_date'] ?? false ? 'tv' : 'movie'), - 'overview' => substr($r['overview'] ?? '', 0, 150), - 'poster' => 'https://image.tmdb.org/t/p/w300' . $r['poster_path'], - 'rating' => round((float)($r['vote_average'] ?? 0), 1), - 'vote_count' => (int)($r['vote_count'] ?? 0), - ]; - if (count($candidates) >= 8) break; + // Per-entry TV preference. Signal le plus fiable = la STRUCTURE du dossier, pas le nom : + // une série a plusieurs épisodes (≥2 vidéos à plat) OU des sous-dossiers de saison / + // numérotés (Season 1, Saison 3, 01, 02…) ; un film n'a qu'une vidéo. On garde aussi + // le marqueur S01-S99 dans le nom comme signal complémentaire. + $entryPreferTv = $preferTv || (bool)preg_match('/\bS\d{1,2}\b/i', $first['name']); + if (!$entryPreferTv) { + $entryPath = $dir . '/' . $first['name']; + if (is_dir($entryPath)) { + $videoCount = 0; $seasonish = 0; + foreach (@scandir($entryPath) ?: [] as $sub) { + if ($sub[0] === '.') continue; + $full = $entryPath . '/' . $sub; + if (is_dir($full)) { + // sous-dossier saison/arc/partie OU purement numérique (01, 02…) + if (preg_match('/^(s\d{1,2}\b|saison|season|saga|arc|part|\d{1,3}$)/i', $sub)) $seasonish++; + } elseif (in_array(strtolower(pathinfo($sub, PATHINFO_EXTENSION)), $VIDEO_EXTS, true)) { + $videoCount++; } } - usleep(300000); + if ($videoCount >= 2 || $seasonish >= 1) $entryPreferTv = true; } } - // ── Score candidates and pick best ── - $bestMatch = null; - $bestScore = 0; - foreach ($candidates as $c) { - $score = tmdb_score_candidate($first['title'], $first['year'], $c, $entryPreferTv); - if ($score > $bestScore) { - $bestScore = $score; - $bestMatch = $c; - } - } + // ── Recherche du meilleur candidat (boucle word-removal + cache + retry) ── + // tmdb_match raccourcit la requête mot par mot et score contre la requête utilisée. + // $responded distingue un vrai "rien trouvé" d'un échec réseau/5xx transitoire. + $responded = false; + $result = tmdb_match($searchTitle, $searchYear, $entryPreferTv, $TMDB_API_KEY, null, $responded); + $bestScore = $result['score'] ?? 0; + $bestMatch = $result['candidate'] ?? null; $verified = tmdb_score_to_verified($bestScore); $match = null; @@ -426,8 +400,10 @@ function (SplFileInfo $current, string $key, RecursiveDirectoryIterator $iterato } catch (PDOException $e) { ai_log('DB error (match write): ' . $e->getMessage()); } - } else { - // Increment match_attempts for next retry pass + } elseif ($responded) { + // Vrai "rien trouvé" (TMDB a répondu) → on consomme une tentative. + // Échec réseau/5xx transitoire : on NE touche pas match_attempts, le + // prochain passage cron réessaiera (évite de griller une entrée sur un 502). $nextAttempt = $attempt + 1; try { $db->prepare("UPDATE folder_posters SET match_attempts = :a WHERE rowid = :id") @@ -440,7 +416,9 @@ function (SplFileInfo $current, string $key, RecursiveDirectoryIterator $iterato if ($match) { ai_log('MATCH | "' . $first['title'] . '" x' . count($files) . ' -> ' . $match['title'] . ' (id=' . $match['id'] . ' score=' . $bestScore . ' verified=' . $verified . ')'); - } elseif ($candidates) { + } elseif (!$responded) { + ai_log('SKIP | "' . $first['title'] . '" TMDB injoignable (transitoire) — match_attempts inchangé'); + } else { ai_log('WEAK | "' . $first['title'] . '" best_score=' . $bestScore . ' (below threshold, attempt=' . ($attempt+1) . ')'); } } From b1eb4d3fdce9e916def2e3ea18cfe004ad7b53fc Mon Sep 17 00:00:00 2001 From: root Date: Sat, 20 Jun 2026 11:02:49 -0400 Subject: [PATCH 2/2] fix(ci): phpstan by-ref types + e2e demo item counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - params de sortie bool &$responded/$fromCache : défaut false (pas null) → type non-nullable attendu par PHPStan - e2e: démo enrichie (18 films, 5 animés, 6 séries) → maj des comptes attendus --- functions.php | 6 +++--- tests/e2e/comprehensive.spec.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/functions.php b/functions.php index 8e5504b..1a4d7b7 100644 --- a/functions.php +++ b/functions.php @@ -505,7 +505,7 @@ function tmdb_fetch(string $url, $ctx = null, int $maxRetries = 2): ?array { * Mettre à 0 pour bypass le cache (force refresh). * @return array|null Decoded JSON ou null si echec et pas en cache. */ -function tmdb_fetch_cached(string $url, int $ttlSec = 604800, bool &$fromCache = null): ?array { +function tmdb_fetch_cached(string $url, int $ttlSec = 604800, bool &$fromCache = false): ?array { $fromCache = false; if ($ttlSec <= 0) return tmdb_fetch($url); @@ -559,7 +559,7 @@ function tmdb_fetch_cached(string $url, int $ttlSec = 604800, bool &$fromCache = * Cherche un titre sur TMDB et retourne TOUS les candidats (pour le pick IA). * @return array[] Array of {id, title, year, type, overview, poster} */ -function tmdb_search_candidates(string $title, ?int $year, string $apiKey, $ctx, int $limit = 15, bool $preferTv = false, bool &$responded = null): array { +function tmdb_search_candidates(string $title, ?int $year, string $apiKey, $ctx, int $limit = 15, bool $preferTv = false, bool &$responded = false): array { $candidates = []; $seenIds = []; $responded = false; // true dès qu'un appel TMDB renvoie une réponse valide (≠ échec réseau/5xx) @@ -626,7 +626,7 @@ function tmdb_search_candidates(string $title, ?int $year, string $apiKey, $ctx, * * @return array|null ['candidate' => array, 'score' => int] ou null si < 35 (seuil bas tmdb_score_to_verified) */ -function tmdb_match(string $title, ?int $year, bool $preferTv, string $apiKey, $ctx = null, bool &$responded = null): ?array { +function tmdb_match(string $title, ?int $year, bool $preferTv, string $apiKey, $ctx = null, bool &$responded = false): ?array { $responded = false; $words = explode(' ', trim($title)); $minWords = max(1, count($words) - 4); // borne la récursion à 5 essais max diff --git a/tests/e2e/comprehensive.spec.ts b/tests/e2e/comprehensive.spec.ts index 847f292..4b4eecb 100644 --- a/tests/e2e/comprehensive.spec.ts +++ b/tests/e2e/comprehensive.spec.ts @@ -69,7 +69,7 @@ test.describe('Public Browse', () => { await expect(page.locator('.row-name.is-folder, .grid-card-title')).toContainText(['Series']); }); - test('Films folder contains 12 items', async ({ page }) => { + test('Films folder contains 18 items', async ({ page }) => { requireLocal(); await page.goto(BROWSE_ROOT + '?p=Films'); const html = await page.content(); @@ -77,12 +77,12 @@ test.describe('Public Browse', () => { test.skip(true, 'Films folder not available'); return; } - // 12 film files seeded by demo-data.sh + // 18 film files seeded by demo-data.sh const items = page.locator('.row:not(:has(.row-name:text("..")))'); - await expect(items).toHaveCount(12, { timeout: 10000 }); + await expect(items).toHaveCount(18, { timeout: 10000 }); }); - test('Anime folder contains 3 sub-items (Attack on Titan, Death Note, One Piece)', async ({ page }) => { + test('Anime folder contains 5 sub-items (incl. Attack on Titan, Death Note, One Piece)', async ({ page }) => { requireLocal(); await page.goto(BROWSE_ROOT + '?p=Anime'); const html = await page.content(); @@ -108,7 +108,7 @@ test.describe('Public Browse', () => { await expect(page.locator('.row-name.is-folder')).toContainText(['Season'], { timeout: 10000 }); }); - test('Series folder contains 4 sub-items', async ({ page }) => { + test('Series folder contains 6 sub-items', async ({ page }) => { requireLocal(); await page.goto(BROWSE_ROOT + '?p=Series'); const html = await page.content(); @@ -223,7 +223,7 @@ test.describe('TMDB Posters', () => { expect(typeof data.pending).toBe('number'); }); - test('Films posters JSON has 12 entries (one per film)', async ({ page }) => { + test('Films posters JSON has 18 entries (one per film)', async ({ page }) => { requireLocal(); const resp = await page.request.get(BROWSE_ROOT + '?p=Films&posters=1'); if (!resp.ok()) {