Created
April 12, 2026 18:20
-
-
Save PierreDurrr/7c8065e92a5401f8697c39679b73c02f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const http = require('http'); | |
| const https = require('https'); | |
| const url = require('url'); | |
| const FANKAI_BASE = process.env.FANKAI_URL || 'https://metadata.fankai.fr'; | |
| const PORT = process.env.PORT || 3000; | |
| const TMDB_KEY = process.env.TMDB_API_KEY || 'NOPE'; | |
| const PROXY_ID = 'tv.plex.agents.custom.fankai.proxy'; | |
| const FANKAI_ID = 'tv.plex.agents.custom.fankai'; | |
| const TMDB_IMG = 'https://image.tmdb.org/t/p'; | |
| const OMDB_KEY = process.env.OMDB_API_KEY || 'NOPE'; | |
| // Cache mémoire (TTL 10min) | |
| const cache = new Map(); | |
| function cached(key, fn, ttl = 600000) { | |
| const hit = cache.get(key); | |
| if (hit && Date.now() - hit.ts < ttl) return Promise.resolve(hit.val); | |
| return fn().then(val => { cache.set(key, { val, ts: Date.now() }); return val; }); | |
| } | |
| function makeRequest(method, targetUrl, bodyStr) { | |
| return new Promise((resolve, reject) => { | |
| const parsed = new url.URL(targetUrl); | |
| const bodyBuffer = bodyStr ? Buffer.from(bodyStr) : null; | |
| const options = { | |
| hostname: parsed.hostname, | |
| port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), | |
| path: parsed.pathname + parsed.search, | |
| method, | |
| headers: { | |
| 'Accept': 'application/json', | |
| 'Content-Type': 'application/json', | |
| ...(bodyBuffer ? { 'Content-Length': bodyBuffer.length } : {}) | |
| } | |
| }; | |
| const proto = parsed.protocol === 'https:' ? https : http; | |
| const req = proto.request(options, res => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => resolve({ status: res.statusCode, body: data })); | |
| }); | |
| req.on('error', reject); | |
| if (bodyBuffer) req.write(bodyBuffer); | |
| req.end(); | |
| }); | |
| } | |
| async function tmdb(path, params = {}) { | |
| const qs = new URLSearchParams({ api_key: TMDB_KEY, ...params }).toString(); | |
| const u = `https://api.themoviedb.org/3${path}?${qs}`; | |
| return cached(u, async () => { | |
| const r = await makeRequest('GET', u, null); | |
| if (r.status === 200) return JSON.parse(r.body); | |
| console.log(` → TMDB ${r.status} for ${path}`); | |
| return null; | |
| }); | |
| } | |
| function img(path, size = 'w500') { return path ? `${TMDB_IMG}/${size}${path}` : null; } | |
| function isFankaiError(status, body) { | |
| if (status !== 200) return true; | |
| try { return JSON.parse(body).error !== undefined; } catch { return false; } | |
| } | |
| function isFankaiEmpty(body) { | |
| try { | |
| const mc = JSON.parse(body).MediaContainer; | |
| if (!mc) return false; | |
| if (Array.isArray(mc.Metadata) && mc.Metadata.length === 0) return true; | |
| if (Array.isArray(mc.Image) && mc.Image.length === 0 && mc.size === 0) return true; | |
| return false; | |
| } catch { return false; } | |
| } | |
| function readBody(req) { | |
| return new Promise(resolve => { | |
| let body = ''; | |
| req.on('data', chunk => body += chunk); | |
| req.on('end', () => resolve(body)); | |
| }); | |
| } | |
| function mc(extra = {}) { | |
| return { MediaContainer: { identifier: PROXY_ID, offset: 0, size: 0, totalSize: 0, ...extra } }; | |
| } | |
| function empty() { return JSON.stringify(mc({ Metadata: [] })); } | |
| function emptyImages() { return JSON.stringify(mc({ Image: [] })); } | |
| // Résolution GUID externe → tmdbId | |
| async function resolveGuid(guid) { | |
| if (!guid) return null; | |
| let m = guid.match(/tmdb:\/\/(?:show\/)?(\d+)/); | |
| if (m) return m[1]; | |
| m = guid.match(/tvdb:\/\/(?:show\/)?(\d+)/); | |
| if (m) { | |
| const d = await tmdb(`/find/${m[1]}`, { external_source: 'tvdb_id' }); | |
| return d?.tv_results?.[0]?.id?.toString() || null; | |
| } | |
| m = guid.match(/imdb:\/\/(tt\d+)/); | |
| if (m) { | |
| const d = await tmdb(`/find/${m[1]}`, { external_source: 'imdb_id' }); | |
| return d?.tv_results?.[0]?.id?.toString() || null; | |
| } | |
| return null; | |
| } | |
| // Mappers | |
| function mapImages(show) { | |
| const images = []; | |
| if (show.poster_path) images.push({ type: 'coverPoster', alt: show.name, url: img(show.poster_path) }); | |
| if (show.backdrop_path) images.push({ type: 'background', alt: show.name, url: img(show.backdrop_path, 'original') }); | |
| (show.images?.logos || []).filter(i => i.file_path?.endsWith('.png')).slice(0, 2) | |
| .forEach(i => images.push({ type: 'clearLogo', alt: show.name, url: img(i.file_path, 'original') })); | |
| (show.images?.backdrops || []).slice(1, 6) | |
| .forEach(i => images.push({ type: 'background', url: img(i.file_path, 'original') })); | |
| (show.images?.posters || []).slice(1, 6) | |
| .forEach(i => images.push({ type: 'coverPoster', url: img(i.file_path) })); | |
| return images; | |
| } | |
| function mapCast(credits) { | |
| const cast = credits?.cast || credits?.aggregate_credits?.cast || []; | |
| return cast.slice(0, 25).map(a => ({ | |
| tag: a.name, | |
| role: a.roles?.[0]?.character || a.character || '', | |
| thumb: img(a.profile_path, 'w185'), | |
| })); | |
| } | |
| async function fetchOmdbData(imdbId) { | |
| if (!imdbId) return null; | |
| return cached(`omdb:${imdbId}`, async () => { | |
| try { | |
| const u = `https://www.omdbapi.com/?i=${imdbId}&apikey=${OMDB_KEY}`; | |
| const r = await makeRequest('GET', u, null); | |
| if (r.status !== 200) return null; | |
| const d = JSON.parse(r.body); | |
| if (d.Response === 'False') return null; | |
| return d; | |
| } catch (e) { | |
| console.error(` → Erreur OMDB: ${e.message}`); | |
| return null; | |
| } | |
| }, 3600000); // cache 1h | |
| } | |
| async function mapRatings(show, omdb) { | |
| const ratings = []; | |
| // TMDB | |
| if (show.vote_average) { | |
| ratings.push({ | |
| image: 'themoviedb://image.rating', | |
| type: 'audience', | |
| value: parseFloat(show.vote_average.toFixed(1)), | |
| }); | |
| } | |
| if (omdb) { | |
| // IMDB | |
| if (omdb.imdbRating && omdb.imdbRating !== 'N/A') { | |
| ratings.push({ | |
| image: 'imdb://image.rating', | |
| type: 'audience', | |
| value: parseFloat(omdb.imdbRating), | |
| }); | |
| } | |
| // Rotten Tomatoes | |
| const rtRatings = omdb.Ratings || []; | |
| const rtCritic = rtRatings.find(r => r.Source === 'Rotten Tomatoes'); | |
| if (rtCritic?.Value && rtCritic.Value !== 'N/A') { | |
| const score = parseInt(rtCritic.Value); | |
| ratings.push({ | |
| image: score >= 60 ? 'rottentomatoes://image.rating.ripe' : 'rottentomatoes://image.rating.rotten', | |
| type: 'critic', | |
| value: score / 10, | |
| }); | |
| } | |
| const mcScore = rtRatings.find(r => r.Source === 'Metacritic'); | |
| if (mcScore?.Value && mcScore.Value !== 'N/A') { | |
| const score = parseInt(mcScore.Value.split('/')[0]); | |
| if (!isNaN(score)) { | |
| ratings.push({ | |
| image: 'rottentomatoes://image.rating.upright', | |
| type: 'audience', | |
| value: score / 10, | |
| }); | |
| } | |
| } | |
| } | |
| return ratings; | |
| } | |
| function mapCollections(show) { | |
| // Collections TMDB (keywords utilisés comme collections) | |
| return (show.keywords?.results || []) | |
| .filter(k => k.name) | |
| .slice(0, 3) | |
| .map(k => ({ tag: k.name })); | |
| } | |
| async function mapShow(show, forSearch = false, omdb = null) { | |
| const thumb = img(show.poster_path); | |
| const art = img(show.backdrop_path, 'original'); | |
| const showYear = show.first_air_date ? parseInt(show.first_air_date) : undefined; | |
| const frRating = show.content_ratings?.results?.find(r => r.iso_3166_1 === 'FR') | |
| || show.content_ratings?.results?.[0]; | |
| const base = { | |
| type: 'show', | |
| guid: `${PROXY_ID}://show/tmdb-${show.id}`, | |
| ratingKey: `tmdb-${show.id}`, | |
| key: `/library/metadata/tmdb-${show.id}/children`, | |
| title: show.name, | |
| titleSort: show.name?.replace(/^(The|A|An|Le|La|Les|Un|Une) /i, '').trim(), | |
| originalTitle: show.original_name !== show.name ? show.original_name : undefined, | |
| year: showYear, | |
| summary: show.overview || '', | |
| tagline: show.tagline || undefined, | |
| originallyAvailableAt: show.first_air_date || '', | |
| thumb, art, | |
| Image: [ | |
| ...(thumb ? [{ type: 'coverPoster', alt: show.name, url: thumb }] : []), | |
| ...(art ? [{ type: 'background', alt: show.name, url: art }] : []), | |
| ], | |
| Guid: [], | |
| score: forSearch ? Math.round((show.vote_average || 0) * 10) : undefined, | |
| }; | |
| if (!forSearch) { | |
| Object.assign(base, { | |
| contentRating: frRating?.rating || undefined, | |
| studio: show.networks?.[0]?.name || undefined, | |
| rating: show.vote_average ? parseFloat(show.vote_average.toFixed(1)) : undefined, | |
| ratingImage: show.vote_average ? 'themoviedb://image.rating' : undefined, | |
| audienceRating: omdb?.imdbRating && omdb.imdbRating !== 'N/A' | |
| ? parseFloat(omdb.imdbRating) | |
| : (show.vote_average ? parseFloat(show.vote_average.toFixed(1)) : undefined), | |
| audienceRatingImage: omdb?.imdbRating && omdb.imdbRating !== 'N/A' | |
| ? 'imdb://image.rating' | |
| : (show.vote_average ? 'themoviedb://image.rating' : undefined), | |
| leafCount: show.number_of_episodes || undefined, | |
| childCount: show.number_of_seasons || undefined, | |
| Image: mapImages(show), | |
| Genre: (show.genres || []).map(g => ({ tag: g.name })), | |
| Country: (show.production_countries || []).map(c => ({ tag: c.name })), | |
| Network: (show.networks || []).map(n => ({ tag: n.name })), | |
| Collection: mapCollections(show), | |
| Role: mapCast(show), | |
| Rating: await mapRatings(show, omdb), | |
| Guid: [ | |
| { id: `tmdb://${show.id}` }, | |
| ...(show.external_ids?.tvdb_id ? [{ id: `tvdb://${show.external_ids.tvdb_id}` }] : []), | |
| ...(show.external_ids?.imdb_id ? [{ id: `imdb://${show.external_ids.imdb_id}` }] : []), | |
| ], | |
| Children: { | |
| Metadata: (show.seasons || []).map(s => ({ | |
| type: 'season', | |
| guid: `${PROXY_ID}://season/${show.id}-${s.season_number}`, | |
| ratingKey: `${show.id}-${s.season_number}`, | |
| key: `/library/metadata/${show.id}-${s.season_number}/children`, | |
| parentRatingKey: `tmdb-${show.id}`, | |
| parentTitle: show.name, | |
| title: s.name, | |
| index: s.season_number, | |
| year: s.air_date ? parseInt(s.air_date) : showYear, | |
| originallyAvailableAt: s.air_date || '', | |
| summary: s.overview || '', | |
| leafCount: s.episode_count || 0, | |
| thumb: img(s.poster_path) || thumb, | |
| Image: img(s.poster_path) ? [{ type: 'coverPoster', alt: s.name, url: img(s.poster_path) }] : [], | |
| Guid: [], | |
| })), | |
| }, | |
| }); | |
| } | |
| return base; | |
| } | |
| function mapEpisode(ep, tmdbId, seasonNum, showName, showThumb, showCast = []) { | |
| const epThumb = img(ep.still_path); | |
| const directors = (ep.crew || []).filter(c => c.job === 'Director').map(c => ({ | |
| tag: c.name, thumb: img(c.profile_path, 'w185') | |
| })); | |
| const writers = (ep.crew || []) | |
| .filter(c => ['Writer','Screenplay','Story','Creator','Co-Writer'].includes(c.job)) | |
| .map(c => ({ tag: c.name, thumb: img(c.profile_path, 'w185') })); | |
| const producers = (ep.crew || []) | |
| .filter(c => ['Producer','Executive Producer','Co-Producer'].includes(c.job)) | |
| .map(c => ({ tag: c.name, thumb: img(c.profile_path, 'w185') })); | |
| // Acteurs principaux du show + guest stars de l'épisode | |
| const guestActors = (ep.guest_stars || []).slice(0, 15).map(a => ({ | |
| tag: a.name, role: a.character, thumb: img(a.profile_path, 'w185') | |
| })); | |
| const mainCast = showCast.slice(0, 10).map(a => ({ | |
| tag: a.name, role: a.roles?.[0]?.character || a.character || '', thumb: img(a.profile_path, 'w185') | |
| })); | |
| // Fusionner en évitant les doublons | |
| const actorNames = new Set(guestActors.map(a => a.tag)); | |
| const actors = [...guestActors, ...mainCast.filter(a => !actorNames.has(a.tag))]; | |
| return { | |
| type: 'episode', | |
| guid: `${PROXY_ID}://episode/${tmdbId}-${seasonNum}-${ep.episode_number}`, | |
| ratingKey: `${tmdbId}-${seasonNum}-${ep.episode_number}`, | |
| key: `/library/metadata/${tmdbId}-${seasonNum}-${ep.episode_number}`, | |
| parentRatingKey: `${tmdbId}-${seasonNum}`, | |
| grandparentRatingKey: `tmdb-${tmdbId}`, | |
| grandparentTitle: showName || '', | |
| parentTitle: `Saison ${seasonNum}`, | |
| parentIndex: seasonNum, | |
| title: ep.name || `Épisode ${ep.episode_number}`, | |
| titleSort: ep.name, | |
| index: ep.episode_number, | |
| summary: ep.overview || '', | |
| originallyAvailableAt: ep.air_date || '', | |
| year: ep.air_date ? parseInt(ep.air_date) : undefined, | |
| rating: ep.vote_average ? parseFloat(ep.vote_average.toFixed(1)) : undefined, | |
| ratingImage: ep.vote_average ? 'themoviedb://image.rating' : undefined, | |
| audienceRating: ep.vote_average ? parseFloat(ep.vote_average.toFixed(1)) : undefined, | |
| audienceRatingImage: ep.vote_average ? 'themoviedb://image.rating' : undefined, | |
| duration: ep.runtime ? ep.runtime * 60000 : undefined, | |
| thumb: epThumb, | |
| grandparentThumb: showThumb, | |
| Image: [ | |
| ...(epThumb ? [{ type: 'snapshot', alt: ep.name, url: epThumb }] : []), | |
| ], | |
| Guid: [{ id: `tmdb://episode/${ep.id}` }], | |
| Rating: ep.vote_average ? [{ image: 'themoviedb://image.rating', type: 'audience', value: parseFloat(ep.vote_average.toFixed(1)) }] : [], | |
| Director: directors, | |
| Writer: writers, | |
| Producer: producers, | |
| Role: actors, | |
| }; | |
| } | |
| // Handlers | |
| async function getShow(tmdbId, lang) { | |
| const data = await tmdb(`/tv/${tmdbId}`, { | |
| language: lang, | |
| append_to_response: 'external_ids,content_ratings,aggregate_credits,images,keywords', | |
| include_image_language: `${lang.split('-')[0]},null,en`, | |
| }); | |
| if (!data) return null; | |
| // Récupérer les données OMDB en parallèle si on a un ID IMDB | |
| const imdbId = data.external_ids?.imdb_id; | |
| const omdb = await fetchOmdbData(imdbId); | |
| if (omdb) console.log(` → OMDB: IMDB ${omdb.imdbRating}, RT ${omdb.Ratings?.find(r => r.Source === 'Rotten Tomatoes')?.Value || 'N/A'}`); | |
| const meta = await mapShow(data, false, omdb); | |
| console.log(` → TMDB show "${data.name}" (${data.number_of_seasons} saisons, ${data.number_of_episodes} éps)`); | |
| return JSON.stringify(mc({ Metadata: [meta], size: 1, totalSize: 1 })); | |
| } | |
| async function getSeason(tmdbId, seasonNum, lang) { | |
| const [season, show] = await Promise.all([ | |
| tmdb(`/tv/${tmdbId}/season/${seasonNum}`, { | |
| language: lang, | |
| append_to_response: 'credits,images', | |
| }), | |
| tmdb(`/tv/${tmdbId}`, { language: lang, append_to_response: 'aggregate_credits,external_ids' }), | |
| ]); | |
| if (!season) return null; | |
| const thumb = img(season.poster_path); | |
| const showThumb = img(show?.poster_path); | |
| const showYear = show?.first_air_date ? parseInt(show.first_air_date) : undefined; | |
| const showCast = show?.aggregate_credits?.cast || []; | |
| const episodes = (season.episodes || []).map(ep => | |
| mapEpisode(ep, tmdbId, seasonNum, show?.name, showThumb, showCast) | |
| ); | |
| const seasonImages = [ | |
| ...(thumb ? [{ type: 'coverPoster', alt: season.name, url: thumb }] : []), | |
| ...((season.images?.posters || []).slice(1, 4).map(i => ({ type: 'coverPoster', url: img(i.file_path) }))), | |
| ]; | |
| const seasonMeta = { | |
| type: 'season', | |
| guid: `${PROXY_ID}://season/${tmdbId}-${seasonNum}`, | |
| ratingKey: `${tmdbId}-${seasonNum}`, | |
| key: `/library/metadata/${tmdbId}-${seasonNum}/children`, | |
| parentRatingKey: `tmdb-${tmdbId}`, | |
| parentTitle: show?.name || '', | |
| grandparentTitle: show?.name || '', | |
| title: season.name || `Saison ${seasonNum}`, | |
| titleSort: season.name, | |
| index: seasonNum, | |
| year: season.air_date ? parseInt(season.air_date) : showYear, | |
| originallyAvailableAt: season.air_date || '', | |
| summary: season.overview || '', | |
| leafCount: episodes.length, | |
| thumb, | |
| Image: seasonImages, | |
| Guid: [], | |
| Children: { Metadata: episodes }, | |
| }; | |
| console.log(` → TMDB saison ${seasonNum}: ${episodes.length} épisodes`); | |
| return JSON.stringify(mc({ Metadata: [seasonMeta], size: 1, totalSize: 1 })); | |
| } | |
| async function getEpisode(tmdbId, seasonNum, epNum, lang) { | |
| const [season, show] = await Promise.all([ | |
| tmdb(`/tv/${tmdbId}/season/${seasonNum}`, { | |
| language: lang, | |
| append_to_response: 'credits,images', | |
| }), | |
| tmdb(`/tv/${tmdbId}`, { language: lang, append_to_response: 'aggregate_credits,external_ids' }), | |
| ]); | |
| if (!season?.episodes) return null; | |
| const ep = season.episodes.find(e => e.episode_number === epNum); | |
| if (!ep) return null; | |
| const showCast = show?.aggregate_credits?.cast || []; | |
| const showThumb = img(show?.poster_path); | |
| const meta = mapEpisode(ep, tmdbId, seasonNum, show?.name, showThumb, showCast); | |
| console.log(` → TMDB épisode S${seasonNum}E${epNum}: ${ep.name}`); | |
| return JSON.stringify(mc({ Metadata: [meta], size: 1, totalSize: 1 })); | |
| } | |
| async function getImages(tmdbId, seasonNum, epNum, lang) { | |
| let data; | |
| const langPrefix = lang.split('-')[0]; | |
| if (epNum !== undefined) { | |
| data = await tmdb(`/tv/${tmdbId}/season/${seasonNum}/episode/${epNum}/images`, { | |
| include_image_language: `${langPrefix},null,en` | |
| }); | |
| } else if (seasonNum !== undefined) { | |
| data = await tmdb(`/tv/${tmdbId}/season/${seasonNum}/images`, { | |
| include_image_language: `${langPrefix},null,en` | |
| }); | |
| } else { | |
| data = await tmdb(`/tv/${tmdbId}/images`, { | |
| include_image_language: `${langPrefix},null,en` | |
| }); | |
| } | |
| if (!data) return null; | |
| const images = []; | |
| (data.posters || []).slice(0, 6).forEach(i => images.push({ type: 'coverPoster', url: img(i.file_path) })); | |
| (data.backdrops || []).slice(0, 6).forEach(i => images.push({ type: 'background', url: img(i.file_path, 'original') })); | |
| (data.logos || []).filter(i => i.file_path?.endsWith('.png')).slice(0, 3) | |
| .forEach(i => images.push({ type: 'clearLogo', url: img(i.file_path, 'original') })); | |
| (data.stills || []).slice(0, 6).forEach(i => images.push({ type: 'snapshot', url: img(i.file_path) })); | |
| return JSON.stringify(mc({ Image: images, size: images.length, totalSize: images.length })); | |
| } | |
| async function searchTmdb(body, lang) { | |
| const type = body.type || 2; | |
| // Match type 3 (saison) : on cherche le show parent puis retourne la bonne saison | |
| if (type === 3) { | |
| const title = body.parentTitle || body.title; | |
| const seasonNum = body.index; | |
| if (!title) return null; | |
| const data = await tmdb('/search/tv', { query: title, language: lang }); | |
| if (!data?.results?.length) return null; | |
| const show = data.results[0]; | |
| const seasonData = await tmdb(`/tv/${show.id}/season/${seasonNum || 1}`, { language: lang }); | |
| if (!seasonData) return null; | |
| const thumb = img(seasonData.poster_path) || img(show.poster_path); | |
| const meta = { | |
| type: 'season', | |
| guid: `${PROXY_ID}://season/${show.id}-${seasonData.season_number}`, | |
| ratingKey: `${show.id}-${seasonData.season_number}`, | |
| key: `/library/metadata/${show.id}-${seasonData.season_number}/children`, | |
| parentRatingKey: `tmdb-${show.id}`, | |
| parentTitle: show.name, | |
| title: seasonData.name, | |
| index: seasonData.season_number, | |
| year: seasonData.air_date ? parseInt(seasonData.air_date) : undefined, | |
| originallyAvailableAt: seasonData.air_date || '', | |
| summary: seasonData.overview || '', | |
| thumb, | |
| Image: thumb ? [{ type: 'coverPoster', alt: seasonData.name, url: thumb }] : [], | |
| Guid: [], | |
| score: 100, | |
| }; | |
| console.log(` → TMDB match saison: ${show.name} S${seasonData.season_number}`); | |
| return JSON.stringify(mc({ Metadata: [meta], size: 1, totalSize: 1 })); | |
| } | |
| // Match type 4 (épisode) : retourne l'épisode | |
| if (type === 4) { | |
| const title = body.grandparentTitle || body.parentTitle || body.title; | |
| const seasonNum = body.parentIndex || 1; | |
| const epNum = body.index || 1; | |
| if (!title) return null; | |
| const data = await tmdb('/search/tv', { query: title, language: lang }); | |
| if (!data?.results?.length) return null; | |
| const show = data.results[0]; | |
| const seasonData = await tmdb(`/tv/${show.id}/season/${seasonNum}`, { language: lang, append_to_response: 'credits' }); | |
| if (!seasonData?.episodes) return null; | |
| const ep = seasonData.episodes.find(e => e.episode_number === epNum); | |
| if (!ep) return null; | |
| const meta = mapEpisode(ep, show.id, seasonNum, show.name, img(show.poster_path)); | |
| meta.score = 100; | |
| console.log(` → TMDB match épisode: ${show.name} S${seasonNum}E${epNum}`); | |
| return JSON.stringify(mc({ Metadata: [meta], size: 1, totalSize: 1 })); | |
| } | |
| // Match type 2 (show) par GUID | |
| const guids = body.guids || (body.guid ? [body.guid] : []); | |
| for (const g of guids) { | |
| const tmdbId = await resolveGuid(g); | |
| if (tmdbId) { | |
| const show = await tmdb(`/tv/${tmdbId}`, { language: lang }); | |
| if (show) { | |
| const meta = await mapShow(show, true); | |
| meta.score = 100; | |
| meta.Children = buildSeasonChildren(show); | |
| console.log(` → TMDB match par GUID: ${show.name}`); | |
| return JSON.stringify(mc({ Metadata: [meta], size: 1, totalSize: 1 })); | |
| } | |
| } | |
| } | |
| // Match type 2 par titre | |
| const title = body.title || body.parentTitle || body.grandparentTitle; | |
| if (!title) return null; | |
| const year = body.year || body.parentYear; | |
| const data = await tmdb('/search/tv', { | |
| query: title, | |
| language: lang, | |
| ...(year ? { first_air_date_year: year } : {}), | |
| }); | |
| if (!data?.results?.length) return null; | |
| const limit = body.manual ? 8 : 1; | |
| const metadataPromises = data.results.slice(0, limit).map(async (show) => { | |
| const meta = await mapShow(show, true); | |
| if (!body.manual) { | |
| const details = await tmdb(`/tv/${show.id}`, { language: lang }); | |
| if (details) meta.Children = buildSeasonChildren(details); | |
| } | |
| return meta; | |
| }); | |
| const metadata = await Promise.all(metadataPromises); | |
| console.log(` → TMDB search ${title}: ${metadata.length} résultat(s) → ${metadata[0]?.title}`); | |
| return JSON.stringify(mc({ Metadata: metadata, size: metadata.length, totalSize: data.total_results })); | |
| } | |
| function buildSeasonChildren(show) { | |
| return { | |
| Metadata: (show.seasons || []).map(s => ({ | |
| type: 'season', | |
| guid: `${PROXY_ID}://season/${show.id}-${s.season_number}`, | |
| ratingKey: `${show.id}-${s.season_number}`, | |
| key: `/library/metadata/${show.id}-${s.season_number}/children`, | |
| parentRatingKey: `tmdb-${show.id}`, | |
| title: s.name, | |
| index: s.season_number, | |
| year: s.air_date ? parseInt(s.air_date) : undefined, | |
| originallyAvailableAt: s.air_date || '', | |
| leafCount: s.episode_count || 0, | |
| thumb: img(s.poster_path), | |
| Image: img(s.poster_path) ? [{ type: 'coverPoster', alt: s.name, url: img(s.poster_path) }] : [], | |
| Guid: [], | |
| })), | |
| }; | |
| } | |
| function parsePath(path) { | |
| let m = path.match(/\/metadata\/tmdb-(\d+)(\/|$)/); | |
| if (m) return { type: 'show', tmdbId: m[1] }; | |
| m = path.match(/\/metadata\/(\d+)-(\d+)-(\d+)(\/|$)/); | |
| if (m) return { type: 'episode', tmdbId: m[1], season: parseInt(m[2]), episode: parseInt(m[3]) }; | |
| m = path.match(/\/metadata\/(\d+)-(\d+)(\/|$)/); | |
| if (m) return { type: 'season', tmdbId: m[1], season: parseInt(m[2]) }; | |
| return null; | |
| } | |
| function rootResponse() { | |
| return JSON.stringify({ | |
| MediaProvider: { | |
| identifier: PROXY_ID, | |
| title: 'Fankai + TMDB Aggregator', | |
| version: '1.3.0', | |
| Feature: [ | |
| { key: '/library/metadata', type: 'metadata' }, | |
| { key: '/library/metadata/matches', type: 'match' }, | |
| ], | |
| Types: [ | |
| { | |
| type: 2, | |
| Scheme: [{ scheme: PROXY_ID }], | |
| Order: [ | |
| { key: 'tmdbAiring', title: 'The Movie Database (Aired)' }, | |
| { key: 'tvdbAiring', title: 'TheTVDB (Aired)' }, | |
| { key: 'tvdbDvd', title: 'TheTVDB (DVD)' }, | |
| { key: 'tvdbAbsolute', title: 'TheTVDB (Absolute)' }, | |
| ], | |
| }, | |
| { type: 3, Scheme: [{ scheme: PROXY_ID }] }, | |
| { type: 4, Scheme: [{ scheme: PROXY_ID }] }, | |
| ], | |
| } | |
| }); | |
| } | |
| function send(res, status, body) { | |
| res.writeHead(status, { | |
| 'Content-Type': 'application/json', | |
| 'Content-Length': Buffer.byteLength(body), | |
| }); | |
| res.end(body); | |
| } | |
| const server = http.createServer(async (req, res) => { | |
| const parsed = url.parse(req.url, true); | |
| const path = parsed.pathname; | |
| const query = parsed.query; | |
| console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); | |
| try { | |
| const requestBody = await readBody(req); | |
| const lang = (query['X-Plex-Language'] || 'fr-FR').split(',')[0].trim(); | |
| const isRoot = path === '/' || path === '/plex'; | |
| const isMatch = path.includes('/matches'); | |
| const isExtras = path.includes('/extras'); | |
| const isImages = path.includes('/images'); | |
| const tmdbInfo = parsePath(path); | |
| if (isRoot) { | |
| console.log(` → Racine`); | |
| return send(res, 200, rootResponse()); | |
| } | |
| // Appel upstream Fankai | |
| const qs = new URLSearchParams(query).toString(); | |
| const fullPath = qs ? `${path}?${qs}` : path; | |
| const parsedBase = new url.URL(FANKAI_BASE); | |
| const basePath = parsedBase.pathname.replace(/\/$/, ''); | |
| const upstreamUrl = `${parsedBase.protocol}//${parsedBase.hostname}${parsedBase.port ? ':' + parsedBase.port : ''}${basePath}${fullPath}`; | |
| console.log(` → Upstream: ${req.method} ${upstreamUrl}`); | |
| const fankai = await makeRequest(req.method, upstreamUrl, requestBody || null); | |
| console.log(` → Fankai ${fankai.status}: ${fankai.body.substring(0, 100)}`); | |
| const fankaiOk = !isFankaiError(fankai.status, fankai.body) && !isFankaiEmpty(fankai.body); | |
| // Match | |
| if (isMatch) { | |
| if (fankaiOk) { console.log(` → Fankai match OK`); return send(res, 200, fankai.body); } | |
| let body = {}; | |
| try { body = JSON.parse(requestBody); } catch { body = { title: query.title, year: query.year }; } | |
| console.log(` → TMDB fallback (type=${body.type}, title="${body.title || body.parentTitle}")`); | |
| return send(res, 200, await searchTmdb(body, lang) || empty()); | |
| } | |
| // Extras (toujours vide) | |
| if (isExtras) { | |
| return send(res, 200, empty()); | |
| } | |
| // Images | |
| if (isImages) { | |
| if (fankaiOk) return send(res, 200, fankai.body); | |
| if (tmdbInfo) { | |
| console.log(` → TMDB images ${tmdbInfo.type} ${tmdbInfo.tmdbId}`); | |
| return send(res, 200, await getImages(tmdbInfo.tmdbId, tmdbInfo.season, tmdbInfo.episode, lang) || emptyImages()); | |
| } | |
| return send(res, 200, emptyImages()); | |
| } | |
| // Metadata TMDB | |
| if (tmdbInfo) { | |
| if (fankaiOk) return send(res, 200, fankai.body); | |
| if (tmdbInfo.type === 'show') { | |
| console.log(` → TMDB show ${tmdbInfo.tmdbId}`); | |
| return send(res, 200, await getShow(tmdbInfo.tmdbId, lang) || empty()); | |
| } | |
| if (tmdbInfo.type === 'season') { | |
| console.log(` → TMDB saison ${tmdbInfo.tmdbId}-${tmdbInfo.season}`); | |
| return send(res, 200, await getSeason(tmdbInfo.tmdbId, tmdbInfo.season, lang) || empty()); | |
| } | |
| if (tmdbInfo.type === 'episode') { | |
| console.log(` → TMDB épisode ${tmdbInfo.tmdbId}-${tmdbInfo.season}-${tmdbInfo.episode}`); | |
| return send(res, 200, await getEpisode(tmdbInfo.tmdbId, tmdbInfo.season, tmdbInfo.episode, lang) || empty()); | |
| } | |
| } | |
| // Fallback | |
| if (fankaiOk) return send(res, 200, fankai.body); | |
| return send(res, 200, empty()); | |
| } catch (err) { | |
| console.error(` → Erreur: ${err.message}\n${err.stack}`); | |
| return send(res, 200, empty()); | |
| } | |
| }); | |
| server.listen(PORT, () => { | |
| console.log(`Fankai Proxy v1.3.0 démarré sur le port ${PORT}`); | |
| console.log(`Fankai upstream: ${FANKAI_BASE}`); | |
| console.log(`TMDB fallback: activé`); | |
| console.log(`Proxy identifier: ${PROXY_ID}`); | |
| }); |
Author
Author
docker-compose.yml :
services:
fankai-proxy:
build: .
container_name: fankai-proxy
restart: unless-stopped
ports:
- "11223:3000"
environment:
- FANKAI_URL=https://metadata.fankai.fr
- PLEX_URL=http://ip:port
- PLEX_TOKEN=NOPE
- PORT=3000
- TMDB_API_KEY=NOPE
- OMDB_API_KEY=NOPE
Author
- Définir l'ip du fankai-proxy en tant que nouveau fournisseur de méta-données
- Dans la partie agent du nouveau fournisseur, ajouter dans l'ordre Fan-Kai, puis Plex Series et Plex Local Media
- Refresh série ou bibliothèque
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Dockerfile :