feat(import): catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage
This commit is contained in:
1 parent
67794d92c8
commit
0ff7f21958
28 files changed
+806
-152
No files matched your search
+5
-4
@@ -17,7 +17,8 @@ import delugeDriver from './torrent-clients/deluge.js'
|
||||
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from './organize.js'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
|
||||
import { readAvailable, readInfohashMap } from './lib/github-cache.js'
|
||||
import { loadCatalog } from './lib/serie-helpers.js'
|
||||
import { ORGANIZED_PATH, readOrganized, writeOrganized } from './lib/organized-store.js'
|
||||
import { pushNotif } from './lib/notifs.js'
|
||||
import { readRequests, completeRequest } from './requests.js'
|
||||
@@ -175,7 +176,7 @@ server.listen(PORT, async () => {
|
||||
logger.warn('api', `Migration dest_dir échouée : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
loadEnrichedSeriesData()
|
||||
loadCatalog()
|
||||
.then(async seriesData => {
|
||||
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
|
||||
dedupeOrganizedEpisodes(ORGANIZED_PATH)
|
||||
@@ -187,7 +188,7 @@ server.listen(PORT, async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const infohashMap = await readInfohashMap()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai', infohashMap),
|
||||
seriesData,
|
||||
@@ -242,7 +243,7 @@ server.listen(PORT, async () => {
|
||||
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
if (updated > 0)
|
||||
|
||||
@@ -26,6 +26,22 @@ export function writeOrganized(data: Organized, file = ORGANIZED_PATH): void {
|
||||
renameWithRetry(tmp, file)
|
||||
}
|
||||
|
||||
// Répercute des déplacements de fichiers sur toutes les entrées qui les suivent, tous hashes confondus
|
||||
export function retargetEntries(data: Organized, moves: Map<string, string>): number {
|
||||
let updated = 0
|
||||
for (const eps of Object.values(data)) {
|
||||
for (const entry of Object.values(eps)) {
|
||||
const to = entry?.dest_path ? moves.get(entry.dest_path) : undefined
|
||||
if (!to) continue
|
||||
entry.dest_path = to
|
||||
entry.dest_filename = path.basename(to)
|
||||
entry.dest_dir = path.dirname(to)
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
// Lecture-modification-écriture synchrone ; fn renvoie false pour ne rien écrire
|
||||
export function updateOrganized(fn: (data: Organized) => boolean | void, file = ORGANIZED_PATH): void {
|
||||
const data = readOrganized(file)
|
||||
|
||||
+33
-4
@@ -11,7 +11,8 @@ import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadEnrichedSeriesData, readInfohashMap } from './github-cache.js'
|
||||
import { readInfohashMap } from './github-cache.js'
|
||||
import { loadCatalogStatus } from './serie-helpers.js'
|
||||
import { dispatchDownload, dispatchList } from '../torrent-clients/index.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
|
||||
@@ -69,6 +70,32 @@ export function isSynced(serieId: number): boolean {
|
||||
return !!loadSynced()[serieId]
|
||||
}
|
||||
|
||||
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.
|
||||
export function reconcileSynced(catalog: any[]): SyncedSerie[] {
|
||||
const map = loadSynced()
|
||||
const ids = new Set(catalog.map(sd => sd.id))
|
||||
const orphans: SyncedSerie[] = []
|
||||
let changed = false
|
||||
for (const entry of Object.values(map)) {
|
||||
if (ids.has(entry.serieId)) continue
|
||||
const matches = catalog.filter(sd => normalizeName(sd.title ?? '') === normalizeName(entry.serieName))
|
||||
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}`)
|
||||
changed = true
|
||||
} else {
|
||||
orphans.push(entry)
|
||||
}
|
||||
}
|
||||
if (changed) saveSynced(map)
|
||||
return orphans
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function readOrganized(): Record<string, Record<string, any>> {
|
||||
@@ -119,14 +146,16 @@ function getOrganizedEpisodeIds(sd: any, organized: Record<string, Record<string
|
||||
// ── Logique de sync ───────────────────────────────────────────
|
||||
|
||||
export async function runRssSync(): Promise<{ sent: number; skipped: number; errors: number }> {
|
||||
if (Object.keys(loadSynced()).length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
if (complete) reconcileSynced(seriesData)
|
||||
const synced = loadSynced()
|
||||
const ids = Object.keys(synced).map(Number)
|
||||
if (ids.length === 0) return { sent: 0, skipped: 0, errors: 0 }
|
||||
|
||||
logger.info('rss-sync', `Démarrage sync — ${ids.length} série(s) surveillée(s)`)
|
||||
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
|
||||
const activeHashes = new Set<string>()
|
||||
try {
|
||||
@@ -146,7 +175,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} introuvable dans le cache`)
|
||||
logger.warn('rss-sync', `Série ${serieId} ("${syncedEntry.serieName}") introuvable dans le catalogue — surveillance à retirer depuis Paramètres › Catalogue`)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
+49
-10
@@ -1,6 +1,6 @@
|
||||
import path from 'path'
|
||||
import { logger } from '../logger.js'
|
||||
import { githubGet } from './github-cache.js'
|
||||
import { githubGet, loadEnrichedSeriesData, readAvailable } from './github-cache.js'
|
||||
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
|
||||
@@ -26,25 +26,64 @@ export function serieHasEpisodes(sd: any): boolean {
|
||||
return (sd?.seasons ?? []).some((s: any) => (s.episodes ?? []).length > 0)
|
||||
}
|
||||
|
||||
// Repli sur l'API Fankai quand le scraper n'a pas (encore) la série
|
||||
export function serieFolderName(title: string): string {
|
||||
return title.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Même règle que seasonFolder() du worker
|
||||
export function seasonFolderName(seasonNumber: number, englishDirectory: boolean): string {
|
||||
return seasonNumber === 0 ? 'Specials' : (englishDirectory ? `Season ${String(seasonNumber).padStart(2, '0')}` : `Saison ${seasonNumber}`)
|
||||
}
|
||||
|
||||
const errorMessage = (err: unknown) => err instanceof Error ? err.message : String(err)
|
||||
|
||||
// Données scraper avec le titre à jour de l'API, ou repli complet sur l'API quand le scraper n'a pas (encore) la série
|
||||
export async function resolveSerieData(serieId: number, action?: string): Promise<any | null> {
|
||||
let scraperError = 'aucun épisode'
|
||||
try {
|
||||
const sd = await githubGet(`series/${serieId}.json`)
|
||||
if (serieHasEpisodes(sd)) return sd
|
||||
} catch (err) {
|
||||
scraperError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
const [scraper, api] = await Promise.allSettled([githubGet(`series/${serieId}.json`), fankaiGet(`/series/${serieId}`)])
|
||||
if (scraper.status === 'fulfilled' && serieHasEpisodes(scraper.value))
|
||||
return api.status === 'fulfilled' && api.value?.title ? { ...scraper.value, title: api.value.title } : scraper.value
|
||||
const scraperError = scraper.status === 'rejected' ? errorMessage(scraper.reason) : 'aucun épisode'
|
||||
try {
|
||||
const sd = await fetchSerieFromApi(serieId)
|
||||
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 (${err instanceof Error ? err.message : err})`)
|
||||
logger.error('api', `${action ?? 'Lecture 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.
|
||||
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
|
||||
let apiSeries: any[]
|
||||
try {
|
||||
const data = await fankaiGet('/series')
|
||||
apiSeries = Array.isArray(data) ? data : (data.series ?? [])
|
||||
} catch (err) {
|
||||
if (Date.now() - lastCatalogWarn > 60 * 60_000) {
|
||||
lastCatalogWarn = Date.now()
|
||||
logger.warn('api', `Catalogue : API Fankai injoignable, titres du scraper conservés (${errorMessage(err)})`)
|
||||
}
|
||||
return { series: seriesData, complete: false }
|
||||
}
|
||||
const titles = new Map<number, string>(apiSeries.filter(s => s.title).map(s => [s.id, s.title]))
|
||||
const catalog = seriesData.map(sd => titles.has(sd.id) ? { ...sd, title: titles.get(sd.id) } : sd)
|
||||
// Scraper vide = injoignable : ne pas interroger l'API série par série
|
||||
if (seriesData.length === 0) return { series: catalog, complete: false }
|
||||
const known = new Set(seriesData.map(sd => sd.id))
|
||||
const missing = await Promise.all(apiSeries.filter(s => !known.has(s.id)).map(s => fetchSerieFromApi(s.id).catch(() => null)))
|
||||
return { series: [...catalog, ...missing.filter(Boolean)], complete: scraperComplete && missing.every(Boolean) }
|
||||
}
|
||||
|
||||
export async function loadCatalog(force = false): Promise<any[]> {
|
||||
return (await loadCatalogStatus(force)).series
|
||||
}
|
||||
|
||||
export function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
|
||||
+14
-6
@@ -22,18 +22,26 @@ export interface LogEntry {
|
||||
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const ROTATE_SLACK = 200 // marge avant rotation, pour ne pas réécrire le fichier à chaque ligne
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
// ── Écriture ──────────────────────────────────────────────────
|
||||
|
||||
let lineCount: number | null = null
|
||||
|
||||
function countLines(): number {
|
||||
if (!fs.existsSync(LOGS_PATH)) return 0
|
||||
return fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).length
|
||||
}
|
||||
|
||||
function writeLine(entry: LogEntry) {
|
||||
try {
|
||||
lineCount ??= countLines()
|
||||
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
// Rotation si trop grand
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
const lines = content.split('\n').filter(Boolean)
|
||||
if (lines.length > MAX_LINES) {
|
||||
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
|
||||
if (++lineCount > MAX_LINES + ROTATE_SLACK) {
|
||||
const lines = fs.readFileSync(LOGS_PATH, 'utf-8').split('\n').filter(Boolean).slice(-MAX_LINES)
|
||||
fs.writeFileSync(LOGS_PATH, lines.join('\n') + '\n', 'utf-8')
|
||||
lineCount = lines.length
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -98,7 +106,7 @@ export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||
}
|
||||
|
||||
export function clearLogs(): void {
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8'); lineCount = 0 } catch {}
|
||||
}
|
||||
|
||||
export function logsFileSize(): number {
|
||||
|
||||
+26
-16
@@ -9,7 +9,7 @@ import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { readSettings } from './settings.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, type Organized } from './lib/organized-store.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from './lib/organized-store.js'
|
||||
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
@@ -171,6 +171,16 @@ export async function scanMediaPath(
|
||||
const presentFiles = new Set<string>()
|
||||
let noMatch = 0
|
||||
|
||||
const trackedByPath = new Map<string, string[]>()
|
||||
for (const [hash, episodes] of Object.entries(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(episodes)) {
|
||||
if (!entry?.dest_path) continue
|
||||
const keys = trackedByPath.get(entry.dest_path) ?? []
|
||||
keys.push(`${hash}:${episodeId}`)
|
||||
trackedByPath.set(entry.dest_path, keys)
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
@@ -181,6 +191,14 @@ export async function scanMediaPath(
|
||||
walk(full)
|
||||
} else if (entry.isFile() && /\.(mkv|mp4|avi|m4v|mov|wmv)$/i.test(entry.name)) {
|
||||
result.found++
|
||||
|
||||
// Fichier déjà suivi (sous n'importe quel hash) : ne pas créer d'entrée en double
|
||||
const tracked = trackedByPath.get(full)
|
||||
if (tracked) {
|
||||
for (const key of tracked) presentFiles.add(key)
|
||||
continue
|
||||
}
|
||||
|
||||
const nameWithoutExt = entry.name.replace(/\.[^.]+$/, '')
|
||||
let match = filenameIndex.get(nameWithoutExt)
|
||||
|
||||
@@ -460,8 +478,7 @@ export async function migrateOrganizedEpisodeIds(
|
||||
return { updated, orphaned }
|
||||
}
|
||||
|
||||
// ── Déduplication : un seul enregistrement par épisode ────────
|
||||
|
||||
// ── Déduplication ─────────────────────────────────────────────
|
||||
export function dedupeOrganizedEpisodes(
|
||||
organizedPath: string,
|
||||
): { removed: number } {
|
||||
@@ -485,22 +502,14 @@ export function dedupeOrganizedEpisodes(
|
||||
for (const [episodeId, candidates] of byEpisode) {
|
||||
if (candidates.length < 2) continue
|
||||
|
||||
const score = (c: { entry: any }) => {
|
||||
const exists = c.entry?.dest_path ? fs.existsSync(c.entry.dest_path) : false
|
||||
const at = Date.parse(c.entry?.at ?? '') || 0
|
||||
return { exists, at }
|
||||
}
|
||||
const sorted = [...candidates].sort((a, b) => {
|
||||
const sa = score(a), sb = score(b)
|
||||
if (sa.exists !== sb.exists) return sa.exists ? -1 : 1
|
||||
return sb.at - sa.at
|
||||
})
|
||||
const present = candidates.filter(c => c.entry?.dest_path && fs.existsSync(c.entry.dest_path))
|
||||
if (present.length === 0) continue
|
||||
|
||||
const keep = sorted[0]
|
||||
for (const c of sorted.slice(1)) {
|
||||
for (const c of candidates) {
|
||||
if (present.includes(c)) continue
|
||||
delete (organized[c.hash] as any)[episodeId]
|
||||
removed++
|
||||
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (conservé sous ${keep.hash.slice(0, 8)}…)`)
|
||||
logger.info('organize', `Dédoublonnage : ep ${episodeId} retiré de ${c.hash.slice(0, 8)}… (fichier absent, conservé sous ${present[0].hash.slice(0, 8)}…)`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +585,7 @@ export async function syncFilenameChanges(
|
||||
dest_path : newPath,
|
||||
at : new Date().toISOString(),
|
||||
}
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
renamed++
|
||||
changed = true
|
||||
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
|
||||
|
||||
+174
-20
@@ -5,24 +5,82 @@ import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { getGitlabTitle } from '../gitlab-map.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { resolveEpNaming, computeExpectedName, resolveSerieData } from '../lib/serie-helpers.js'
|
||||
import {
|
||||
resolveEpNaming, computeExpectedName, resolveSerieData, loadCatalog, loadCatalogStatus,
|
||||
serieFolderName, seasonFolderName,
|
||||
} from '../lib/serie-helpers.js'
|
||||
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
|
||||
import { dispatchRemove } from '../torrent-clients/index.js'
|
||||
import { readRequests, deleteRequest } from '../requests.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, type Organized } from '../lib/organized-store.js'
|
||||
import { readOrganized, writeOrganized, updateOrganized, retargetEntries, type Organized } from '../lib/organized-store.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
const serieNotFound = (id: unknown) => `Série ${id} introuvable dans le scraper et sur l'API Fankai (voir les logs)`
|
||||
|
||||
// Entrée d'un épisode sous n'importe quel hash (torrent absent des paths du scraper)
|
||||
function findEntry(organized: Organized, episodeId: number): { hash: string; entry: any } | null {
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
if (eps[String(episodeId)]) return { hash, entry: eps[String(episodeId)] }
|
||||
return null
|
||||
}
|
||||
|
||||
// Entrées dont l'épisode n'existe plus dans le catalogue
|
||||
function findOrphans(organized: Organized, catalog: any[]) {
|
||||
const known = new Set<string>()
|
||||
for (const sd of catalog)
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) known.add(String(ep.id))
|
||||
const orphans: { hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null }[] = []
|
||||
for (const [hash, eps] of Object.entries(organized))
|
||||
for (const [episodeId, entry] of Object.entries(eps))
|
||||
if (!known.has(episodeId))
|
||||
orphans.push({ hash, episode_id: Number(episodeId), season: entry?.season ?? null, episode: entry?.episode ?? null, dest_path: entry?.dest_path ?? null })
|
||||
return orphans
|
||||
}
|
||||
|
||||
// Dossiers série (enfants directs de la médiathèque) contenant les fichiers suivis d'une série
|
||||
function serieFolders(sd: any, organized: Organized, mediaPath: string): Map<string, number> {
|
||||
const ids = new Set<string>()
|
||||
for (const season of sd.seasons ?? [])
|
||||
for (const ep of season.episodes ?? []) ids.add(String(ep.id))
|
||||
const folders = new Map<string, number>()
|
||||
if (!mediaPath) return folders
|
||||
for (const eps of Object.values(organized)) {
|
||||
for (const [episodeId, entry] of Object.entries(eps)) {
|
||||
if (!ids.has(episodeId) || !entry?.dest_path) continue
|
||||
const parts = path.relative(mediaPath, entry.dest_path).split(path.sep)
|
||||
if (parts.length < 2 || parts[0] === '..' || path.isAbsolute(parts[0])) continue
|
||||
const folder = path.join(mediaPath, parts[0])
|
||||
folders.set(folder, (folders.get(folder) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return folders
|
||||
}
|
||||
|
||||
function listFiles(dir: string): string[] {
|
||||
const files: string[] = []
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) files.push(...listFiles(full))
|
||||
else files.push(full)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function removeEmptyDirs(dir: string): void {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }))
|
||||
if (entry.isDirectory()) removeEmptyDirs(path.join(dir, entry.name))
|
||||
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
|
||||
}
|
||||
|
||||
// ── Import manuel ──────────────────────────────────────────────
|
||||
router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const { serie_id, items } = req.body
|
||||
if (!serie_id || !Array.isArray(items) || items.length === 0) {
|
||||
res.status(400).json({ error: 'serie_id et items requis' }); return
|
||||
}
|
||||
const { mediaPath, organizeMode, nfoSupport } = readSettings()
|
||||
const { mediaPath, organizeMode, nfoSupport, englishDirectory } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
@@ -31,8 +89,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const apply = (op: (data: Organized) => void) => { op(organized); ops.push(op) }
|
||||
const sd = await resolveSerieData(Number(serie_id), 'Import manuel')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serie_id) }); return }
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const serieTitle = serieFolderName(sd.title ?? sd.show_title ?? '')
|
||||
const episodeIndex = new Map<number, { ep: any; season: any }>()
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -58,8 +115,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
const destFilename = resolvedExt && resolvedExt !== srcExt
|
||||
? resolvedName.slice(0, -resolvedExt.length) + srcExt
|
||||
: resolvedName
|
||||
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolderName(season.season_number, englishDirectory))
|
||||
const destPath = path.join(destDir, destFilename)
|
||||
try {
|
||||
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
|
||||
@@ -102,6 +158,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
}
|
||||
if (isInSeriePath && !fs.existsSync(destPath)) {
|
||||
fs.renameSync(file_path, destPath)
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
logger.info('api', `Import manuel (rename) : "${srcFilename}" → "${destFilename}"`)
|
||||
} else if (!isInSeriePath && !fs.existsSync(destPath)) {
|
||||
if (organizeMode === 'hardlink') {
|
||||
@@ -110,6 +167,7 @@ router.post('/manual-import', requireAuth, async (req, res) => {
|
||||
} else if (organizeMode === 'move') {
|
||||
try { fs.renameSync(file_path, destPath) }
|
||||
catch { await fs.promises.copyFile(file_path, destPath); await fs.promises.unlink(file_path) }
|
||||
apply(data => { retargetEntries(data, new Map([[file_path, destPath]])) })
|
||||
} else {
|
||||
await fs.promises.copyFile(file_path, destPath)
|
||||
}
|
||||
@@ -191,6 +249,10 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
|
||||
if (!entry && organized['manual']?.[String(ep.id)]) {
|
||||
entry = organized['manual'][String(ep.id)]
|
||||
}
|
||||
if (!entry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { entry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!entry) continue
|
||||
|
||||
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
|
||||
@@ -226,9 +288,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
|
||||
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
|
||||
if (!orgEntry) {
|
||||
for (const [h, eps] of Object.entries(organized)) {
|
||||
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
|
||||
}
|
||||
const found = findEntry(organized, Number(episode_id))
|
||||
if (found) { orgEntry = found.entry; entryHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
|
||||
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
|
||||
@@ -240,6 +301,7 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
|
||||
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
writeOrganized(organized)
|
||||
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
|
||||
res.json({ ok: true, renamed: true, old_name: orgEntry.dest_filename, new_name: newName })
|
||||
@@ -400,12 +462,12 @@ router.delete('/organized/:serieId/:episodeId', requireAuth, async (req, res) =>
|
||||
router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { nfoSupport } = readSettings()
|
||||
const { series: seriesData, complete } = await loadCatalogStatus()
|
||||
const organized = readOrganized()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result: any[] = []
|
||||
for (const sd of seriesData) {
|
||||
const rawTitle = sd.title ?? sd.show_title ?? ''
|
||||
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
const serieTitle = serieFolderName(rawTitle)
|
||||
const episodes: any[] = []
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
@@ -422,6 +484,10 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
|
||||
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
|
||||
}
|
||||
if (!orgEntry) {
|
||||
const found = findEntry(organized, ep.id)
|
||||
if (found) { orgEntry = found.entry; orgHash = found.hash }
|
||||
}
|
||||
if (!orgEntry) continue
|
||||
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
|
||||
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
|
||||
@@ -432,24 +498,111 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
|
||||
result.push({ serie_id: sd.id, serie_title: rawTitle, serie_title_clean: serieTitle, total: episodes.length, needs_rename: episodes.filter(e => e.needs_rename).length, episodes })
|
||||
}
|
||||
}
|
||||
res.json({ series: result, nfo_support: nfoSupport })
|
||||
const orphans = complete
|
||||
? findOrphans(organized, seriesData).map(o => ({ ...o, file_exists: !!o.dest_path && fs.existsSync(o.dest_path) }))
|
||||
: []
|
||||
res.json({ series: result, nfo_support: nfoSupport, orphans, orphans_checked: complete })
|
||||
} catch (err) {
|
||||
logger.error('api', `organized-summary échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Retrait des entrées orphelines ─────────────────────────────
|
||||
router.delete('/organized-summary/orphans', requireAdmin, async (req, res) => {
|
||||
const only = Array.isArray(req.body?.episode_ids) ? new Set(req.body.episode_ids.map(String)) : null
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
if (!complete) { res.status(503).json({ error: 'Catalogue incomplet (scraper ou API Fankai injoignable), réessayez plus tard' }); return }
|
||||
let removed = 0
|
||||
try {
|
||||
updateOrganized(data => {
|
||||
for (const o of findOrphans(data, series)) {
|
||||
if (only && !only.has(String(o.episode_id))) continue
|
||||
delete data[o.hash][String(o.episode_id)]
|
||||
removed++
|
||||
}
|
||||
return removed > 0
|
||||
})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return
|
||||
}
|
||||
logger.info('api', `Entrées orphelines retirées du suivi : ${removed}`)
|
||||
res.json({ ok: true, removed })
|
||||
})
|
||||
|
||||
// ── Dossier série ──────────────────────────────────────────────
|
||||
router.get('/organized/:serieId/folder', requireAuth, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId)
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const current = [...serieFolders(sd, organized, mediaPath)].map(([folder, entries]) => ({ path: folder, entries, exists: fs.existsSync(folder) }))
|
||||
res.json({ expected, current, needs_rename: current.some(f => f.exists && f.path !== expected) })
|
||||
})
|
||||
|
||||
router.post('/organized/:serieId/folder', requireAdmin, async (req, res) => {
|
||||
const serieId = Number(req.params.serieId)
|
||||
const sd = await resolveSerieData(serieId, 'Renommage dossier')
|
||||
if (!sd) { res.status(404).json({ error: serieNotFound(serieId) }); return }
|
||||
const { mediaPath } = readSettings()
|
||||
if (!mediaPath) { res.status(400).json({ error: 'Chemin médiathèque non configuré' }); return }
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
const expected = path.join(mediaPath, serieFolderName(sd.title ?? sd.show_title ?? ''))
|
||||
const sources = [...serieFolders(sd, organized, mediaPath).keys()].filter(f => f !== expected && fs.existsSync(f))
|
||||
if (sources.length === 0) { res.json({ ok: true, moved: 0, updated: 0 }); return }
|
||||
|
||||
// Tout le contenu (vidéos, NFO, images…) est fusionné dans le dossier attendu, sans jamais écraser
|
||||
const moves = new Map<string, string>()
|
||||
for (const src of sources)
|
||||
for (const file of listFiles(src)) moves.set(file, path.join(expected, path.relative(src, file)))
|
||||
const targets = [...moves.values()]
|
||||
const conflicts = targets.filter((to, i) => fs.existsSync(to) || targets.indexOf(to) !== i)
|
||||
if (conflicts.length > 0) {
|
||||
res.status(409).json({ error: `${conflicts.length} fichier(s) existent déjà dans « ${path.basename(expected)} »`, conflicts: conflicts.slice(0, 10) }); return
|
||||
}
|
||||
|
||||
const done = new Map<string, string>()
|
||||
let failure: string | null = null
|
||||
for (const [from, to] of moves) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true })
|
||||
fs.renameSync(from, to)
|
||||
done.set(from, to)
|
||||
} catch (err) {
|
||||
failure = `${path.basename(from)} : ${err instanceof Error ? err.message : err}`
|
||||
break
|
||||
}
|
||||
}
|
||||
for (const src of sources) {
|
||||
try { removeEmptyDirs(src) } catch {}
|
||||
}
|
||||
let updated = 0
|
||||
try { updateOrganized(data => { updated = retargetEntries(data, done); return updated > 0 }) }
|
||||
catch (err) {
|
||||
logger.error('api', `Renommage dossier série ${serieId} : fichiers déplacés mais suivi non mis à jour : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: 'Fichiers déplacés mais suivi non mis à jour, lancez un scan de la médiathèque' }); return
|
||||
}
|
||||
const label = `"${sources.map(s => path.basename(s)).join('", "')}" → "${path.basename(expected)}"`
|
||||
if (failure) {
|
||||
logger.error('api', `Renommage dossier ${label} interrompu après ${done.size} fichier(s) : ${failure}`)
|
||||
res.status(500).json({ error: `Interrompu après ${done.size} fichier(s) : ${failure}`, moved: done.size, updated }); return
|
||||
}
|
||||
logger.info('api', `Dossier série renommé : ${label} (${done.size} fichier(s), ${updated} entrée(s))`)
|
||||
res.json({ ok: true, moved: done.size, updated })
|
||||
})
|
||||
|
||||
// ── Rename en masse ────────────────────────────────────────────
|
||||
router.post('/rename-all', requireAdmin, async (req, res) => {
|
||||
const { serie_id, serie_ids } = req.body
|
||||
const onlyIds = Array.isArray(serie_ids) ? new Set(serie_ids.map(Number)) : serie_id ? new Set([Number(serie_id)]) : null
|
||||
const { nfoSupport } = readSettings()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
if (onlyIds) {
|
||||
const known = new Set(seriesData.map((sd: any) => sd.id))
|
||||
const missing = await Promise.all([...onlyIds].filter(id => !known.has(id)).map(id => resolveSerieData(id, 'Rename masse')))
|
||||
seriesData.push(...missing.filter(Boolean))
|
||||
}
|
||||
const seriesData = await loadCatalog()
|
||||
let organized: Organized
|
||||
try { organized = readOrganized() }
|
||||
catch (err) { res.status(500).json({ error: err instanceof Error ? err.message : 'organized.json illisible' }); return }
|
||||
@@ -482,6 +635,7 @@ router.post('/rename-all', requireAdmin, async (req, res) => {
|
||||
}
|
||||
fs.renameSync(oldPath, newPath)
|
||||
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
|
||||
retargetEntries(organized, new Map([[oldPath, newPath]]))
|
||||
done.push(ep.id)
|
||||
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Router } from 'express'
|
||||
import { requireAuth } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
|
||||
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { recentOrganized, pushNotif } from '../lib/notifs.js'
|
||||
import { dispatchGetFiles } from '../torrent-clients/index.js'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
@@ -12,7 +12,7 @@ const router = Router()
|
||||
|
||||
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const organizedPath = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
@@ -36,7 +36,7 @@ router.post('/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
|
||||
// Récupère la progression par fichier depuis le client torrent.
|
||||
// Permet au worker de sauter les fichiers encore en téléchargement (EBUSY sur Windows).
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requireAdmin } from '../auth.js'
|
||||
import { logger } from '../logger.js'
|
||||
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync } from '../lib/rss-sync.js'
|
||||
import { loadSynced, setSync, setSyncBulk, isSynced, runRssSync, reconcileSynced } from '../lib/rss-sync.js'
|
||||
import { loadCatalogStatus } from '../lib/serie-helpers.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -21,6 +22,12 @@ router.post('/rss-sync/run', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** GET /api/rss-sync/orphans → surveillances dont la série n'existe plus (avant /:id) */
|
||||
router.get('/rss-sync/orphans', requireAdmin, async (_req, res) => {
|
||||
const { series, complete } = await loadCatalogStatus()
|
||||
res.json({ checked: complete, orphans: complete ? reconcileSynced(series) : [] })
|
||||
})
|
||||
|
||||
/** POST /api/rss-sync/bulk → activer/désactiver la surveillance de plusieurs séries (avant /:id) */
|
||||
router.post('/rss-sync/bulk', requireAdmin, (req, res) => {
|
||||
const enabled = req.body?.enabled === true
|
||||
|
||||
@@ -7,7 +7,8 @@ import { DATA_DIR, BASE_DIR } from '../config.js'
|
||||
import { readSettings } from '../settings.js'
|
||||
import { systemInfo } from '../system.js'
|
||||
import { scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds, dedupeOrganizedEpisodes } from '../organize.js'
|
||||
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
|
||||
import { readAvailable, cacheClear } from '../lib/github-cache.js'
|
||||
import { loadCatalog } from '../lib/serie-helpers.js'
|
||||
import { cacheSize } from '../lib/github-cache.js'
|
||||
import { recentOrganized } from '../lib/notifs.js'
|
||||
import { workerRunning } from '../organize.js'
|
||||
@@ -87,10 +88,8 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
// Lecture seule : le dossier est créé au moment de l'import
|
||||
if (!fs.existsSync(dirPath)) { res.json({ path: dirPath, files: [], exists: false }); return }
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
@@ -111,7 +110,7 @@ router.get('/browse-files', requireAuth, (req, res) => {
|
||||
}
|
||||
walk(dirPath)
|
||||
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
|
||||
res.json({ path: dirPath, files })
|
||||
res.json({ path: dirPath, files, exists: true })
|
||||
} catch (err) {
|
||||
logger.warn('api', `browse-files "${dirPath}" : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
@@ -140,7 +139,7 @@ router.post('/update', requireAuth, async (req, res) => {
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
|
||||
if (force) {
|
||||
const seriesData = await loadEnrichedSeriesData(true)
|
||||
const seriesData = await loadCatalog(true)
|
||||
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
|
||||
const { removed } = dedupeOrganizedEpisodes(organizedPath)
|
||||
const { renamed, errors } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
@@ -152,7 +151,7 @@ router.post('/update', requireAuth, async (req, res) => {
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
;(async () => {
|
||||
try {
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
|
||||
if (renamed > 0)
|
||||
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
|
||||
@@ -170,7 +169,7 @@ router.post('/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const seriesData = await loadCatalog()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
|
||||
Reference in new issue
Block a user