Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d426662fd6 | ||
|
|
bf0285378d | ||
|
|
bde760e8b4 | ||
|
|
a358ee2d83 | ||
|
|
559ce91954 | ||
|
|
b5bd8b9b48 | ||
|
|
9576ec9b23 | ||
|
|
a582991c15 | ||
|
|
2d0abab222 | ||
|
|
8ec323dc95 | ||
|
|
630374def7 | ||
|
|
0e09c85efd | ||
|
|
89c0db5f36 | ||
|
|
debf61f714 | ||
|
|
0c53c1d32b | ||
|
|
d563ed4c68 | ||
|
|
3b39a5a8c4 | ||
|
|
50144c70dc | ||
|
|
482743bf3a | ||
|
|
f3a36af629 | ||
|
|
bc960ba585 | ||
|
|
e648764715 | ||
|
|
15fac6c472 | ||
|
|
61591020be | ||
|
|
f92b3c5fb9 | ||
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 | ||
|
|
7b780e4fa1 | ||
|
|
444b30ef3d | ||
|
|
42b46496d1 | ||
|
|
f092da4108 |
No files matched your search
@@ -48,6 +48,12 @@ jobs:
|
||||
bun-target: bun-windows-x64
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64.zip
|
||||
# Baseline pour les CPUs Windows anciens (sans AVX2, avant ~2013)
|
||||
- target: windows-x64-baseline
|
||||
os: windows-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,13 +96,13 @@ 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 }}
|
||||
@@ -106,12 +112,12 @@ jobs:
|
||||
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/
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
|
||||
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
|
||||
|
||||
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
|
||||
|
||||
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
|
||||
|
||||
|
||||
|
||||
+347
-42
@@ -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
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}` : ''})`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}` : ''})`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
@@ -129,16 +147,23 @@ const form = ref({
|
||||
autoImport : true,
|
||||
})
|
||||
|
||||
const { data: systemInfo } = await useFetch('/api/system/info')
|
||||
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
open: false,
|
||||
field: 'completePath',
|
||||
currentPath: systemInfo.value?.defaultPath ?? '/',
|
||||
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 = { open: true, field, currentPath: form.value[field] || picker.value.currentPath || '/' }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
@@ -147,12 +172,14 @@ function onPickerSelect(path: string) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes] = await Promise.all([
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
])
|
||||
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() {
|
||||
|
||||
Reference in new issue
Block a user