Skip to content

Instantly share code, notes, and snippets.

@PierreDurrr
Created April 12, 2026 18:20
Show Gist options
  • Select an option

  • Save PierreDurrr/7c8065e92a5401f8697c39679b73c02f to your computer and use it in GitHub Desktop.

Select an option

Save PierreDurrr/7c8065e92a5401f8697c39679b73c02f to your computer and use it in GitHub Desktop.
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}`);
});
@PierreDurrr

PierreDurrr commented Apr 12, 2026

Copy link
Copy Markdown
Author

Dockerfile :

FROM node:20-alpine
WORKDIR /app
COPY proxy.js .
EXPOSE 3000
CMD ["node", "fankai-proxy.js"]

@PierreDurrr

Copy link
Copy Markdown
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

@PierreDurrr

PierreDurrr commented Apr 12, 2026

Copy link
Copy Markdown
Author
  1. Définir l'ip du fankai-proxy en tant que nouveau fournisseur de méta-données
  2. Dans la partie agent du nouveau fournisseur, ajouter dans l'ordre Fan-Kai, puis Plex Series et Plex Local Media
  3. Refresh série ou bibliothèque

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment