Compare commits

...
29 Commits
Author SHA1 Message Date
MasutaYunikon ba736a6fe1 Merge pull request #81 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.1
2026-04-10 12:39:53 +02:00
MasutaYunikon ceb18cef3c chore(main): release 3.6.1 2026-04-10 12:38:59 +02:00
MasutaYunikon 327cd66dcc Merge remote-tracking branch 'origin/main' 2026-04-10 12:38:30 +02:00
MasutaYunikon 31779d8f7e fix: windows baseline 2026-04-10 12:38:21 +02:00
MasutaYunikon d426662fd6 Merge pull request #80 from Masutayunikon/release-please--branches--main
chore(main): release 3.6.0
2026-04-10 12:21:44 +02:00
MasutaYunikon bf0285378d chore(main): release 3.6.0 2026-04-10 12:17:42 +02:00
MasutaYunikon bde760e8b4 Merge remote-tracking branch 'origin/main' 2026-04-10 12:17:14 +02:00
MasutaYunikon a358ee2d83 feat: ajout d'un exe pour les cpu non avx2 2026-04-10 12:17:06 +02:00
MasutaYunikon 559ce91954 Merge pull request #79 from Masutayunikon/release-please--branches--main
chore(main): release 3.5.0
2026-04-09 22:48:03 +02:00
MasutaYunikon b5bd8b9b48 chore(main): release 3.5.0 2026-04-09 22:47:19 +02:00
MasutaYunikon 9576ec9b23 fix: import manuelle desactivé pour le moment 2026-04-09 22:46:49 +02:00
MasutaYunikon a582991c15 feat: manual import 2026-04-09 22:36:15 +02:00
MasutaYunikon 2d0abab222 fix: utorrent path for import 2026-04-09 18:34:36 +02:00
MasutaYunikon 8ec323dc95 fix: remove log for mapping
fix: unlink before move

fix: l'organiseur prend les fichiers renommer maintenant

feat: ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard

fix: ajout d'une verif lors de l'écriture de la config sur les items dans body

fix: ne sauvegarde plus le mots de passe "fake" quand on edit un client

feat: ajout du driver rtorrent

fix: empeche un login failed de se repeter et de se faire bloquer par les clients
2026-04-09 18:30:34 +02:00
MasutaYunikon 630374def7 fix: remove log for mapping
fix: unlink before move
2026-04-09 16:04:19 +02:00
MasutaYunikon 0e09c85efd feat: add uttorent driver
fix: organiser take correct name
2026-04-09 15:59:55 +02:00
MasutaYunikon 89c0db5f36 fix: add log for candidate path 2026-04-08 22:07:19 +02:00
MasutaYunikon debf61f714 fix: polling des torrents à 60s
feat: ajout d'un bouton refresh
2026-04-08 19:39:31 +02:00
MasutaYunikon 0c53c1d32b Merge remote-tracking branch 'origin/main' 2026-04-08 19:25:28 +02:00
MasutaYunikon d563ed4c68 feat: ajout des parametres dossier de téléchargements pour les clients torrent
feat: ajout de synology downloader comme client

fix: augmentation du header size 8kb -> 32kb

feat: Ajout des mapping sur les clients

feat: auto update des fichiers nfo
2026-04-08 19:25:19 +02:00
MasutaYunikon 3b39a5a8c4 Merge pull request #78 from Masutayunikon/release-please--branches--main
chore(main): release 3.4.0
2026-04-08 15:33:40 +02:00
MasutaYunikon 50144c70dc chore(main): release 3.4.0 2026-04-08 15:14:07 +02:00
MasutaYunikon 482743bf3a fix: typo 2026-04-08 15:13:35 +02:00
MasutaYunikon f3a36af629 fix: le driver de Transmission n'est plus basé sur android
fix: les torrent inconnue en états seront mis dans la catégorie terminée
feat: ajout d'un retour visuel si on a un path par defaut sur /
fix: utilisation de nfo_filename au lieu de original_filename
2026-04-08 15:07:59 +02:00
MasutaYunikon bc960ba585 Merge pull request #77 from Nackophilz/main
feat: Ajout du support pour l'édition/mise à jour des clients torrent
2026-04-08 14:55:17 +02:00
ElPouki e648764715 Merge branch 'Masutayunikon:main' into main 2026-04-08 14:34:04 +02:00
ElPouki 15fac6c472 feat: Ajout du support pour l'édition/mise à jour des clients torrent
Nouveau helper updateClient et un endpoint PUT /api/torrent-clients/:uuid qui enregistre les modifications, journalise les mises à jour et renvoie le client.
Mise à jour de l'UI des paramètres pour ajouter un bouton "Modifier" et réutiliser la modale d'ajout de client pour les éditions (afin d'assurer le suivi de l'uuid ), bascule méthode PUT pour les requêtes de mise à jour, et actualisation de la liste des clients ainsi que des notifications en cas de modification réussie.
2026-04-08 14:33:48 +02:00
MasutaYunikon 61591020be Merge pull request #76 from Nackophilz/main
Fix : Saison 00 et double slash chemin
2026-04-08 14:21:07 +02:00
ElPouki f92b3c5fb9 Fix : Saison 00 et double slash chemin
Organize worker :  Traiter la saison 0 comme "Spéciaux" au lieu de "Saison 0".

FolderPicker : Ajouter un bouton "accueil" pour naviguer vers la racine, nettoyer le chemin sélectionné en supprimant les slashes de fin (avec un repli sur "/" pour la racine), et fiabiliser joinPath pour éviter les doubles slashes lorsque la base se termine par un slash
2026-04-08 14:12:01 +02:00
17 changed files with 1786 additions and 145 deletions

No files matched your search

+11 -4
View File
@@ -48,6 +48,11 @@ jobs:
bun-target: bun-windows-x64
binary: binaries/fankarr.exe
archive: fankarr-windows-x64.zip
- target: windows-x64-baseline
os: ubuntu-latest
bun-target: bun-windows-x64-baseline
binary: binaries/fankarr.exe
archive: fankarr-windows-x64-baseline.zip
- target: linux-x64
os: ubuntu-latest
bun-target: bun-linux-x64
@@ -90,28 +95,30 @@ jobs:
path: public/
- name: Build binary (Unix)
if: matrix.target != 'windows-x64'
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-baseline'
run: |
mkdir -p binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
- name: Build binary (Windows)
if: matrix.target == 'windows-x64'
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-baseline'
run: |
New-Item -ItemType Directory -Force -Path binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
shell: pwsh
env:
BUN_DOWNLOAD_TIMEOUT: 120000
- name: Build worker
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
- name: Package archive (Windows)
if: matrix.target == 'windows-x64'
if: matrix.target == 'windows-x64' || matrix.target == 'windows-x64-baseline'
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
shell: pwsh
- name: Package archive (Unix)
if: matrix.target != 'windows-x64'
if: matrix.target != 'windows-x64' && matrix.target != 'windows-x64-baseline'
run: |
chmod +x ${{ matrix.binary }}
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
+64
View File
@@ -1,5 +1,69 @@
# Changelog
## [3.6.1](https://github.com/Masutayunikon/FanKarr/compare/v3.6.0...v3.6.1) (2026-04-10)
### Bug Fixes
* windows baseline ([0469031](https://github.com/Masutayunikon/FanKarr/commit/0469031ff05c8794b20f73bc7c3ac417ab473e5d))
## [3.6.0](https://github.com/Masutayunikon/FanKarr/compare/v3.5.0...v3.6.0) (2026-04-10)
### Features
* ajout d'un exe pour les cpu non avx2 ([51aacaf](https://github.com/Masutayunikon/FanKarr/commit/51aacafbcb0de1ca6a8b496c0905aa4e831d6008))
## [3.5.0](https://github.com/Masutayunikon/FanKarr/compare/v3.4.0...v3.5.0) (2026-04-09)
### Features
* add uttorent driver ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* ajout d'information dans le fichier d'organisation pour l'organisation manuel plus tard ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ajout d'un bouton refresh ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* ajout de synology downloader comme client ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* Ajout des mapping sur les clients ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout des parametres dossier de téléchargements pour les clients torrent ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* ajout du driver rtorrent ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* auto update des fichiers nfo ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* manual import ([b65e3e7](https://github.com/Masutayunikon/FanKarr/commit/b65e3e7cd8563466725e7eaf2f8aad7afd927296))
### Bug Fixes
* add log for candidate path ([9c6465b](https://github.com/Masutayunikon/FanKarr/commit/9c6465bc45f9e08e67c53f4e0c83b6a625ea25f1))
* ajout d'une verif lors de l'écriture de la config sur les items dans body ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* augmentation du header size 8kb -> 32kb ([eefa216](https://github.com/Masutayunikon/FanKarr/commit/eefa216da5ed6428b982ded31ab696623e9e5806))
* empeche un login failed de se repeter et de se faire bloquer par les clients ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* import manuelle desactivé pour le moment ([fc7fd3a](https://github.com/Masutayunikon/FanKarr/commit/fc7fd3a0ecfba3a82f851fdcc7a278fdeb43febe))
* l'organiseur prend les fichiers renommer maintenant ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* ne sauvegarde plus le mots de passe "fake" quand on edit un client ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* organiser take correct name ([712db87](https://github.com/Masutayunikon/FanKarr/commit/712db87a77be4dec157c3addb5bc12b1ac2bc124))
* polling des torrents à 60s ([48f86ea](https://github.com/Masutayunikon/FanKarr/commit/48f86ea9cc36ded75f02fa3879a7d3bd2640fb70))
* remove log for mapping ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* remove log for mapping ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* unlink before move ([7376306](https://github.com/Masutayunikon/FanKarr/commit/73763066b900a246910481d6ac06a4991959396d))
* unlink before move ([4d963e5](https://github.com/Masutayunikon/FanKarr/commit/4d963e5bf3ab00e798089e93cdc846fbd2c31de6))
* utorrent path for import ([eba2552](https://github.com/Masutayunikon/FanKarr/commit/eba2552e1dba67eec615c2a17e0ca1b896fc1a3b))
## [3.4.0](https://github.com/Masutayunikon/FanKarr/compare/v3.3.3...v3.4.0) (2026-04-08)
### Features
* ajout d'un retour visuel si on a un path par defaut sur / ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* Ajout du support pour l'édition/mise à jour des clients torrent ([dda8a03](https://github.com/Masutayunikon/FanKarr/commit/dda8a03403b46027a23b4a2812ca3797a2628673))
* Ajout du support pour l'édition/mise à jour des clients torrent ([a3e1c34](https://github.com/Masutayunikon/FanKarr/commit/a3e1c34e7547bab75c45ddd3eace0d211409ccd5))
### Bug Fixes
* le driver de Transmission n'est plus basé sur android ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* les torrent inconnue en états seront mis dans la catégorie terminée ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
* typo ([18ec548](https://github.com/Masutayunikon/FanKarr/commit/18ec548d76c929afac11cd16d88e734480ee2755))
* utilisation de nfo_filename au lieu de original_filename ([4a5312d](https://github.com/Masutayunikon/FanKarr/commit/4a5312d4ece9a2f1ff484ab9e1cbbb7cf291c5a5))
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
+347 -42
View File
@@ -1,5 +1,6 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import http from 'http'
import fs from 'fs'
import path from 'path'
import { authStatus, authSetup, authLogin, authLogout, requireAuth } from './auth.js'
@@ -7,10 +8,13 @@ import { readSettings, writeSettings } from './settings.js'
import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
sanitizeClient, dispatchDownload, dispatchList, updateClient
} from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import transmissionDriver from './torrent-clients/transmission.js'
import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
@@ -18,6 +22,9 @@ import { systemInfo } from './system.js'
registerDriver(qbittorrentDriver)
registerDriver(transmissionDriver)
registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
@@ -39,7 +46,6 @@ app.post('/api/auth/setup', authSetup)
app.post('/api/auth/login', authLogin)
app.post('/api/auth/logout', authLogout)
// system info
app.get('/api/system/info', systemInfo)
// ── Settings ───────────────────────────────────────────────────
@@ -48,7 +54,15 @@ app.get('/api/settings', requireAuth, (_req, res) => {
})
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
const patch: Record<string, any> = {}
if (mediaPath !== undefined) patch.mediaPath = mediaPath
if (completePath !== undefined) patch.completePath = completePath
if (organizeMode !== undefined) patch.organizeMode = organizeMode
if (category !== undefined) patch.category = category
if (nfoSupport !== undefined) patch.nfoSupport = nfoSupport
if (autoImport !== undefined) patch.autoImport = autoImport
if (devMode !== undefined) patch.devMode = devMode
const updated = writeSettings(patch)
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
})
@@ -71,11 +85,40 @@ app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json({ ok: true })
})
app.put('/api/torrent-clients/:uuid', 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 }
const oldClient = getClient(String(req.params.uuid))
if (oldClient) {
const driver = getDriver(type)
if (driver) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
const updated = updateClient(String(req.params.uuid), name, type, config)
if (!updated) { res.status(404).json({ error: 'Client introuvable' }); return }
res.json(sanitizeClient(updated))
})
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
const { type, config } = req.body
const { type, config, uuid } = 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 }
if (uuid) {
const oldClient = getClient(uuid)
if (oldClient) {
for (const field of driver.definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
}
res.json(await driver.test(config))
})
app.post('/api/torrent-clients/:uuid/test', requireAuth, async (req, res) => {
@@ -199,7 +242,7 @@ async function loadEnrichedSeriesData(): Promise<any[]> {
return allSd.filter(Boolean)
}
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, any>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
if (!serieData) return 'none'
const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.length === 0) return 'none'
@@ -217,7 +260,8 @@ function computeSerieDownloadState(serieData: any | null, organized: Record<stri
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
}
}
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
@@ -230,7 +274,7 @@ app.get('/api/series', requireAuth, async (_req, res) => {
try {
const [apiData, availableIds] = await Promise.all([fankaiGet('/series'), readAvailable()])
const availableSet = new Set<number>(availableIds)
let organized: Record<string, Record<string, string>> = {}
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const activeTorrents = new Set<string>()
try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
@@ -259,13 +303,12 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
let organized: Record<string, Record<string, string>> = {}
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any> = {}
const seasonTorrentMapBySn : Record<number, any> = {}
const integraleTorrents : any[] = []
const packEpisodesTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
if (serieData) {
for (const t of (serieData.torrents ?? [])) {
@@ -273,7 +316,10 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
const resolved = buildResolvedEpisodes(serieData, t.infohash)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) {
@@ -281,16 +327,24 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) { if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) }
for (const ep of resolved) {
const isOrg = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrg) organizedEpisodeIds.add(ep.episode_id)
}
}
for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) {
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
const isOrg = orgFiles[String(ep.id)] !== undefined
if (!isOrg) {
const match = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
} else {
organizedEpisodeIds.add(ep.id)
}
}
}
}
@@ -339,7 +393,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
try {
const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai')
let organized: Record<string, Record<string, string>> = {}
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {}
@@ -419,6 +473,7 @@ app.post('/api/logs/clear', requireAuth, (_req, res) => {
app.get('/api/system', requireAuth, (_req, res) => {
res.json({ isDocker: fs.existsSync('/.dockerenv') })
})
app.get('/api/browse', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
try {
@@ -430,12 +485,45 @@ app.get('/api/browse', requireAuth, (req, res) => {
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
}
})
app.get('/api/browse-files', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
try {
const files: { name: string; path: string; size: number }[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
catch { return }
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && VIDEO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
const stat = fs.statSync(full)
files.push({ name: entry.name, path: full, size: stat.size })
} catch {}
}
}
}
walk(dirPath)
files.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
res.json({ path: dirPath, files })
} 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' })
}
})
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
try {
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
app.post('/api/update', requireAuth, async (_req, res) => {
try {
cacheClear()
@@ -449,6 +537,7 @@ app.post('/api/update', requireAuth, async (_req, res) => {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
app.post('/api/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
@@ -462,58 +551,252 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
}
})
// ── Debug (dev mode uniquement) ───────────────────────────────
// ── Import manuel ──────────────────────────────────────────────
app.post('/api/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 ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
let organized: Record<string, Record<string, any>> = {}
try { if (fs.existsSync(ORGANIZED_PATH)) organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8')) } catch {}
const sd = await readSerieData(Number(serie_id))
if (!sd) { res.status(404).json({ error: 'Série introuvable dans le catalogue' }); return }
const rawTitle = sd.title ?? sd.show_title ?? ''
const serieTitle = rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
const episodeIndex = new Map<number, { ep: any; season: any }>()
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
episodeIndex.set(ep.id, { ep, season })
}
}
const done: number[] = []
const errors: { file: string; error: string }[] = []
for (const item of items) {
const { file_path, episode_id, hash } = item
if (!file_path || !episode_id) { errors.push({ file: file_path ?? '?', error: 'Paramètres manquants' }); continue }
const found = episodeIndex.get(Number(episode_id))
if (!found) { errors.push({ file: file_path, error: `Épisode ${episode_id} introuvable` }); continue }
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
const destFilename = resolvedName.endsWith('.nfo') ? resolvedName.slice(0, -4) + srcExt : resolvedName
const seasonFolder = season.season_number === 0 ? 'Specials' : `Saison ${season.season_number}`
const destDir = path.join(mediaPath, serieTitle, seasonFolder)
const destPath = path.join(destDir, destFilename)
try {
if (!fs.existsSync(file_path)) throw new Error('Fichier source introuvable')
fs.mkdirSync(destDir, { recursive: true })
if (!fs.existsSync(destPath)) {
if (organizeMode === 'hardlink') {
try { fs.linkSync(file_path, destPath) }
catch { await fs.promises.copyFile(file_path, destPath) }
} else {
await fs.promises.copyFile(file_path, destPath)
await fs.promises.unlink(file_path)
}
}
const torrentHash = String(hash || '').toLowerCase() || 'manual'
if (!organized[torrentHash]) organized[torrentHash] = {}
organized[torrentHash][String(episode_id)] = {
at: new Date().toISOString(), season: season.season_number,
episode: ep.episode_number, episode_id: ep.id,
src_filename: srcFilename, dest_filename: destFilename, dest_path: destPath,
}
done.push(episode_id)
logger.info('api', `Import manuel : "${srcFilename}" → "${destPath}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
errors.push({ file: srcFilename, error: msg })
logger.error('api', `Import manuel échoué pour "${srcFilename}" : ${msg}`)
}
}
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(organized, null, 2), 'utf-8')
res.json({ ok: true, done: done.length, errors })
})
app.get('/api/organized/:serieId', requireAuth, async (req, res) => {
const serieId = Number(req.params.serieId)
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const hash = p.infohash.toLowerCase()
const entry = (organized[hash] ?? {})[String(ep.id)]
if (entry) { result[String(ep.id)] = entry; break }
}
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
result[String(ep.id)] = organized['manual'][String(ep.id)]
}
}
}
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Debug ──────────────────────────────────────────────────────
app.get('/api/debug/stats', requireAuth, (req, res) => {
const { devMode } = readSettings()
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
const mem = process.memoryUsage()
const uptimeS = Math.floor(process.uptime())
const h = Math.floor(uptimeS / 3600)
const m = Math.floor((uptimeS % 3600) / 60)
const s = uptimeS % 60
let organizedCount = 0
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) {
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0)
organizedCount = Object.values(org).reduce((acc: number, episodes: any) => acc + Object.keys(episodes).length, 0)
}
} catch {}
res.json({
memory: {
heapUsed : Math.round(mem.heapUsed / 1024 / 1024),
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
rss : Math.round(mem.rss / 1024 / 1024),
},
cache: {
entries : _cache.size,
ttlHours : 6,
},
uptime: `${h}h ${m}m ${s}s`,
memory : { heapUsed: Math.round(mem.heapUsed / 1024 / 1024), heapTotal: Math.round(mem.heapTotal / 1024 / 1024), rss: Math.round(mem.rss / 1024 / 1024) },
cache : { entries: _cache.size, ttlHours: 6 },
uptime : `${h}h ${m}m ${s}s`,
uptimeSeconds: uptimeS,
worker: {
running: workerRunning,
},
organized: {
trackedFiles: organizedCount,
},
requests: {
notifs: recentOrganized.length,
},
worker : { running: workerRunning },
organized: { trackedFiles: organizedCount },
requests : { notifs: recentOrganized.length },
})
})
// ── NFO Updates ────────────────────────────────────────────────
interface NfoUpdateNotif {
serieTitle: string; commitSha: string; commitMsg: string
updatedAt: string; status: 'updated' | 'error'; error?: string
}
const recentNfoUpdates: NfoUpdateNotif[] = []
const MAX_NFO_NOTIFS = 30
const NFO_COMMITS_PATH = path.join(DATA_DIR, 'nfo_commits.json')
const GITLAB_API_NFO = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_NFO = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabTitle(title: string): string { return SERIE_TITLE_GITLAB_MAP[title] ?? title }
function loadNfoCommits(): Record<string, string> {
try { if (!fs.existsSync(NFO_COMMITS_PATH)) return {}; return JSON.parse(fs.readFileSync(NFO_COMMITS_PATH, 'utf-8')) }
catch { return {} }
}
function saveNfoCommits(commits: Record<string, string>) {
fs.mkdirSync(path.dirname(NFO_COMMITS_PATH), { recursive: true })
fs.writeFileSync(NFO_COMMITS_PATH, JSON.stringify(commits, null, 2), 'utf-8')
}
async function getLatestGitlabCommit(gitlabTitle: string): Promise<{ sha: string; message: string } | null> {
try {
const res = await fetch(`${GITLAB_API_NFO}/commits?path=${encodeURIComponent('pack/' + gitlabTitle)}&per_page=1&ref_name=main`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) return null
const data: any[] = await res.json()
if (!data.length) return null
return { sha: data[0].id, message: data[0].title ?? data[0].message ?? '' }
} catch { return null }
}
async function downloadNfoFolder(gitlabTitle: string, destRoot: string): Promise<void> {
let files: any[] = [], page = 1
while (true) {
const batch = await fetch(`${GITLAB_API_NFO}/tree?path=${encodeURIComponent('pack/' + gitlabTitle)}&recursive=true&per_page=100&page=${page}&ref=main`, { headers: { 'User-Agent': 'fankarr' } }).then(r => r.ok ? r.json() : [])
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch); if (batch.length < 100) break; page++
}
let downloaded = 0, skipped = 0
for (const entry of files.filter((f: any) => f.type === 'blob')) {
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
try {
const res = await fetch(`${GITLAB_RAW_NFO}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`, { headers: { 'User-Agent': 'fankarr' } })
if (!res.ok) { skipped++; continue }
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, Buffer.from(await res.arrayBuffer()))
downloaded++
} catch { skipped++ }
}
logger.info('nfo-update', `"${gitlabTitle}" — ${downloaded} fichiers mis à jour, ${skipped} ignorés`)
}
async function checkNfoUpdates() {
const { mediaPath, nfoSupport } = readSettings()
if (!nfoSupport) return
let organized: Record<string, Record<string, any>> = {}
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
if (Object.keys(organized).length === 0) return
const seriesData = await loadEnrichedSeriesData()
const knownHashes = new Set(Object.keys(organized))
const serieTitles = new Set<string>()
for (const sd of seriesData) {
const allTorrents = [...(sd.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => s.torrents ?? []), ...(sd.seasons ?? []).flatMap((s: any) => (s.episodes ?? []).flatMap((e: any) => e.torrents ?? []))]
if (allTorrents.some((t: any) => knownHashes.has(t.infohash?.toLowerCase()))) {
const rawTitle = sd.title ?? sd.show_title ?? ''
if (rawTitle) serieTitles.add(rawTitle.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim())
}
}
if (serieTitles.size === 0) return
const commits = loadNfoCommits()
let hasChanges = false
for (const serieTitle of serieTitles) {
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)})`)
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}`)
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()
}
if (hasChanges) saveNfoCommits(commits)
}
app.get('/api/nfo-updates/recent', requireAuth, (_req, res) => { res.json(recentNfoUpdates) })
app.post('/api/nfo-updates/check', requireAuth, async (_req, res) => {
logger.info('nfo-update', 'Vérification manuelle des MAJ NFO lancée')
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif manuelle échouée : ${err instanceof Error ? err.message : err}`))
res.json({ ok: true, message: 'Vérification lancée en arrière-plan' })
})
// ── Catch-all SPA ──────────────────────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
// ── Démarrage ──────────────────────────────────────────────────
app.listen(PORT, async () => {
logger.info('api', `Serveur démarré sur le port ${PORT}`)
const server = http.createServer({ maxHeaderSize: 32768 }, app)
server.listen(PORT, async () => {
logger.info('api', `Serveur démarré sur le port ${PORT}`)
try {
const available = await readAvailable()
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
@@ -523,6 +806,21 @@ app.listen(PORT, async () => {
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings()
// Migration organized.json : si ancien format (valeurs string) → reset + rescan
try {
if (fs.existsSync(ORGANIZED_PATH)) {
const raw = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
const isOldFormat = Object.values(raw).some((entries: any) =>
Object.values(entries).some(v => typeof v === 'string')
)
if (isOldFormat) {
logger.info('api', 'Migration organized.json : ancien format détecté → réinitialisation')
fs.writeFileSync(ORGANIZED_PATH, '{}', 'utf-8')
}
}
} catch {}
loadEnrichedSeriesData()
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
@@ -549,4 +847,11 @@ app.listen(PORT, async () => {
autoOrganize()
setInterval(autoOrganize, 5 * 60_000)
}, 10_000)
setTimeout(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif initiale échouée : ${err instanceof Error ? err.message : err}`))
setInterval(() => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 30_000)
})
+91 -37
View File
@@ -13,12 +13,25 @@ import { DATA_DIR } from './config.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe
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 }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Types ────────────────────────────────────────────────────
export interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
}
// organized.json : Record<hash, Record<episode_id_string, OrgEntry>>
type Organized = Record<string, Record<string, OrgEntry>>
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
@@ -29,51 +42,45 @@ function readSettings(): { mediaPath: string; completePath: string; organizeMode
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
function loadOrganized(): Organized {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch { return {} }
}
function markOrganized(hash: string, filename: string) {
function markOrganized(hash: string, episodeId: number, entry: OrgEntry) {
const data = loadOrganized()
if (!data[hash]) data[hash] = {}
data[hash][filename] = new Date().toISOString()
data[hash][String(episodeId)] = entry
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
}
function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
function isOrganized(hash: string, episodeId: number): boolean {
return !!loadOrganized()[hash]?.[String(episodeId)]
}
// ─── Lookup torrent dans les seriesData ───────────────────────
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
const h = hash.toLowerCase()
for (const sd of seriesData) {
// Niveau série (pack intégrale)
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
// Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons
// Si oui, pas de _season pour que buildFileMap scanne toutes les saisons
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
)
if (seasonsWithHash.length === 1) {
// Torrent propre à une seule saison → on filtre sur cette saison
const season = seasonsWithHash[0]
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
} else if (seasonsWithHash.length > 1) {
// Même torrent dans plusieurs saisons → pas de filtre saison
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
// Niveau épisode
for (const season of sd.seasons ?? []) {
const matchingEpisodes = season.episodes?.filter((ep: any) =>
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
@@ -97,8 +104,8 @@ function buildFileMap(
hash : string,
nfoSupport : boolean,
seasonFilter?: number,
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
): Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; episode_number: number; episode_id: number; nfo_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) {
@@ -124,17 +131,21 @@ function buildFileMap(
: null
if (!nfoSupport && !fmtName) {
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`)
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback nfo_filename`)
}
const destName = nfoSupport
? (ep.original_filename ?? filename)
: (fmtName ?? ep.original_filename ?? filename)
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
season_number : season.season_number,
original_filename: destName,
fullPath : matchedPath,
season_number : season.season_number,
episode_number: ep.episode_number,
episode_id : ep.id,
nfo_filename : destName,
fullPath : matchedPath,
})
}
}
@@ -241,7 +252,16 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string { return `Saison ${n}` }
function swapExtension(filename: string, ext: string): string {
const currentExt = path.extname(filename)
if (!currentExt || currentExt === ext) return filename
return filename.slice(0, -currentExt.length) + ext
}
function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
@@ -294,13 +314,17 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
const destName = nfoSupport
? (ep.original_filename ?? filename)
: (fmtName ?? ep.original_filename ?? filename)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName)
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
const destName = swapExtension(resolvedName, srcExt)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, destName)
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
const candidates = filePath ? [
path.join(savePath, filePath),
@@ -326,10 +350,22 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
await fsp.unlink(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
}
markOrganized(hash, filename)
markOrganized(hash, ep.id, {
at: new Date().toISOString(), season: season.season_number ?? 1,
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
@@ -345,8 +381,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
result.total = fileMap.size
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue }
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
const candidates = [
path.join(savePath, fullPath),
@@ -364,21 +400,38 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
}
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
const dest = path.join(destDir, original_filename)
const dest = path.join(destDir, nfo_filename)
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
if (fs.existsSync(dest)) {
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
result.skipped++
continue
}
try {
fs.mkdirSync(destDir, { recursive: true })
if (organizeMode === 'hardlink') {
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
} else {
await fsp.copyFile(src, dest)
markOrganized(hash, episode_id, {
at: new Date().toISOString(), season: season_number,
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
await fsp.unlink(src)
}
markOrganized(hash, filename)
result.done++
debug(`${original_filename} → Saison ${season_number}`)
debug(`${nfo_filename} → Saison ${season_number}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`Échec import "${filename}" : ${msg}`)
@@ -410,9 +463,10 @@ parentPort?.on('message', async (msg: any) => {
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
const orgHash = organized[t.hash] ?? {}
const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name]
? [...fileMap.values()].every(f => orgHash[String(f.episode_id)])
: Object.keys(orgHash).length > 0
if (allDone) {
debug(`"${t.name}" déjà entièrement importé — skip`)
+91 -17
View File
@@ -30,8 +30,26 @@ export interface OrganizeResult {
errors : { file: string; error: string }[]
}
interface OrgEntry {
at : string
season : number
episode : number
episode_id : number
src_filename : string
dest_filename: string
dest_path : string
}
type Organized = Record<string, Record<string, OrgEntry>>
// ── Scan initial ──────────────────────────────────────────────
function swapExt(filename: string, ext: string): string {
const cur = path.extname(filename)
if (!cur || cur === ext) return filename
return filename.slice(0, -cur.length) + ext
}
export async function scanMediaPath(
mediaPath : string,
organizedPath: string,
@@ -46,7 +64,16 @@ export async function scanMediaPath(
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
const filenameIndex = new Map<string, string>()
// Map : nom_sur_disque → { hash, srcFilename, episodeId, season, episode }
const filenameIndex = new Map<string, {
hash : string
srcFilename : string
episodeId : number
season : number
episode : number
destFilename: string
}>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
@@ -54,19 +81,51 @@ export async function scanMediaPath(
if (typeof p === 'string') continue
const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue
const filename = p.path.replace(/\\/g, '/').split('/').pop()
if (filename) filenameIndex.set(filename, hash)
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
const srcExt = path.extname(srcFilename)
const entry = {
hash,
srcFilename,
episodeId : ep.id,
season : season.season_number,
episode : ep.episode_number,
destFilename: srcFilename,
}
// 1. Nom brut source
filenameIndex.set(srcFilename, entry)
// 2. nfo_filename avec extension swappée
if (ep.nfo_filename) {
const nfoRenamed = swapExt(ep.nfo_filename, srcExt)
filenameIndex.set(nfoRenamed, { ...entry, destFilename: nfoRenamed })
}
// 3. original_filename
if (ep.original_filename) {
filenameIndex.set(ep.original_filename, { ...entry, destFilename: ep.original_filename })
}
// 4. formatted_name + .mkv
if (ep.formatted_name?.trim()) {
const fmtName = ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + '.mkv'
filenameIndex.set(fmtName, { ...entry, destFilename: fmtName })
}
}
}
}
}
let organized: Record<string, Record<string, string>> = {}
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Track les paires hash:episodeId présentes
const presentFiles = new Set<string>()
function walk(dir: string) {
@@ -79,24 +138,40 @@ export async function scanMediaPath(
walk(full)
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
result.found++
const hash = filenameIndex.get(entry.name)
if (!hash) continue
presentFiles.add(`${hash}:${entry.name}`)
if (organized[hash]?.[entry.name]) continue
const match = filenameIndex.get(entry.name)
if (!match) continue
const { hash, srcFilename, episodeId, season, episode, destFilename } = match
presentFiles.add(`${hash}:${episodeId}`)
if (organized[hash]?.[String(episodeId)]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
organized[hash][String(episodeId)] = {
at : new Date().toISOString(),
season,
episode,
episode_id : episodeId,
src_filename : srcFilename,
dest_filename: destFilename,
dest_path : full,
}
result.added++
logger.debug('organize', `Scan match : "${entry.name}" → ep ${episodeId} (S${season}E${episode})`)
}
}
}
walk(mediaPath)
// Supprimer uniquement les entrées connues dans l'index et absentes du disque
const allEpisodeIds = new Set([...filenameIndex.values()].map(v => `${v.hash}:${v.episodeId}`))
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) {
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
for (const [hash, episodes] of Object.entries(organized)) {
for (const episodeId of Object.keys(episodes)) {
if (allEpisodeIds.has(`${hash}:${episodeId}`) && !presentFiles.has(`${hash}:${episodeId}`)) {
delete organized[hash][episodeId]
removed++
}
}
@@ -151,11 +226,10 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state)
let organized: Record<string, Record<string, string>> = {}
let organized: Organized = {}
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
if (fs.existsSync(ORGANIZED_PATH))
organized = JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
} catch {}
const hasUnorganized = torrents.some(t =>
+100 -1
View File
@@ -117,6 +117,16 @@ export function getClient(uuid: string): SavedClient | undefined {
return loadClients().find(c => c.uuid === uuid)
}
export function updateClient(uuid: string, name: string, type: string, config: Record<string, string | number>): SavedClient | null {
const clients = loadClients()
const index = clients.findIndex(c => c.uuid === uuid)
if (index === -1) return null
clients[index] = { ...clients[index], name, type, config }
saveClients(clients)
logger.info('torrent-clients', `Client modifié : "${name}" (${type})`)
return clients[index]
}
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
if (!definition) definition = getDriver(client.type)?.definition
if (!definition) return client
@@ -129,6 +139,69 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
return { ...client, config }
}
// ─── Backoff par client ───────────────────────────────────────────────────────
// Si un client échoue à l'auth, on le met en cooldown pour éviter le ban IP
interface BackoffEntry {
failCount : number
nextRetry : number // timestamp ms
}
const _backoff = new Map<string, BackoffEntry>()
// Délais progressifs : 1min, 5min, 15min, 30min, 60min (plafonné)
const BACKOFF_DELAYS = [60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
function isInCooldown(uuid: string): boolean {
const entry = _backoff.get(uuid)
if (!entry) return false
return Date.now() < entry.nextRetry
}
function cooldownRemaining(uuid: string): string {
const entry = _backoff.get(uuid)
if (!entry) return ''
const remaining = Math.max(0, entry.nextRetry - Date.now())
const m = Math.ceil(remaining / 60_000)
return m > 0 ? `${m}min` : ''
}
function registerFailure(uuid: string, clientName: string) {
const entry = _backoff.get(uuid) ?? { failCount: 0, nextRetry: 0 }
entry.failCount = Math.min(entry.failCount + 1, BACKOFF_DELAYS.length - 1) + (entry.failCount === 0 ? 0 : 0)
const delay = BACKOFF_DELAYS[Math.min(entry.failCount - 1, BACKOFF_DELAYS.length - 1)]
entry.nextRetry = Date.now() + delay
_backoff.set(uuid, entry)
const min = Math.round(delay / 60_000)
logger.warn('torrent-clients', `Client "${clientName}" en cooldown ${min}min après ${entry.failCount} échec(s)`)
}
function registerSuccess(uuid: string) {
_backoff.delete(uuid)
}
// Exposé pour l'API de status
export function getClientCooldowns(): Record<string, { failCount: number; nextRetry: number }> {
const result: Record<string, { failCount: number; nextRetry: number }> = {}
for (const [uuid, entry] of _backoff.entries()) {
if (Date.now() < entry.nextRetry) result[uuid] = entry
}
return result
}
// ─── Remote path remapping ────────────────────────────────────────────────────
// FIX : remplace le préfixe remotePath par localPath dans save_path
// ex: remotePath=/downloads, localPath=/mnt/nas/downloads
// /downloads/fankai/Serie → /mnt/nas/downloads/fankai/Serie
function remapSavePath(savePath: string, remotePath: string, localPath: string): string {
const remote = remotePath.replace(/\/+$/, '')
const local = localPath.replace(/\/+$/, '')
if (!remote || !local) return savePath
if (!savePath.startsWith(remote)) return savePath
return local + savePath.slice(remote.length)
}
// ─── Dispatch download ─────────────────────────────────────────────────────────
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
@@ -171,15 +244,41 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver) continue
// Skip si le client est en cooldown suite à des échecs d'auth
if (isInCooldown(client.uuid)) {
const remaining = cooldownRemaining(client.uuid)
logger.debug('torrent-clients', `Client "${client.name}" en cooldown — retry dans ${remaining}`)
continue
}
try {
const clientCategory = (client.config.category as string) || category
const torrents = await driver.list(client.config, clientCategory)
registerSuccess(client.uuid)
const remotePath = String(client.config.remotePath ?? '')
const localPath = String(client.config.localPath ?? '')
const hasRemap = remotePath && localPath
for (const t of torrents) {
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
const save_path = hasRemap
? remapSavePath(t.save_path, remotePath, localPath)
: t.save_path
results.push({ ...t, save_path, client_uuid: client.uuid, client_name: client.name })
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
// Déclencher le backoff si c'est une erreur d'auth
const isAuthError = msg.toLowerCase().includes('login') ||
msg.toLowerCase().includes('auth') ||
msg.toLowerCase().includes('ban') ||
msg.toLowerCase().includes('forbidden') ||
msg.toLowerCase().includes('401') ||
msg.toLowerCase().includes('403')
if (isAuthError) registerFailure(client.uuid, client.name)
}
}
+8 -5
View File
@@ -39,10 +39,13 @@ const QB: TorrentClientDriver = {
id : 'qbittorrent',
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
@@ -110,7 +113,7 @@ const QB: TorrentClientDriver = {
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
}
+235
View File
@@ -0,0 +1,235 @@
/**
* rTorrent Driver
* ===============
* Communique via XML-RPC (HTTP POST).
* Compatible ruTorrent (via /RPC2) et rTorrent standalone (via /RPC2 ou /XMLRPC).
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// ─── XML-RPC helpers ──────────────────────────────────────────
function xmlValue(val: any): string {
if (typeof val === 'string') return `<value><string>${val}</string></value>`
if (typeof val === 'number') return `<value><i8>${val}</i8></value>`
if (typeof val === 'boolean') return `<value><boolean>${val ? 1 : 0}</boolean></value>`
if (Array.isArray(val)) return `<value><array><data>${val.map(xmlValue).join('')}</data></array></value>`
return `<value><string>${String(val)}</string></value>`
}
function xmlCall(method: string, params: any[] = []): string {
return `<?xml version="1.0"?>
<methodCall>
<methodName>${method}</methodName>
<params>${params.map(p => `<param>${xmlValue(p)}</param>`).join('')}</params>
</methodCall>`
}
function parseXmlValue(node: Element): any {
const child = node.firstElementChild
if (!child) return node.textContent ?? ''
switch (child.tagName) {
case 'string' : return child.textContent ?? ''
case 'int' :
case 'i4' :
case 'i8' : return parseInt(child.textContent ?? '0', 10)
case 'double' : return parseFloat(child.textContent ?? '0')
case 'boolean' : return child.textContent === '1'
case 'array' : {
const data = child.querySelector('data')
if (!data) return []
return Array.from(data.children).map(parseXmlValue)
}
case 'struct' : {
const result: Record<string, any> = {}
for (const member of Array.from(child.querySelectorAll(':scope > member'))) {
const name = member.querySelector(':scope > name')?.textContent ?? ''
const value = member.querySelector(':scope > value')
if (name && value) result[name] = parseXmlValue(value)
}
return result
}
default: return child.textContent ?? ''
}
}
async function rpcCall(
config : Record<string, string | number>,
method : string,
params : any[] = [],
): Promise<any> {
const body = xmlCall(method, params)
const headers : Record<string, string> = { 'Content-Type': 'text/xml' }
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const rpcPath = String(config.rpcPath || '/RPC2')
const url = `${String(config.url).replace(/\/+$/, '')}${rpcPath}`
const res = await fetch(url, { method: 'POST', headers, body })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
// Parse XML — Node.js n'a pas DOMParser natif, on fait un parse minimaliste
const faultMatch = text.match(/<name>faultString<\/name>\s*<value><string>([^<]*)<\/string>/)
if (faultMatch) throw new Error(`rTorrent fault : ${faultMatch[1]}`)
const valueMatch = text.match(/<methodResponse>\s*<params>\s*<param>\s*<value>([\s\S]*?)<\/value>\s*<\/param>/)
if (!valueMatch) return null
// Parse simplifié pour les types courants
const inner = valueMatch[1].trim()
// Array
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
return parseInnerValue(inner)
}
function parseInnerValue(inner: string): any {
if (inner.startsWith('<array>')) {
const items = [...inner.matchAll(/<value>([\s\S]*?)<\/value>/g)]
return items.map(m => parseInnerValue(m[1].trim()))
}
const strMatch = inner.match(/^<string>([\s\S]*?)<\/string>$/)
if (strMatch) return strMatch[1]
const intMatch = inner.match(/^<(?:i4|i8|int)>([\s\S]*?)<\/(?:i4|i8|int)>$/)
if (intMatch) return parseInt(intMatch[1], 10)
// Valeur brute sans tag
if (!inner.includes('<')) return inner
return inner
}
// multicall pour récupérer plusieurs champs en une requête
async function d_multicall(
config : Record<string, string | number>,
view : string,
methods: string[],
): Promise<any[][]> {
const params = [view, ...methods.map(m => `${m}=`)]
const result = await rpcCall(config, 'd.multicall2', ['', ...params])
return Array.isArray(result) ? result : []
}
// ─── State mapping ────────────────────────────────────────────
// rTorrent state : is_active (0/1) + is_hash_checking (0/1) + get_complete (0/1)
// + is_open (0/1)
function mapState(isOpen: number, isActive: number, isChecking: number, isComplete: number): TorrentInfo['state'] {
if (isChecking) return 'checking'
if (!isOpen) return 'paused'
if (!isActive) return 'paused'
if (isComplete) return 'seeding'
return 'downloading'
}
const RT: TorrentClientDriver = {
definition: {
id : 'rtorrent',
label : 'rTorrent',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'rpcPath', label: 'Chemin RPC', type: 'text', placeholder: '/RPC2', required: false, default: '/RPC2' },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie (label)', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.info('rtorrent', `Test de connexion réussi sur ${config.url} — version ${version}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('rtorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const version = await rpcCall(config, 'system.client_version')
logger.debug('rtorrent', `Healthcheck OK — version ${version}`)
return { online: true, version: String(version) }
} catch (err) {
logger.debug('rtorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const methods = [
'd.hash',
'd.name',
'd.is_open',
'd.is_active',
'd.is_hash_checking',
'd.complete',
'd.size_bytes',
'd.bytes_done',
'd.down.rate',
'd.left_bytes',
'd.down.rate',
'd.directory',
'd.custom1', // label rTorrent
]
const rows = await d_multicall(config, 'main', methods)
return rows
.map(r => {
const [hash, name, isOpen, isActive, isChecking, isComplete,
size, downloaded, speed, leftBytes, , directory, label] = r
const progress = size > 0 ? Math.min(100, Math.round(((size - leftBytes) / size) * 100)) : 0
const eta = speed > 0 && leftBytes > 0 ? Math.round(leftBytes / speed) : -1
return {
hash : String(hash).toLowerCase(),
name : String(name),
state : mapState(Number(isOpen), Number(isActive), Number(isChecking), Number(isComplete)),
progress,
size : Number(size),
downloaded: Number(downloaded),
speed : Number(speed),
eta,
save_path : String(directory),
category : String(label ?? ''),
} satisfies TorrentInfo
})
.filter(t => {
if (!category) return true
return t.category === category
})
},
async add(config, url) {
// load_start charge et démarre le torrent
const method = url.startsWith('magnet:') ? 'load.start' : 'load.start'
const args: any[] = ['', url]
if (config.savePath) {
args.push(`d.directory.set="${config.savePath}"`)
}
if (config.category) {
args.push(`d.custom1.set=${config.category}`)
}
await rpcCall(config, method, args)
logger.info('rtorrent', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
}
export default RT
+158
View File
@@ -0,0 +1,158 @@
/**
* Synology Download Station Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// Synology Download Station task status codes
function mapState(status: string): TorrentInfo['state'] {
if (status === 'downloading') return 'downloading'
if (status === 'seeding' || status === 'finished') return 'seeding'
if (status === 'paused') return 'paused'
if (status === 'waiting' || status === 'filehosting_waiting') return 'downloading'
if (status === 'hash_checking') return 'checking'
if (status === 'error') return 'error'
return 'unknown'
}
// Authentification DSM — retourne un SID
async function dsLogin(config: Record<string, string | number>): Promise<string> {
const params = new URLSearchParams({
api : 'SYNO.API.Auth',
version: '3',
method : 'login',
account: String(config.username ?? ''),
passwd : String(config.password ?? ''),
session: 'DownloadStation',
format : 'sid',
})
const res = await fetch(`${config.url}/webapi/auth.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Auth échouée : ${JSON.stringify(data.error)}`)
return data.data.sid
}
async function dsRequest(
config : Record<string, string | number>,
api : string,
method : string,
version: string,
extra : Record<string, string> = {},
sid : string,
): Promise<any> {
const params = new URLSearchParams({ api, version, method, _sid: sid, ...extra })
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`DS erreur : ${JSON.stringify(data.error)}`)
return data.data
}
const DS: TorrentClientDriver = {
definition: {
id : 'synology-ds',
label : 'Synology Download Station',
fields: [
{ key: 'url', label: 'URL DSM', type: 'url', placeholder: 'http://192.168.1.x:5000', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/volume1/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/volume1/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await dsLogin(config)
logger.info('synology-ds', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('synology-ds', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const sid = await dsLogin(config)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Info',
version: '2',
method : 'getinfo',
_sid : sid,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/info.cgi?${params}`)
if (!res.ok) return { online: false }
const data = await res.json()
if (!data.success) return { online: false }
const version = data.data?.version_string ?? 'inconnue'
logger.debug('synology-ds', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('synology-ds', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const sid = await dsLogin(config)
const data = await dsRequest(config, 'SYNO.DownloadStation.Task', 'list', '1',
{ additional: 'transfer,detail' }, sid)
const tasks: any[] = data?.tasks ?? []
return tasks
.filter(t => {
if (!category) return true
// Synology n'a pas de labels natifs — on filtre par destination si elle contient la catégorie
return (t.additional?.detail?.destination as string)?.includes(String(category))
})
.map(t => {
const transfer = t.additional?.transfer ?? {}
const size = t.size ?? 0
const dl = transfer.size_downloaded ?? 0
return {
hash : t.id,
name : t.title,
state : mapState(t.status),
progress : size > 0 ? Math.min(100, Math.round((dl / size) * 100)) : 0,
size,
downloaded: dl,
speed : transfer.speed_download ?? 0,
eta : -1,
save_path : t.additional?.detail?.destination ?? '',
category : '',
} satisfies TorrentInfo
})
},
async add(config, url) {
const sid = await dsLogin(config)
const extra: Record<string, string> = { uri: url }
if (config.savePath) extra.destination = String(config.savePath)
const params = new URLSearchParams({
api : 'SYNO.DownloadStation.Task',
version: '1',
method : 'create',
_sid : sid,
...extra,
})
const res = await fetch(`${config.url}/webapi/DownloadStation/task.cgi?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!data.success) throw new Error(`Ajout échoué : ${JSON.stringify(data.error)}`)
logger.info('synology-ds', `Torrent ajouté avec succès${config.savePath ? ` (dossier: ${config.savePath})` : ''}`)
},
}
export default DS
+14 -14
View File
@@ -23,7 +23,7 @@ async function trRequest(
sessionId = ''
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Content-Type' : 'application/json',
'X-Transmission-Session-Id': sessionId,
}
@@ -57,10 +57,13 @@ const TR: TorrentClientDriver = {
id : 'transmission',
label : 'Transmission',
fields: [
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Dossier cible',type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
@@ -89,17 +92,17 @@ const TR: TorrentClientDriver = {
},
async list(config, category) {
const fields = ['hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels']
const fields = [
'hashString', 'name', 'status', 'percentDone', 'totalSize',
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels',
]
const data = await trRequest(config, 'torrent-get', { fields })
const torrents: any[] = data.arguments?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
// Filtrer par label ou par dossier contenant la catégorie
return t.labels?.includes(category) ||
(t.downloadDir as string)?.includes(category)
return t.labels?.includes(category)
})
.map(t => ({
hash : t.hashString,
@@ -120,9 +123,6 @@ const TR: TorrentClientDriver = {
if (config.savePath) {
args['download-dir'] = String(config.savePath)
} else if (config.category) {
// Si pas de savePath explicite, ajouter la catégorie comme sous-dossier
args['download-dir'] = `/sdcard/Download/${config.category}`
}
if (config.category) {
@@ -130,7 +130,7 @@ const TR: TorrentClientDriver = {
}
await trRequest(config, 'torrent-add', args)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
}
+196
View File
@@ -0,0 +1,196 @@
/**
* uTorrent Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
// uTorrent status flags (bitmask) :
// 1 = Started, 2 = Checking, 4 = Start after check, 8 = Checked,
// 16 = Error, 32 = Paused, 64 = Queued, 128 = Loaded
function mapState(status: number): TorrentInfo['state'] {
if (status & 16) return 'error'
if (status & 32) return 'paused'
if (status & 2) return 'checking'
// Démarré + chargé + vérifié → on regarde le ratio de progression
if (status & 1 && status & 8) return 'downloading'
if (status & 64) return 'downloading' // queued
return 'unknown'
}
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
'Authorization': `Basic ${auth}`,
}
const res = await fetch(`${config.url}/gui/token.html`, { headers })
if (!res.ok) throw new Error(`HTTP ${res.status} — token introuvable`)
const text = await res.text()
const match = text.match(/<div[^>]+id=['"]token['"][^>]*>([^<]+)</)
if (!match) throw new Error('Token CSRF introuvable dans la réponse')
const token = match[1].trim()
const cookie = res.headers.get('set-cookie') ?? ''
return { token, cookie }
}
async function utRequest(
config : Record<string, string | number>,
params : Record<string, string>,
session: UTSession,
): Promise<any> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const qs = new URLSearchParams({ token: session.token, ...params })
const res = await fetch(`${config.url}/gui/?${qs}`, {
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`uTorrent HTTP ${res.status}`)
return res.json()
}
function mapTorrent(t: any[]): TorrentInfo {
// uTorrent retourne un tableau par torrent :
// [hash, status, name, size, progress, downloaded, uploaded, ratio,
// ul_speed, dl_speed, eta, label, peers_connected, peers_swarm,
// seeds_connected, seeds_swarm, availability, queue_order, remaining]
const [hash, status, name, size, progress, downloaded, , , , dlSpeed, eta, label] = t
return {
hash : hash.toLowerCase(),
name,
state : progress >= 1000 ? 'seeding' : mapState(status),
progress : Math.min(100, Math.round(progress / 10)),
size,
downloaded,
speed : dlSpeed ?? 0,
eta : eta ?? -1,
save_path : '', // récupéré séparément via torrent properties si besoin
category : label ?? '',
}
}
const UT: TorrentClientDriver = {
definition: {
id : 'utorrent',
label : 'uTorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
{ key: 'localPath', label: 'Chemin local (FanKarr)', type: 'text', placeholder: '/mnt/nas/downloads', required: false },
],
},
async test(config) {
try {
await utGetSession(config)
logger.info('utorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('utorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getsettings' }, session)
const version = data?.['build']?.toString() ?? 'inconnue'
logger.debug('utorrent', `Healthcheck OK — build ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('utorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
const session = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, session)
const torrents: any[][] = data?.torrents ?? []
return torrents
.filter(t => {
if (!category) return true
return t[11] === category // index 11 = label
})
.map(t => {
const info = mapTorrent(t)
// Récupérer le save_path depuis les properties si disponible
const props = data?.props?.find((p: any) => p[0]?.toLowerCase() === info.hash)
if (props) info.save_path = props[1] ?? ''
return info
})
},
async add(config, url) {
const session = await utGetSession(config)
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const form = new FormData()
form.append('torrent_url', url)
const qs = new URLSearchParams({
token : session.token,
action: 'add-url',
s : url,
})
if (config.savePath) {
qs.set('download_dir', '1')
qs.set('path', String(config.savePath))
}
const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
// Appliquer le label/catégorie si défini
if (config.category) {
// On ne peut pas set le label au moment de l'ajout, il faut attendre
// que le torrent soit chargé — on le fait via setprops après un délai
setTimeout(async () => {
try {
const s2 = await utGetSession(config)
const data = await utRequest(config, { list: '1' }, s2)
const torrents: any[][] = data?.torrents ?? []
// Trouver le torrent le plus récemment ajouté sans label
const target = torrents.find(t => !t[11])
if (target) {
await utRequest(config, {
action: 'setprops',
hash : target[0],
s : 'label',
v : String(config.category),
}, s2)
}
} catch {}
}, 3000)
}
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
}
export default UT
+16 -2
View File
@@ -14,6 +14,12 @@
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
@@ -62,7 +68,8 @@
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
<button @click="$emit('select', current)" class="btn-primary">
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
</div>
@@ -85,8 +92,15 @@ const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
emit('select', current.value.replace(/\/+$/, '') || '/')
}
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
if (base === '/') return `/${name}`
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
return `${cleanBase}/${name}`
}
async function navigateTo(p: string) {
+312
View File
@@ -0,0 +1,312 @@
<template>
<Teleport to="body">
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('close')">
<div class="bg-card border border-border rounded-xl w-full max-w-3xl flex flex-col" style="max-height: 85vh">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
<div>
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
<p class="text-xs text-muted mt-0.5">Associez vos fichiers existants aux épisodes du catalogue</p>
</div>
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
</div>
<!-- Étape 1 : Choisir un dossier -->
<div v-if="step === 'folder'" class="flex flex-col gap-4 px-6 py-5">
<p class="text-sm text-secondary">Sélectionnez le dossier contenant vos fichiers vidéo :</p>
<button
@click="pickerOpen = true"
class="settings-input text-left font-mono truncate"
:class="selectedFolder ? 'text-primary' : 'text-muted'"
>
{{ selectedFolder || 'Choisir un dossier…' }}
</button>
<div class="flex gap-2">
<button @click="scanFolder" :disabled="!selectedFolder || scanning" class="btn-primary">
{{ scanning ? 'Scan…' : 'Scanner ce dossier' }}
</button>
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
</div>
</div>
<!-- Étape 2 : Matcher les fichiers -->
<template v-else-if="step === 'match'">
<!-- Toolbar -->
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
<p class="text-xs text-muted">
<span class="text-primary font-medium">{{ files.length }}</span> fichier{{ files.length > 1 ? 's' : '' }} trouvé{{ files.length > 1 ? 's' : '' }}
· <span class="text-green-400">{{ matchedCount }} matché{{ matchedCount > 1 ? 's' : '' }}</span>
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non matché{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
</p>
<button @click="step = 'folder'" class="text-xs text-muted hover:text-primary transition-colors">← Changer de dossier</button>
</div>
<!-- Liste fichiers -->
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
<div
v-for="(item, i) in items"
:key="item.file.path"
class="flex items-center gap-3 p-3 rounded-lg border transition-colors"
:class="item.episode_id
? 'border-green-500/20 bg-green-500/5'
: 'border-border bg-shell'"
>
<!-- Icône état -->
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
:class="item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
<svg v-if="item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
</div>
<!-- Nom fichier -->
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
<p class="text-[11px] text-muted mt-0.5">{{ formatSize(item.file.size) }}</p>
</div>
<!-- Sélecteur épisode -->
<select
v-model="items[i].episode_id"
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
@change="items[i].hash = getHashForEpisode(Number(items[i].episode_id))"
>
<option :value="null">— Non associé —</option>
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
<option
v-for="ep in season.episodes"
:key="ep.id"
:value="ep.id"
:disabled="isEpisodeAlreadyUsed(ep.id, i)"
>
E{{ String(ep.episode_number).padStart(2, '0') }} — {{ ep.title || `Épisode ${ep.episode_number}` }}
{{ isEpisodeOrganized(ep.id) ? '✓' : '' }}
</option>
</optgroup>
</select>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
<p v-else class="text-xs text-muted">
Seuls les fichiers associés à un épisode seront importés.
</p>
<div class="flex gap-2">
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
<button
@click="doImport"
:disabled="matchedCount === 0 || importing"
class="btn-primary"
>
{{ importing ? 'Import…' : `Importer ${matchedCount} fichier${matchedCount > 1 ? 's' : ''}` }}
</button>
</div>
</div>
</template>
<!-- Étape 3 : Résultat -->
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
</div>
<div>
<p class="text-sm font-semibold text-primary">Import terminé</p>
<p class="text-xs text-muted mt-1">
{{ importResult.done }} fichier{{ importResult.done > 1 ? 's' : '' }} importé{{ importResult.done > 1 ? 's' : '' }}
<span v-if="importResult.errors.length > 0" class="text-red-400">
· {{ importResult.errors.length }} erreur{{ importResult.errors.length > 1 ? 's' : '' }}
</span>
</p>
</div>
<div v-if="importResult.errors.length > 0" class="w-full text-left bg-red-500/5 border border-red-500/20 rounded-lg p-3">
<p class="text-xs text-red-400 font-medium mb-1.5">Erreurs</p>
<div v-for="e in importResult.errors" :key="e.file" class="text-xs text-muted mb-1">
<span class="text-primary font-mono">{{ e.file }}</span> — {{ e.error }}
</div>
</div>
<button @click="$emit('close'); $emit('imported')" class="btn-primary">Fermer</button>
</div>
</div>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="pickerOpen"
:initial-path="selectedFolder || '/'"
@select="onFolderSelect"
@cancel="pickerOpen = false"
/>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import FolderPicker from '@/components/FolderPicker.vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
const props = defineProps<{
serieId : number
serieName: string
seasons : any[]
organized: Record<string, any> // episode_id → entry
}>()
const emit = defineEmits<{
close : []
imported: []
}>()
// ─── State ────────────────────────────────────────────────────
const step = ref<'folder' | 'match' | 'done'>('folder')
const pickerOpen = ref(false)
const selectedFolder = ref('')
const scanning = ref(false)
const importing = ref(false)
const importError = ref('')
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
interface FileItem {
file : { name: string; path: string; size: number }
episode_id: number | null
hash : string | null
}
const files = ref<{ name: string; path: string; size: number }[]>([])
const items = ref<FileItem[]>([])
// ─── Computed ─────────────────────────────────────────────────
const matchedCount = computed(() => items.value.filter(i => i.episode_id !== null).length)
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
// ─── Helpers ──────────────────────────────────────────────────
function isEpisodeOrganized(epId: number): boolean {
return !!props.organized?.[String(epId)]
}
function isEpisodeAlreadyUsed(epId: number, currentIndex: number): boolean {
return items.value.some((item, i) => i !== currentIndex && item.episode_id === epId)
}
function getHashForEpisode(episodeId: number): string | null {
for (const season of props.seasons) {
for (const ep of season.episodes) {
if (ep.id !== episodeId) continue
for (const p of ep.paths ?? []) {
if (typeof p === 'object' && p.infohash) return p.infohash.toLowerCase()
}
}
}
return null
}
// Auto-match : on essaie de matcher par original_filename, nfo_filename, formatted_name
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
const lower = filename.toLowerCase()
for (const season of props.seasons) {
for (const ep of season.episodes) {
const candidates = [
ep.original_filename,
ep.nfo_filename?.replace(/\.nfo$/, '.mkv'),
ep.formatted_name ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null,
].filter(Boolean).map((s: string) => s.toLowerCase())
if (candidates.some(c => c === lower || lower.includes(c.replace(/\.mkv$/, '')))) {
const hash = getHashForEpisode(ep.id)
return { episode_id: ep.id, hash }
}
}
}
// Fallback : match par numéro d'épisode dans le nom de fichier
const numMatch = filename.match(/(?:e|ep|episode|épisode)[.\s_-]?0*(\d+)/i)
?? filename.match(/\b0*(\d{1,3})\b/)
if (numMatch) {
const num = parseInt(numMatch[1], 10)
for (const season of props.seasons) {
const ep = season.episodes.find((e: any) => e.episode_number === num)
if (ep) {
const hash = getHashForEpisode(ep.id)
return { episode_id: ep.id, hash }
}
}
}
return { episode_id: null, hash: null }
}
function formatSize(bytes: number): string {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
}
// ─── Actions ──────────────────────────────────────────────────
function onFolderSelect(p: string) {
selectedFolder.value = p
pickerOpen.value = false
}
async function scanFolder() {
if (!selectedFolder.value) return
scanning.value = true
try {
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(selectedFolder.value)}`, { credentials: 'include' })
if (!res.ok) { toast('Erreur lors du scan', 'error'); return }
const data = await res.json()
files.value = data.files ?? []
items.value = files.value.map(f => {
const match = autoMatch(f.name)
return { file: f, ...match }
})
step.value = 'match'
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
async function doImport() {
importing.value = true
importError.value = ''
const payload = items.value
.filter(i => i.episode_id !== null)
.map(i => ({
file_path : i.file.path,
episode_id : i.episode_id,
hash : i.hash,
}))
try {
const res = await fetch('/api/manual-import', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
})
const data = await res.json()
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
importResult.value = { done: data.done, errors: data.errors ?? [] }
step.value = 'done'
} catch {
importError.value = 'Impossible de contacter le serveur'
} finally {
importing.value = false
}
}
</script>
+23 -8
View File
@@ -18,6 +18,13 @@
<button @click="importAll" :disabled="importingAll" class="btn-primary">
{{ importingAll ? '...' : 'Importer tout' }}
</button>
<button @click="fetchTorrents" :disabled="loading" class="btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none"
:class="loading ? 'animate-spin' : ''">
<path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.36-2.64M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.36 2.64"/>
<polyline points="16 5 21 5 21 10"/>
</svg>
</button>
<button @click="togglePolling" class="btn-secondary">
{{ polling ? 'Pause' : 'Reprendre' }}
</button>
@@ -159,7 +166,7 @@
</div>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-semibold text-primary">{{ t.progress }}%</p>
<p class="text-sm font-semibold text-primary">{{ clampedProgress(t) }}%</p>
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
</div>
</div>
@@ -168,10 +175,10 @@
<div class="h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' ? 'bg-green-500' :
:class="t.state === 'seeding' || t.state === 'unknown' ? 'bg-green-500' :
t.state === 'error' ? 'bg-red-500' :
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
:style="{ width: `${t.progress}%` }"
:style="{ width: `${clampedProgress(t)}%` }"
/>
</div>
@@ -181,9 +188,10 @@
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
<span v-if="t.state === 'seeding'" class="text-green-500">Complété</span>
<span v-if="t.state === 'unknown'" class="text-yellow-500">État non remonté par le client</span>
</div>
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
<div v-if="t.state === 'seeding' || t.state === 'unknown'" class="flex items-center gap-2">
<!-- Erreurs fichiers -->
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
@@ -275,12 +283,13 @@ function setSort(val: typeof activeSort.value) {
let pollInterval: ReturnType<typeof setInterval> | null = null
// FIX : unknown retiré de activeTorrents, ajouté dans doneTorrents
const activeTorrents = computed(() =>
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
)
const doneTorrents = computed(() =>
torrents.value.filter(t => t.state === 'seeding')
torrents.value.filter(t => t.state === 'seeding' || t.state === 'unknown')
)
const visibleTorrents = computed(() => {
@@ -320,6 +329,11 @@ const stats = computed(() => [
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
])
// FIX : progress cappé à 100 pour éviter "278.2 GB / 278.1 GB" → 100%
function clampedProgress(t: any): number {
return Math.min(100, Math.max(0, t.progress ?? 0))
}
async function fetchTorrents() {
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
@@ -335,7 +349,7 @@ async function fetchTorrents() {
function startPolling() {
fetchTorrents()
pollInterval = setInterval(fetchTorrents, 3000)
pollInterval = setInterval(fetchTorrents, 60_000)
}
function stopPolling() {
@@ -406,7 +420,8 @@ function stateBadge(state: string) {
paused : { label: 'En pause', class: 'border-border text-muted' },
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
unknown : { label: 'Inconnu', class: 'border-border text-muted' },
// FIX : badge jaune pour signaler état dégradé sans alarmer
unknown : { label: 'État inconnu', class: 'border-yellow-500/40 text-yellow-500' },
}
return map[state] ?? map.unknown
}
+31
View File
@@ -61,7 +61,25 @@
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!--
<button
@click="manualImportOpen = true"
class="inline-flex items-center gap-2 px-4 py-2 text-sm rounded-lg border border-border text-muted hover:text-primary hover:border-secondary transition"
>
Import manuel
</button>
<ManualImportModal
v-if="manualImportOpen && data"
:serie-id="Number(route.params.id)"
:serie-name="data.serie.title"
:seasons="data.seasons"
:organized="organizedByEpisode"
@close="manualImportOpen = false"
@imported="load(); manualImportOpen = false"
/>
-->
<!-- Boutons intégrale -->
<div class="flex flex-wrap gap-2 mt-1">
<button
@@ -243,6 +261,7 @@ import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue
import { useSeriesStore } from '@/stores/series'
import { useToast } from '@/composables/useToast'
import type { Season } from '@/stores/series'
import ManualImportModal from "@/components/ManualImportModal.vue";
// ── Icône d'état du bouton ────────────────────────────────────
// states: 'idle' | 'loading' | 'done' | 'unavailable'
@@ -297,6 +316,17 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
const data = computed(() => store.currentSerie)
const manualImportOpen = ref(false)
const organizedByEpisode = ref<Record<string, any>>({})
// Dans onMounted, après load() :
async function fetchOrganized() {
try {
const res = await fetch('/api/organized', { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
}
// ── Polling downloads ─────────────────────────────────────────
async function fetchActiveDownloads() {
try {
@@ -408,6 +438,7 @@ function formatDuration(seconds: number): string {
onMounted(() => {
load()
// fetchOrganized()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
})
+57 -9
View File
@@ -34,6 +34,7 @@
</div>
<div class="flex items-center gap-1">
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
</div>
</div>
@@ -49,7 +50,7 @@
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">Ajouter un client</h3>
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
</div>
@@ -66,7 +67,10 @@
</select>
</div>
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
<label class="settings-label">{{ field.label }}</label>
<label class="settings-label">
{{ field.label }}
<span v-if="field.required" class="text-red-400 ml-0.5">*</span>
</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
@@ -105,7 +109,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
@@ -116,6 +120,7 @@ const healthStatus = ref<Record<string, boolean | null>>({})
const modal = ref({
open : false,
uuid : null as string | null,
name : '',
type : '',
config : {} as Record<string, string>,
@@ -171,7 +176,20 @@ async function deleteClient(uuid: string) {
}
function openAddModal() {
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
modal.value = { open: true, uuid: null, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
}
function editClient(client: any) {
modal.value = {
open : true,
uuid : client.uuid,
name : client.name,
type : client.type,
config : JSON.parse(JSON.stringify(client.config)),
testing: false,
saving : false,
tested : true,
}
}
function closeModal() { modal.value.open = false }
@@ -187,13 +205,34 @@ function onTypeChange() {
}
}
// Reset tested dès que l'utilisateur modifie un champ (nom ou config)
// sauf si c'est un mot de passe masqué non touché
watch(() => modal.value.name, () => {
if (modal.value.uuid) modal.value.tested = false
})
watch(() => modal.value.config, (newConfig) => {
if (!modal.value.uuid) return
const driver = currentDefinition.value
if (!driver) return
// Si au moins un champ non-password a changé → reset tested
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) {
const field = driver.fields.find(f => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
return
}
}, { deep: true })
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config }),
body: JSON.stringify({ type: modal.value.type, config: modal.value.config, uuid: modal.value.uuid }),
})
const { ok, message } = await res.json()
modal.value.tested = ok
@@ -209,8 +248,12 @@ async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const res = await fetch('/api/torrent-clients', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
const isEdit = !!modal.value.uuid
const method = isEdit ? 'PUT' : 'POST'
const url = isEdit ? `/api/torrent-clients/${modal.value.uuid}` : '/api/torrent-clients'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
@@ -219,10 +262,15 @@ async function saveClient() {
})
if (res.ok) {
const client = await res.json()
clients.value.push(client)
if (isEdit) {
const index = clients.value.findIndex(c => c.uuid === client.uuid)
if (index !== -1) clients.value[index] = client
} else {
clients.value.push(client)
}
refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
toast(isEdit ? 'Client modifié ✓' : 'Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
+32 -6
View File
@@ -15,10 +15,13 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="form.completePath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
@@ -27,14 +30,29 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="form.mediaPath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
@@ -109,7 +127,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
@@ -135,6 +153,15 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
currentPath: '/',
})
// FIX : détection path non configuré (vide ou racine /)
function isRootPath(p: string): boolean {
return !p || p === '/'
}
const hasUnconfiguredPaths = computed(() =>
isRootPath(form.value.completePath) || isRootPath(form.value.mediaPath)
)
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
}
@@ -153,7 +180,6 @@ onMounted(async () => {
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
})
async function save() {