refacto: réécriture des textes, messages et commentaires

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 6a3cb21adf
commit 586eccbbb3
118 files changed
+1621 -2159

No files matched your search

+2 -6
View File
@@ -22,7 +22,6 @@ export function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS, fetchedAt: Date.now() })
}
// Date de la dernière lecture d'une clé
export function cacheFetchedAt(key: string): string | null {
const entry = _cache.get(key)
return entry && Date.now() <= entry.expiresAt ? new Date(entry.fetchedAt).toISOString() : null
@@ -32,10 +31,7 @@ export function cacheClear(): void { _cache.clear() }
export function cacheSize(): number { return _cache.size }
/**
* @param force Ignore le cache mémoire ET le cache CDN de raw.githubusercontent
* (~5 min).
*/
/** force : ignore le cache mémoire et celui du CDN de raw.githubusercontent (environ 5 min). */
export async function githubGet(urlPath: string, force = false): Promise<any> {
if (!force) {
const cached = cacheGet(urlPath)
@@ -44,7 +40,7 @@ export async function githubGet(urlPath: string, force = false): Promise<any> {
const url = force ? `${GITHUB_BASE}/${urlPath}?_=${Date.now()}` : `${GITHUB_BASE}/${urlPath}`
const init = force ? { cache: 'no-store' as const, headers: { 'Cache-Control': 'no-cache' } } : undefined
const res = await fetch(url, init)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
if (!res.ok) throw new Error(`GitHub : HTTP ${res.status} sur ${urlPath}`)
const data = await res.json()
cacheSet(urlPath, data)
return data
+7 -11
View File
@@ -1,7 +1,3 @@
/**
* Helpers pour l'API Jellyfin
* Utilisé pour la sync d'utilisateurs et la validation SSO
*/
import { readSettings } from '../settings.js'
import { logger } from '../logger.js'
@@ -39,7 +35,7 @@ export async function testJellyfinConnection(url: string, token: string): Promis
const res = await fetch(`${url.replace(/\/$/, '')}/System/Info`, {
headers: adminHeaders(token),
})
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }
if (!res.ok) return { ok: false, error: `Jellyfin a répondu HTTP ${res.status} : vérifiez l'URL et la clé API` }
const data = await res.json()
return { ok: true, version: data.Version }
} catch (err) {
@@ -51,15 +47,15 @@ export async function testJellyfinConnection(url: string, token: string): Promis
export async function fetchJellyfinUsers(): Promise<JellyfinUser[]> {
const { jellyfinUrl, jellyfinAdminToken } = readSettings()
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou token manquant)')
if (!jellyfinUrl || !jellyfinAdminToken) throw new Error('Jellyfin non configuré (URL ou clé API manquante)')
const base = jellyfinUrl.replace(/\/$/, '')
const res = await fetch(`${base}/Users`, { headers: adminHeaders(jellyfinAdminToken) })
if (!res.ok) throw new Error(`Jellyfin API ${res.status} : impossible de récupérer les utilisateurs`)
if (!res.ok) throw new Error(`Impossible de récupérer les utilisateurs Jellyfin (HTTP ${res.status})`)
return res.json()
}
// ── Validation d'un token utilisateur Jellyfin (SSO) ──────────
// ── Validation d'un jeton utilisateur Jellyfin ────────────────
export interface JellyfinTokenInfo {
valid : boolean
@@ -77,13 +73,13 @@ export async function validateJellyfinToken(jellyfinUserId: string, jellyfinToke
const res = await fetch(`${base}/Users/${jellyfinUserId}`, {
headers: userHeaders(jellyfinToken),
})
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Token Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin API ${res.status}` }
if (res.status === 401 || res.status === 403) return { valid: false, error: 'Jeton Jellyfin invalide ou expiré' }
if (!res.ok) return { valid: false, error: `Jellyfin a répondu HTTP ${res.status}` }
const user: JellyfinUser = await res.json()
if (user.Policy?.IsDisabled) return { valid: false, error: 'Compte Jellyfin désactivé' }
logger.debug('jellyfin', `Token valide pour "${user.Name}" (${user.Id.slice(0, 8)}…)`)
logger.debug('jellyfin', `Jeton valide pour « ${user.Name} » (${user.Id.slice(0, 8)}…)`)
return { valid: true, userId: user.Id, username: user.Name }
} catch (err) {
return { valid: false, error: err instanceof Error ? err.message : 'Erreur réseau' }
+4 -4
View File
@@ -57,7 +57,7 @@ export async function downloadNfoFolder(gitlabTitle: string, destRoot: string):
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
logger.info('nfo-update', `NFO de « ${gitlabTitle} » : ${downloaded} fichier(s) mis à jour, ${skipped} échec(s)`)
}
export async function checkNfoUpdates() {
@@ -82,15 +82,15 @@ export async function checkNfoUpdates() {
const gitlabTitle = getGitlabTitle(serieTitle)
const latest = await getLatestGitlabCommit(gitlabTitle)
if (!latest || commits[gitlabTitle] === latest.sha) continue
logger.info('nfo-update', `MAJ NFO détectée pour "${gitlabTitle}" (${latest.sha.slice(0, 8)})`)
logger.info('nfo-update', `Mise à jour des NFO disponible pour « ${gitlabTitle} » (${latest.sha.slice(0, 8)})`)
try {
await downloadNfoFolder(gitlabTitle, path.join(mediaPath, serieTitle))
commits[gitlabTitle] = latest.sha
hasChanges = true
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'updated' })
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('nfo-update', `Échec MAJ NFO "${gitlabTitle}" : ${msg}`)
const msg = err instanceof Error ? err.message : 'Erreur inattendue, consultez les journaux'
logger.error('nfo-update', `Échec de la mise à jour des NFO de « ${gitlabTitle} » : ${msg}`)
recentNfoUpdates.unshift({ serieTitle, commitSha: latest.sha.slice(0, 8), commitMsg: latest.message, updatedAt: new Date().toISOString(), status: 'error', error: msg })
}
if (recentNfoUpdates.length > MAX_NFO_NOTIFS) recentNfoUpdates.pop()
+2 -2
View File
@@ -13,8 +13,8 @@ export function readOrganized(file = ORGANIZED_PATH): Organized {
const raw = fs.readFileSync(file, 'utf-8')
let data: unknown
try { data = JSON.parse(raw) }
catch (err) { throw new Error(`organized.json illisible (${raw.length} octets) : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('organized.json : contenu inattendu')
catch (err) { throw new Error(`Fichier de suivi des imports (organized.json) corrompu : ${err instanceof Error ? err.message : err}`) }
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('Fichier de suivi des imports (organized.json) corrompu : contenu inattendu')
return data as Organized
}
+1 -14
View File
@@ -1,9 +1,6 @@
export type RequestTorrent = { url: string; magnet: string | null; infohash: string | null; file_index?: number | null; file_path?: string | null }
/**
* Torrents à envoyer pour une portée de demande.
* episodes non vide → épisodes ciblés ; sinon seasons non vide → saisons ciblées ; sinon toute la série puis voila
*/
/** Torrents à envoyer : épisodes ciblés, sinon saisons ciblées, sinon toute la série. */
export function resolveRequestTorrents(serieData: any, seasons: number[], episodes: number[]): RequestTorrent[] {
const toDownload: RequestTorrent[] = []
const seenHashes = new Set<string>()
@@ -20,16 +17,11 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
toDownload.push({ url: t.torrent_url, magnet: t.magnet ?? null, infohash: t.infohash ?? null, file_index: file_index ?? null, file_path: file_path ?? null })
}
/**
* Ajoute le premier torrent disponible par épisode
*/
function addEpisodesByIndex(epList: any[]) {
for (const ep of epList) {
if (ep.torrents?.length > 0) {
// Premier torrent individuel disponible
addTorrent(ep.torrents[0])
} else if (ep.paths?.length > 0) {
// Premier path dans un pack
const p = ep.paths[0]
const pack = allPackTorrents.find((t: any) => t.infohash?.toLowerCase() === p.infohash?.toLowerCase())
if (pack) addTorrent(pack, p.file_index ?? null, p.path ?? null)
@@ -48,10 +40,8 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
for (const season of serieData.seasons ?? []) {
if (!seasons.includes(season.season_number)) continue
if ((season.torrents ?? []).length > 0) {
// Premier pack saison disponible → télécharger en bloc
addTorrent(season.torrents[0])
} else {
// Pas de pack → épisode par épisode avec file_index
addEpisodesByIndex(season.episodes ?? [])
}
}
@@ -60,12 +50,10 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
if ((serieData.torrents ?? []).length > 0) {
addTorrent(serieData.torrents[0])
} else if ((serieData.seasons ?? []).some((s: any) => s.torrents?.length > 0)) {
// Packs saison (premier par saison)
for (const season of serieData.seasons ?? []) {
if (season.torrents?.length > 0) addTorrent(season.torrents[0])
}
} else {
// Fallback : épisode par épisode avec file_index
for (const season of serieData.seasons ?? []) {
addEpisodesByIndex(season.episodes ?? [])
}
@@ -75,7 +63,6 @@ export function resolveRequestTorrents(serieData: any, seasons: number[], episod
return toDownload
}
// Épisodes uniques concernés par la même portée
export function countRequestedEpisodes(serieData: any, seasons: number[], episodes: number[]): number {
const keys = new Set<string>()
for (const season of serieData.seasons ?? []) {
+15 -28
View File
@@ -1,11 +1,4 @@
/**
* RSS Sync — auto-téléchargement des épisodes sortis après l'activation de la surveillance.
*
* Toutes les 6 heures, FanKarr cherche les épisodes dont le champ `date_added`
* (date d'ajout dans la base metadata) est postérieur à la date d'activation
* du sync pour cette série. Si ces épisodes ont un torrent fankai et ne sont
* pas encore organisés, ils sont envoyés automatiquement au client torrent.
*/
/** Surveillance : envoie au client les épisodes ajoutés au catalogue après activation (toutes les 6 h). */
import path from 'path'
import fs from 'fs'
@@ -21,7 +14,7 @@ import { readSettings } from '../settings.js'
export interface SyncedSerie {
serieId : number
serieName: string
addedAt : string // ISO — date d'activation de la surveillance
addedAt : string // date ISO d'activation de la surveillance
}
type SyncedMap = Record<number, SyncedSerie>
@@ -73,8 +66,7 @@ export function isSynced(serieId: number): boolean {
const normalizeName = (name: string) =>
name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
// Série recréée sous un nouvel ID avec le même nom : la surveillance suit.
// Renvoie les surveillances dont la série n'existe plus dans le catalogue.
// Série recréée sous un nouvel ID et même nom : la surveillance suit ; renvoie celles sans série
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
const map = loadSynced()
const ids = new Set(catalog.map(sd => sd.id))
@@ -86,7 +78,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
if (matches.length === 1 && !map[matches[0].id]) {
delete map[entry.serieId]
map[matches[0].id] = { ...entry, serieId: matches[0].id, serieName: matches[0].title }
logger.info('rss-sync', `Surveillance de "${entry.serieName}" déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
logger.info('rss-sync', `Surveillance de « ${entry.serieName} » déplacée de la série ${entry.serieId} vers ${matches[0].id}`)
changed = true
} else {
orphans.push(entry)
@@ -96,7 +88,7 @@ export function reconcileSynced(catalog: any[]): SyncedSerie[] {
return orphans
}
// ── Helpers ───────────────────────────────────────────────────
// ── Épisodes déjà importés ────────────────────────────────────
function readOrganized(): Record<string, Record<string, any>> {
try {
@@ -144,7 +136,7 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
return ids
}
// ── Logique de sync ───────────────────────────────────────────
// ── Vérification des séries surveillées ───────────────────────
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
@@ -154,7 +146,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const synced = loadSynced()
const ids = Object.keys(synced).map(Number)
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
logger.info('rss-sync', `Surveillance : vérification de ${ids.length} série(s)`)
const organized = readOrganized()
@@ -176,7 +168,7 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const syncedEntry = synced[serieId]
const sd = seriesData.find((s: any) => s.id === serieId)
if (!sd) {
logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
logger.warn('rss-sync', `Série ${serieId} (« ${syncedEntry.serieName} ») absente du catalogue : retirez la surveillance dans Paramètres › Catalogue`)
continue
}
@@ -184,9 +176,8 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const activatedAt = new Date(syncedEntry.addedAt)
const organizedIds = getOrganizedEpisodeIds(sd, organized)
logger.info('rss-sync', `Vérification : ${title} (sync activé le ${activatedAt.toLocaleDateString('fr-FR')})`)
logger.info('rss-sync', `Vérification de « ${title} » (surveillée depuis le ${activatedAt.toLocaleDateString('fr-FR')})`)
// Épisodes candidats : date_added > date d'activation ET pas encore organisés
const candidateIds = new Set<number>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -194,7 +185,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
const epDate = new Date(ep.date_added)
if (epDate <= activatedAt) continue
if (organizedIds.has(ep.id)) continue
// Doit avoir au moins un torrent fankai
const hasTorrent = (ep.torrents ?? []).some((t: any) => t.fankai && (t.torrent_url || t.magnet))
if (!hasTorrent) continue
candidateIds.add(ep.id)
@@ -202,17 +192,15 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
if (candidateIds.size === 0) {
logger.info('rss-sync', `${title} — aucun nouvel épisode depuis l'activation`)
logger.info('rss-sync', `« ${title} » : aucun nouvel épisode depuis l'activation`)
continue
}
logger.info('rss-sync', `${title} — ${candidateIds.size} nouvel(s) épisode(s) à télécharger`)
logger.info('rss-sync', `« ${title} » : ${candidateIds.size} épisode(s) à télécharger`)
// Construire les téléchargements : pack saison si couvre des candidats, sinon épisode par épisode
const toDownload: { url: string; file_index?: number | null; file_path?: string | null; infohash?: string | null; label: string }[] = []
const coveredIds = new Set<number>()
// 1. Packs saison couvrant au moins un candidat
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (!t.fankai) continue
@@ -231,7 +219,6 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
}
}
// 2. Épisodes individuels candidats non couverts par un pack
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (!candidateIds.has(ep.id) || coveredIds.has(ep.id)) continue
@@ -265,19 +252,19 @@ export async function runRssSync(): Promise<{ sent: number; skipped: number; err
infohash : dl.infohash,
})
if (results.some((r: any) => r.ok)) {
logger.info('rss-sync', `Envoyé : ${dl.label}`)
logger.info('rss-sync', `Envoyé au client : ${dl.label}`)
sent++
} else {
logger.warn('rss-sync', `Échec envoi : ${dl.label}`)
logger.warn('rss-sync', `Échec de l'envoi : ${dl.label}`)
errors++
}
} catch (err) {
logger.error('rss-sync', `Erreur dispatch ${dl.label} : ${err instanceof Error ? err.message : err}`)
logger.error('rss-sync', `Erreur à l'envoi de ${dl.label} : ${err instanceof Error ? err.message : err}`)
errors++
}
}
}
logger.info('rss-sync', `Sync terminé — ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
logger.info('rss-sync', `Surveillance terminée : ${sent} envoyé(s), ${skipped} ignoré(s), ${errors} erreur(s)`)
return { sent, skipped, errors }
}
-1
View File
@@ -1,4 +1,3 @@
// Cadence de l'import automatique
export const AUTO_IMPORT_INTERVAL_MS = 5 * 60_000
export const autoImportSchedule = {
+5 -23
View File
@@ -6,7 +6,7 @@ const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
if (!res.ok) throw new Error(`API Fankai : HTTP ${res.status} sur ${endpoint}`)
return res.json()
}
@@ -48,15 +48,14 @@ export async function resolveSerieData(serieId: number, action?: string): Promis
if (action) logger.warn('api', `${action} : série ${serieId} absente du scraper (${scraperError}), données reprises de l'API Fankai`)
return sd
} catch (err) {
logger.error('api', `${action ?? 'Lecture série'} : série ${serieId} introuvable — scraper (${scraperError}), API Fankai (${errorMessage(err)})`)
logger.error('api', `${action ?? 'Lecture de la série'} : série ${serieId} introuvable (scraper : ${scraperError}, API Fankai : ${errorMessage(err)})`)
return null
}
}
let lastCatalogWarn = 0
// Catalogue du scraper complété par l'API Fankai : titres à jour et séries pas encore scrapées.
// complete = false si une source a échoué : ne rien supprimer sur la base d'une absence dans ce cas.
// Scraper complété par l'API Fankai ; complete = false si une source a échoué (ne rien supprimer alors)
export async function loadCatalogStatus(force = false): Promise<{ series: any[]; complete: boolean }> {
const [seriesData, availableIds] = await Promise.all([loadEnrichedSeriesData(force), readAvailable(force)])
const scraperComplete = availableIds.length > 0 && seriesData.length === availableIds.length
@@ -121,14 +120,6 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
@@ -145,10 +136,6 @@ export function resolveEpNaming(ep: any, hash?: string | null): {
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
export function computeExpectedName(
ep : any,
orgEntry: any,
@@ -164,11 +151,7 @@ export function computeExpectedName(
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
/** Dédoublonne par episode_number : la première variante (x264, x265…) garde les torrents des autres. */
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
@@ -226,8 +209,7 @@ export function computeSerieDownloadState(
}
export function countOrganizedEpisodes(serieData: any, organized: Record<string, Record<string, any>>): { total: number; organized: number } {
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
// Épisodes uniques par (saison, numéro) : x264 et x265 comptent pour un
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {