Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43dbfefc7a | ||
|
|
0c576fb59f | ||
|
|
c097cd4d1f | ||
|
|
e67efe39f4 | ||
|
|
237530321e | ||
|
|
91c27cf2f3 | ||
|
|
19d09de88e | ||
|
|
5850265f6d | ||
|
|
27f55ab2be | ||
|
|
f871644330 | ||
|
|
a5e97088c4 | ||
|
|
ca4542d71b | ||
|
|
61418c6f77 | ||
|
|
1d89a8f61e | ||
|
|
db5454393f | ||
|
|
e18c6a1c54 | ||
|
|
49d60aeabd | ||
|
|
c78537c9d8 | ||
|
|
def689a1ca | ||
|
|
8bddc61d41 | ||
|
|
b6587acaf4 | ||
|
|
dd1ead4568 |
No files matched your search
@@ -1,5 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.1.0](https://github.com/Masutayunikon/FanKarr/compare/v1.0.6...v1.1.0) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||||
|
|
||||||
|
## [1.0.6](https://github.com/Masutayunikon/FanKarr/compare/v1.0.5...v1.0.6) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||||
|
|
||||||
|
## [1.0.5](https://github.com/Masutayunikon/FanKarr/compare/v1.0.4...v1.0.5) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||||
|
|
||||||
|
## [1.0.4](https://github.com/Masutayunikon/FanKarr/compare/v1.0.3...v1.0.4) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||||
|
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||||
|
|
||||||
|
## [1.0.3](https://github.com/Masutayunikon/FanKarr/compare/v1.0.2...v1.0.3) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||||
|
|
||||||
## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10)
|
## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -15,13 +15,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# ── Production ─────────────────────────────────────────────────
|
# ── Production ─────────────────────────────────────────────────
|
||||||
FROM base
|
FROM node:20-slim
|
||||||
|
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||||
COPY --from=build /app/dist/server /app/dist/server
|
COPY --from=build /app/dist/server /app/dist/server
|
||||||
COPY --from=build /app/dist/client /app/public
|
COPY --from=build /app/dist/client /app/public
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
CMD ["node", "dist/server/index.js"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PUID=${PUID:-1000}
|
||||||
|
PGID=${PGID:-1000}
|
||||||
|
|
||||||
|
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||||
|
|
||||||
|
# Corriger les permissions du dossier data
|
||||||
|
chown -R "$PUID:$PGID" /app/data
|
||||||
|
|
||||||
|
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
/**
|
||||||
|
* organize-worker.ts
|
||||||
|
* ==================
|
||||||
|
* Worker thread dédié à l'organisation des fichiers.
|
||||||
|
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
|
||||||
|
*
|
||||||
|
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
|
||||||
|
* Messages envoyés (parentPort.postMessage) :
|
||||||
|
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
|
||||||
|
* { type: 'result', hash: string, name: string, done, skipped, errors }
|
||||||
|
* { type: 'done' }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import fsp from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { parentPort } from 'worker_threads'
|
||||||
|
|
||||||
|
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||||
|
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||||
|
|
||||||
|
// ─── Utils log ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||||
|
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
||||||
|
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||||
|
|
||||||
|
// ─── Settings (lu directement, pas d'import circulaire) ──────
|
||||||
|
|
||||||
|
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string } {
|
||||||
|
try {
|
||||||
|
const p = path.join(process.cwd(), 'data', 'settings.json')
|
||||||
|
if (!fs.existsSync(p)) return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' }
|
||||||
|
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
|
} catch { return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Organized log ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function loadOrganized(): Record<string, Record<string, string>> {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||||
|
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||||
|
} catch { return {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOrganized(hash: string, filename: string) {
|
||||||
|
const data = loadOrganized()
|
||||||
|
if (!data[hash]) data[hash] = {}
|
||||||
|
data[hash][filename] = new Date().toISOString()
|
||||||
|
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOrganized(hash: string, filename: string): boolean {
|
||||||
|
return !!loadOrganized()[hash]?.[filename]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Torrent lookup ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function findTorrentByHash(hash: string): any | null {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(TORRENTS_PATH)) return null
|
||||||
|
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||||
|
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dossiers exclus (bonus, musique, images) ────────────────
|
||||||
|
|
||||||
|
const EXCLUDED_FOLDERS = new Set([
|
||||||
|
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||||
|
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||||
|
'images', 'image', 'scans', 'scan', 'soundtrack', 'music',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isInExcludedFolder(filePath: string[]): boolean {
|
||||||
|
for (const folder of filePath.slice(0, -1)) {
|
||||||
|
const f = folder.toLowerCase().trim()
|
||||||
|
if (EXCLUDED_FOLDERS.has(f)) return true
|
||||||
|
for (const excl of EXCLUDED_FOLDERS) {
|
||||||
|
if (f.startsWith(excl)) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Filesystem ops ───────────────────────────────────────────
|
||||||
|
|
||||||
|
function seasonFolder(n: number): string {
|
||||||
|
return `Saison ${String(n).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryHardlink(src: string, dest: string): boolean {
|
||||||
|
try { fs.linkSync(src, dest); return true }
|
||||||
|
catch (err) {
|
||||||
|
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Organise un torrent ──────────────────────────────────────
|
||||||
|
|
||||||
|
async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||||
|
const { mediaPath, completePath, organizeMode } = readSettings()
|
||||||
|
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
|
||||||
|
|
||||||
|
log(`[organize] ── ${name} ──`)
|
||||||
|
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
|
||||||
|
|
||||||
|
const torrent = findTorrentByHash(hash)
|
||||||
|
if (!torrent) {
|
||||||
|
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
|
||||||
|
throw new Error(`Torrent introuvable : ${hash}`)
|
||||||
|
}
|
||||||
|
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
|
||||||
|
|
||||||
|
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||||
|
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||||
|
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
|
||||||
|
|
||||||
|
// ── Cas fichier unique ────────────────────────────────────
|
||||||
|
if (torrentFiles.length === 0) {
|
||||||
|
log(`[organize] torrent_files vide → tentative fichier unique`)
|
||||||
|
const candidates = [path.join(savePath, name), path.join(completePath, name)]
|
||||||
|
let src: string | null = null
|
||||||
|
for (const c of candidates) {
|
||||||
|
log(`[organize] candidate: ${c} → ${fs.existsSync(c) ? '✓' : '✗'}`)
|
||||||
|
if (fs.existsSync(c)) { src = c; break }
|
||||||
|
}
|
||||||
|
if (!src) throw new Error(`Source introuvable pour "${name}"`)
|
||||||
|
|
||||||
|
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
|
||||||
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||||
|
const dest = path.join(destDir, name)
|
||||||
|
log(`[organize] dest: ${dest}`)
|
||||||
|
|
||||||
|
if (!fs.existsSync(dest)) {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true })
|
||||||
|
if (organizeMode === 'hardlink') {
|
||||||
|
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||||
|
} else {
|
||||||
|
await fsp.copyFile(src, dest)
|
||||||
|
await fsp.unlink(src)
|
||||||
|
}
|
||||||
|
log(`[organize] ✓ ${organizeMode} OK`)
|
||||||
|
} else {
|
||||||
|
log(`[organize] → dest existe déjà, skip`)
|
||||||
|
}
|
||||||
|
markOrganized(hash, name)
|
||||||
|
return { ...result, total: 1, done: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Map filename → season ─────────────────────────────────
|
||||||
|
const filenameSeasonMap = new Map<string, number>()
|
||||||
|
for (const ep of torrent.resolved_episodes ?? []) {
|
||||||
|
if (ep.filename && ep.season_number !== undefined)
|
||||||
|
filenameSeasonMap.set(ep.filename, ep.season_number)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.total = torrentFiles.length
|
||||||
|
|
||||||
|
for (const tf of torrentFiles) {
|
||||||
|
const filename = tf.filename
|
||||||
|
const filePath: string[] = tf.path
|
||||||
|
|
||||||
|
// Skip les fichiers dans des dossiers bonus/musique/images
|
||||||
|
if (isInExcludedFolder(filePath)) {
|
||||||
|
log(`[organize] skip bonus: ${filename}`)
|
||||||
|
result.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(savePath, name, ...filePath.slice(0, -1), filename),
|
||||||
|
path.join(savePath, ...filePath.slice(0, -1), filename),
|
||||||
|
path.join(savePath, name, filename),
|
||||||
|
path.join(savePath, filename),
|
||||||
|
path.join(completePath, name, ...filePath.slice(0, -1), filename),
|
||||||
|
path.join(completePath, name, filename),
|
||||||
|
path.join(completePath, filename),
|
||||||
|
]
|
||||||
|
|
||||||
|
let src: string | null = null
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (fs.existsSync(c)) { src = c; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src) {
|
||||||
|
const tried = candidates.map(c => ` • ${c}`).join('\n')
|
||||||
|
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
|
||||||
|
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
||||||
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||||
|
const dest = path.join(destDir, filename)
|
||||||
|
|
||||||
|
if (fs.existsSync(dest)) {
|
||||||
|
markOrganized(hash, filename)
|
||||||
|
result.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(destDir, { recursive: true })
|
||||||
|
if (organizeMode === 'hardlink') {
|
||||||
|
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||||
|
} else {
|
||||||
|
await fsp.copyFile(src, dest)
|
||||||
|
await fsp.unlink(src)
|
||||||
|
}
|
||||||
|
markOrganized(hash, filename)
|
||||||
|
result.done++
|
||||||
|
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
|
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||||
|
result.errors.push({ file: filename, error: msg })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Boucle principale du worker ─────────────────────────────
|
||||||
|
|
||||||
|
parentPort?.on('message', async (msg: any) => {
|
||||||
|
if (msg.type !== 'run') return
|
||||||
|
|
||||||
|
const torrents: any[] = msg.torrents
|
||||||
|
const organized = loadOrganized()
|
||||||
|
|
||||||
|
for (const t of torrents) {
|
||||||
|
if (t.state !== 'seeding') continue
|
||||||
|
|
||||||
|
const torrent = findTorrentByHash(t.hash)
|
||||||
|
if (!torrent) {
|
||||||
|
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: any[] = torrent.torrent_files ?? []
|
||||||
|
// Pour fichier unique : vérifier via le name directement
|
||||||
|
const allDone = files.length > 0
|
||||||
|
? files.every((f: any) => organized[t.hash]?.[f.filename])
|
||||||
|
: !!organized[t.hash]?.[t.name]
|
||||||
|
|
||||||
|
if (allDone) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await organizeTorrent(t.hash, t.name, t.save_path)
|
||||||
|
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||||
|
} catch (err) {
|
||||||
|
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort?.postMessage({ type: 'done' })
|
||||||
|
})
|
||||||
+103
-295
@@ -1,312 +1,120 @@
|
|||||||
import express from 'express'
|
/**
|
||||||
import cookieParser from 'cookie-parser'
|
* organize.ts
|
||||||
import fs from 'fs'
|
* ===========
|
||||||
import path from 'path'
|
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||||
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
|
* Le worker gère tout le filesystem (copy, hardlink, move).
|
||||||
import { readSettings, writeSettings } from './settings.js'
|
*/
|
||||||
import {
|
|
||||||
registerDriver, getDriver, getAvailableClients,
|
|
||||||
listClients, addClient, removeClient, getClient,
|
|
||||||
sanitizeClient, dispatchDownload, dispatchList
|
|
||||||
} from './torrent-clients/index.js'
|
|
||||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
|
||||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
|
||||||
|
|
||||||
registerDriver(qbittorrentDriver)
|
import path from 'path'
|
||||||
|
import { Worker } from 'worker_threads'
|
||||||
|
|
||||||
const app = express()
|
// Un seul worker à la fois
|
||||||
const PORT = 3001
|
let workerRunning = false
|
||||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
|
||||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
|
||||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
|
||||||
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
|
|
||||||
|
|
||||||
app.use(express.json())
|
export interface OrganizeResult {
|
||||||
app.use(cookieParser())
|
total : number
|
||||||
|
skipped : number
|
||||||
// ── Static frontend (prod) ─────────────────────────────────────
|
done : number
|
||||||
const PUBLIC_PATH = path.join(process.cwd(), 'public')
|
errors : { file: string; error: string }[]
|
||||||
if (fs.existsSync(PUBLIC_PATH)) {
|
|
||||||
app.use(express.static(PUBLIC_PATH))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Auth ───────────────────────────────────────────────────────
|
/**
|
||||||
app.get('/api/auth/status', authStatus)
|
* Lance l'auto-organisation dans un worker thread.
|
||||||
app.post('/api/auth/setup', authSetup)
|
* listFn est appelée dans le thread principal (accès qBittorrent),
|
||||||
app.post('/api/auth/login', authLogin)
|
* puis les données sont passées au worker.
|
||||||
app.post('/api/auth/logout', authLogout)
|
*/
|
||||||
|
export async function autoOrganizeAll(
|
||||||
// ── Settings ───────────────────────────────────────────────────
|
listFn: () => Promise<any[]>
|
||||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
): Promise<void> {
|
||||||
res.json(readSettings())
|
if (workerRunning) {
|
||||||
})
|
console.log('[organize] Worker déjà en cours, skip')
|
||||||
app.post('/api/settings', requireAuth, (req, res) => {
|
|
||||||
const { mediaPath, completePath, organizeMode, category } = req.body
|
|
||||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Torrent clients ────────────────────────────────────────────
|
|
||||||
app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
|
|
||||||
res.json(getAvailableClients())
|
|
||||||
})
|
|
||||||
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
|
||||||
res.json(listClients().map((c: any) => sanitizeClient(c)))
|
|
||||||
})
|
|
||||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
|
||||||
const { name, type, config } = req.body
|
|
||||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
|
||||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
|
||||||
res.json(sanitizeClient(addClient(name, type, config)))
|
|
||||||
})
|
|
||||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
|
||||||
const ok = removeClient(String(req.params.uuid))
|
|
||||||
if (!ok) res.status(404).json({ error: 'Client introuvable' })
|
|
||||||
else res.json({ ok: true })
|
|
||||||
})
|
|
||||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
|
||||||
const { type, config } = req.body
|
|
||||||
if (!type || !config) { res.status(400).json({ error: 'type et config requis' }); return }
|
|
||||||
const driver = getDriver(type)
|
|
||||||
if (!driver) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
|
||||||
res.json(await driver.test(config))
|
|
||||||
})
|
|
||||||
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
|
|
||||||
const client = getClient(String(req.params.uuid))
|
|
||||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
|
||||||
const driver = getDriver(client.type)
|
|
||||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
|
||||||
res.json(await driver.test(client.config))
|
|
||||||
})
|
|
||||||
app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res) => {
|
|
||||||
const client = getClient(String(req.params.uuid))
|
|
||||||
if (!client) { res.status(404).json({ error: 'Client introuvable' }); return }
|
|
||||||
const driver = getDriver(client.type)
|
|
||||||
if (!driver) { res.status(400).json({ error: 'Driver introuvable' }); return }
|
|
||||||
res.json(await driver.healthcheck(client.config))
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────
|
|
||||||
function readTorrents(): any[] {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
|
||||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
|
||||||
} catch { return [] }
|
|
||||||
}
|
|
||||||
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}`)
|
|
||||||
return res.json()
|
|
||||||
}
|
|
||||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
|
||||||
const map = new Map<number, any[]>()
|
|
||||||
for (const t of torrents) {
|
|
||||||
if (!t.serie_id) continue
|
|
||||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
|
||||||
map.get(t.serie_id)!.push(t)
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Series ─────────────────────────────────────────────────────
|
|
||||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
|
||||||
const byId = indexBySerie(torrents)
|
|
||||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
|
||||||
res.json({ series: seriesList.map((serie: any) => ({
|
|
||||||
...serie,
|
|
||||||
torrent_count: byId.get(serie.id)?.length ?? 0,
|
|
||||||
has_torrents : byId.has(serie.id)
|
|
||||||
}))})
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|
||||||
const id = Number(req.params.id)
|
|
||||||
try {
|
|
||||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
|
|
||||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
|
||||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
|
||||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
|
||||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
|
||||||
}))
|
|
||||||
|
|
||||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
|
||||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
|
||||||
const availableEpisodeIds = new Set<number>()
|
|
||||||
const episodeTorrentMap: Record<number, any> = {}
|
|
||||||
const seasonTorrentMap : Record<number, any> = {}
|
|
||||||
const integraleTorrents: any[] = []
|
|
||||||
const packEpisodesTorrents: any[] = []
|
|
||||||
|
|
||||||
for (const t of torrents) {
|
|
||||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
|
||||||
for (const ep of t.resolved_episodes ?? []) {
|
|
||||||
availableEpisodeIds.add(ep.episode_id)
|
|
||||||
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
|
|
||||||
}
|
|
||||||
if (t.type === 'pack_saison') {
|
|
||||||
const rs: any[] = t.resolved_seasons ?? []
|
|
||||||
if (rs.length > 1) {
|
|
||||||
packEpisodesTorrents.push(t)
|
|
||||||
} else {
|
|
||||||
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
|
||||||
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
|
||||||
}
|
|
||||||
} else if (t.type === 'pack_integrale') {
|
|
||||||
integraleTorrents.push(t)
|
|
||||||
} else if (t.type === 'pack_episodes') {
|
|
||||||
packEpisodesTorrents.push(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let promotedIntegrale: any = null
|
|
||||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
|
||||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
|
||||||
const rs: any[] = t.resolved_seasons ?? []
|
|
||||||
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
|
|
||||||
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (integraleTorrents.length > 0 || promotedIntegrale) {
|
|
||||||
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
|
|
||||||
if (promotedIntegrale) {
|
|
||||||
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
|
|
||||||
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function packEpisodesLabel(t: any): string {
|
|
||||||
const eps: any[] = t.resolved_episodes ?? []
|
|
||||||
if (!eps.length) return t.raw
|
|
||||||
const bySeason = new Map<number, number[]>()
|
|
||||||
for (const ep of eps) {
|
|
||||||
const sn = ep.season_number ?? 0
|
|
||||||
if (!bySeason.has(sn)) bySeason.set(sn, [])
|
|
||||||
bySeason.get(sn)!.push(ep.episode_number)
|
|
||||||
}
|
|
||||||
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
|
|
||||||
const s = nums.sort((a, b) => a - b)
|
|
||||||
const label = sn === 0 ? 'S0' : `S${sn}`
|
|
||||||
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
|
|
||||||
}).join(' · ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
|
|
||||||
...season,
|
|
||||||
torrent: seasonTorrentMap[season.id] ?? null,
|
|
||||||
episodes: season.episodes.map((ep: any) => ({
|
|
||||||
...ep,
|
|
||||||
available: availableEpisodeIds.has(ep.id),
|
|
||||||
torrent : episodeTorrentMap[ep.id] ?? null
|
|
||||||
}))
|
|
||||||
}))
|
|
||||||
|
|
||||||
const heroBundles: any[] = [
|
|
||||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
|
|
||||||
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
|
|
||||||
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
|
|
||||||
]
|
|
||||||
|
|
||||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Download ───────────────────────────────────────────────────
|
|
||||||
app.post('/api/download', requireAuth, async (req, res) => {
|
|
||||||
const url = req.body.torrent_url ?? req.body.magnet
|
|
||||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
|
||||||
const results = await dispatchDownload(url)
|
|
||||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Downloads — liste torrents Fankai ──────────────────────────
|
|
||||||
app.get('/api/downloads', requireAuth, async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const { category } = readSettings()
|
|
||||||
const torrents = await dispatchList(category ?? 'fankai')
|
|
||||||
res.json(torrents)
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Organize ───────────────────────────────────────────────────
|
|
||||||
app.post('/api/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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let torrents: any[]
|
||||||
try {
|
try {
|
||||||
const result = await organizeTorrent(hash, name, save_path)
|
torrents = await listFn()
|
||||||
res.json(result)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
// ── Torrents status ────────────────────────────────────────────
|
if (torrents.length === 0) return
|
||||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
|
||||||
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
|
|
||||||
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
|
|
||||||
const data = JSON.parse(raw)
|
|
||||||
const count = Array.isArray(data) ? data.length : 0
|
|
||||||
res.json({ exists: true, count, empty: count === 0 })
|
|
||||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── Update torrent_final.json ──────────────────────────────────
|
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
if (seedingCount === 0) return
|
||||||
try {
|
|
||||||
const response = await fetch(GITHUB_RAW_URL)
|
|
||||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
|
||||||
const data = await response.json() as unknown[]
|
|
||||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
|
||||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
|
||||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
|
||||||
res.json({ ok: true, count: data.length })
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||||
if (fs.existsSync(PUBLIC_PATH)) {
|
workerRunning = true
|
||||||
app.get('*path', (_req, res) => {
|
|
||||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||||
|
|
||||||
|
const worker = new Worker(workerPath)
|
||||||
|
|
||||||
|
worker.on('message', (msg: any) => {
|
||||||
|
if (msg.type === 'log') {
|
||||||
|
if (msg.level === 'error') console.error(msg.msg)
|
||||||
|
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||||
|
else console.log(msg.msg)
|
||||||
|
} else if (msg.type === 'result') {
|
||||||
|
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||||
|
} else if (msg.type === 'done') {
|
||||||
|
console.log('[organize] Worker terminé')
|
||||||
|
workerRunning = false
|
||||||
|
worker.terminate()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
worker.on('error', (err) => {
|
||||||
|
console.error('[organize] Worker erreur:', err)
|
||||||
|
workerRunning = false
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('exit', (code) => {
|
||||||
|
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
|
||||||
|
workerRunning = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Envoyer les torrents au worker
|
||||||
|
worker.postMessage({ type: 'run', torrents })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Start ──────────────────────────────────────────────────────
|
/**
|
||||||
app.listen(PORT, () => {
|
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
|
||||||
try {
|
* Lance aussi dans un worker thread.
|
||||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
*/
|
||||||
const count = Array.isArray(data) ? data.length : 0
|
export async function organizeTorrent(
|
||||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
hash : string,
|
||||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
name : string,
|
||||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
savePath: string
|
||||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
): Promise<OrganizeResult> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||||
|
const worker = new Worker(workerPath)
|
||||||
|
|
||||||
// Auto-organise toutes les 30s
|
// On envoie un seul torrent "fictif seeding"
|
||||||
const autoOrganize = async () => {
|
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||||
try {
|
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||||
const { category } = readSettings()
|
|
||||||
await autoOrganizeAll(() => dispatchList(category ?? 'fankai'))
|
worker.on('message', (msg: any) => {
|
||||||
} catch (err) {
|
if (msg.type === 'log') {
|
||||||
console.error('[organize] Erreur auto-organise:', err)
|
if (msg.level === 'error') console.error(msg.msg)
|
||||||
}
|
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||||
}
|
else console.log(msg.msg)
|
||||||
// Premier run 30s après démarrage, puis toutes les 30s
|
} else if (msg.type === 'result') {
|
||||||
setTimeout(() => {
|
result.total = msg.total
|
||||||
autoOrganize()
|
result.skipped = msg.skipped
|
||||||
setInterval(autoOrganize, 30_000)
|
result.done = msg.done
|
||||||
}, 30_000)
|
result.errors = msg.errors
|
||||||
})
|
} else if (msg.type === 'done') {
|
||||||
|
worker.terminate()
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.on('error', (err) => { worker.terminate(); reject(err) })
|
||||||
|
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
|
||||||
|
|
||||||
|
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user