Compare commits

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

FolderPicker : Ajouter un bouton "accueil" pour naviguer vers la racine, nettoyer le chemin sélectionné en supprimant les slashes de fin (avec un repli sur "/" pour la racine), et fiabiliser joinPath pour éviter les doubles slashes lorsque la base se termine par un slash
2026-04-08 14:12:01 +02:00
MasutaYunikon a39a98615b Merge pull request #69 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.3
2026-04-08 10:04:19 +02:00
MasutaYunikon 2a3defdb34 chore(main): release 3.3.3 2026-04-07 20:07:35 +02:00
MasutaYunikon 733f8790ca fix: path 2026-04-07 20:07:03 +02:00
MasutaYunikon 4a9bbd9402 Merge remote-tracking branch 'origin/main' 2026-04-07 19:53:19 +02:00
MasutaYunikon 1562ca32f9 fix: MediaManagementView.vue api call is now in mounted 2026-04-07 19:53:10 +02:00
MasutaYunikon 7b780e4fa1 Merge pull request #68 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.2
2026-04-07 19:42:14 +02:00
MasutaYunikon 444b30ef3d chore(main): release 3.3.2 2026-04-07 19:41:28 +02:00
MasutaYunikon 42b46496d1 Merge remote-tracking branch 'origin/main' 2026-04-07 19:40:57 +02:00
MasutaYunikon f092da4108 fix: use fetch instead of useFetch 2026-04-07 19:40:49 +02:00
MasutaYunikon 8e635c1d28 Merge pull request #67 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.1
2026-04-07 19:24:53 +02:00
MasutaYunikon 3b8b7a0454 chore(main): release 3.3.1 2026-04-07 19:24:23 +02:00
MasutaYunikon 9ef1516b0b Merge remote-tracking branch 'origin/main' 2026-04-07 19:23:57 +02:00
MasutaYunikon 1af210058c fix: add driver register for transmission 2026-04-07 19:23:49 +02:00
MasutaYunikon 6caad83081 Merge pull request #66 from Masutayunikon/release-please--branches--main
chore(main): release 3.3.0
2026-04-07 19:05:43 +02:00
MasutaYunikon 520bfcfae1 chore(main): release 3.3.0 2026-04-07 19:05:07 +02:00
MasutaYunikon 2e5bc8953a Merge remote-tracking branch 'origin/main' 2026-04-07 19:04:34 +02:00
MasutaYunikon f304562f01 feat: add transmission client
feat: set defaultPath for any system
2026-04-07 19:04:26 +02:00
MasutaYunikon c9690d9a1b Merge pull request #65 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.1
2026-04-07 16:18:47 +02:00
MasutaYunikon 5daface279 chore(main): release 3.2.1 2026-04-07 16:17:52 +02:00
MasutaYunikon 5687aaa5d9 Merge remote-tracking branch 'origin/main' 2026-04-07 16:17:22 +02:00
MasutaYunikon ba67817ea0 fix: bun target android 2026-04-07 16:17:13 +02:00
MasutaYunikon e0452dbd0c Merge pull request #64 from Masutayunikon/release-please--branches--main
chore(main): release 3.2.0
2026-04-07 16:14:55 +02:00
MasutaYunikon c376061333 chore(main): release 3.2.0 2026-04-07 16:14:30 +02:00
MasutaYunikon bb02ea6cf8 fix: android build name 2026-04-07 16:13:57 +02:00
MasutaYunikon 887beae614 Merge remote-tracking branch 'origin/main' 2026-04-07 16:11:07 +02:00
MasutaYunikon 1290d5c925 feat: add build for arm64
feat: add developer settings on settings and dashboard debug
2026-04-07 16:10:57 +02:00
MasutaYunikon dffb340250 Merge pull request #63 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.1
2026-03-28 10:45:16 +01:00
MasutaYunikon 059298f537 chore(main): release 3.1.1 2026-03-28 10:44:47 +01:00
MasutaYunikon 0ba2144f2f Merge remote-tracking branch 'origin/main' 2026-03-28 10:44:27 +01:00
MasutaYunikon 83a2fb9860 fix: remove plex settings 2026-03-28 10:44:13 +01:00
MasutaYunikon 5445fadf94 Merge remote-tracking branch 'origin/main' 2026-03-28 10:36:26 +01:00
MasutaYunikon 6d110b86dd fix: torrent for multiple seasons is now working 2026-03-28 10:36:16 +01:00
MasutaYunikon 2ef6d4738a Merge pull request #62 from Masutayunikon/release-please--branches--main
chore(main): release 3.1.0
2026-03-27 22:42:34 +01:00
MasutaYunikon 65f7d4e109 chore(main): release 3.1.0 2026-03-27 22:40:50 +01:00
MasutaYunikon 6166c370ef Merge remote-tracking branch 'origin/main' 2026-03-27 22:40:28 +01:00
MasutaYunikon ad1cade6bf feat: use original_name and formated_name from scraper to bypass timeout 2026-03-27 22:40:21 +01:00
19 changed files with 767 additions and 192 deletions

No files matched your search

+5
View File
@@ -53,6 +53,11 @@ jobs:
bun-target: bun-linux-x64
binary: binaries/fankarr
archive: fankarr-linux-x64.zip
- target: linux-arm64
os: ubuntu-latest
bun-target: bun-linux-arm64
binary: binaries/fankarr
archive: fankarr-linux-arm64-termux.zip
- target: macos
os: macos-latest
bun-target: bun-darwin-arm64
+82
View File
@@ -1,5 +1,87 @@
# Changelog
## [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)
### Bug Fixes
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
### Features
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
### Bug Fixes
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
### Features
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
### Bug Fixes
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
### Bug Fixes
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
### Features
* use original_name and formated_name from scraper to bypass timeout ([1b693fc](https://github.com/Masutayunikon/FanKarr/commit/1b693fc53b9ca3a830c952c3f2c101bf1da7d27e))
## [3.0.0](https://github.com/Masutayunikon/FanKarr/compare/v2.4.1...v3.0.0) (2026-03-27)
+76 -32
View File
@@ -7,14 +7,17 @@ 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 { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import transmissionDriver from './torrent-clients/transmission.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'
import { systemInfo } from './system.js'
registerDriver(qbittorrentDriver)
registerDriver(transmissionDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
@@ -36,13 +39,16 @@ 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 ───────────────────────────────────────────────────
app.get('/api/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles } = req.body
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles })
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode } = req.body
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
logger.info('api', 'Paramètres mis à jour')
res.json(updated)
})
@@ -65,11 +71,31 @@ 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 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) => {
@@ -187,36 +213,10 @@ function normalizeEpisode(ep: any): any {
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
}
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
return Promise.all(seriesData.map(async (sd) => {
if (!sd) return sd
const [serieApi, enrichedSeasons] = await Promise.all([
fankaiGet(`/series/${sd.id}`).catch(() => null),
Promise.all((sd.seasons ?? []).map(async (season: any) => {
let epsData: any[] = []
try {
let lastErr: any
for (let attempt = 0; attempt < 2; attempt++) {
try { const res = await fankaiGet(`/seasons/${season.id}/episodes`); epsData = Array.isArray(res) ? res : (res.episodes ?? []); break }
catch (e) { lastErr = e; if (attempt === 0) await new Promise(r => setTimeout(r, 500)) }
}
if (epsData.length === 0 && lastErr) throw lastErr
} catch { return season }
const origMap = new Map<number, { original_filename: string; formatted_name: string }>()
for (const ep of epsData) {
if (ep.id) origMap.set(ep.id, { original_filename: ep.original_filename ?? '', formatted_name: ep.formatted_name ?? '' })
}
return { ...season, episodes: (season.episodes ?? []).map((ep: any) => ({ ...ep, original_filename: origMap.get(ep.id)?.original_filename ?? null, formatted_name: origMap.get(ep.id)?.formatted_name ?? null })) }
}))
])
return { ...sd, title_for_plex: serieApi?.title_for_plex ?? null, seasons: enrichedSeasons }
}))
}
async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
return enrichSeriesDataWithOriginalFilenames(allSd.filter(Boolean))
return allSd.filter(Boolean)
}
function computeSerieDownloadState(serieData: any | null, organized: Record<string, Record<string, string>>, activeTorrents: Set<string>): 'none' | 'downloading' | 'partial' | 'complete' {
@@ -482,6 +482,50 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
}
})
// ── Debug (dev mode uniquement) ───────────────────────────────
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)
}
} 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`,
uptimeSeconds: uptimeS,
worker: {
running: workerRunning,
},
organized: {
trackedFiles: organizedCount,
},
requests: {
notifs: recentOrganized.length,
},
})
})
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
}
+61 -34
View File
@@ -13,19 +13,18 @@ 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 }) }
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean; usePlexTitles: boolean } {
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false }
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false, usePlexTitles: false } }
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
@@ -49,26 +48,36 @@ function isOrganized(hash: string, filename: string): boolean {
// ─── 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) {
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
if (t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
)
if (seasonsWithHash.length === 1) {
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) {
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: t, serieData: sd }
}
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd }
}
const matchingEpisodes = season.episodes?.filter((ep: any) =>
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
) ?? []
if (matchingEpisodes.length === 1) {
const ep = matchingEpisodes[0]
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} else if (matchingEpisodes.length > 1) {
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
return { torrent: { ...t, _season: season }, serieData: sd }
}
}
@@ -81,8 +90,9 @@ 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; nfo_filename: string; fullPath: string }> {
// FIX : type de retour utilise nfo_filename au lieu de original_filename
const map = new Map<string, { season_number: number; nfo_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) {
@@ -108,17 +118,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)
// FIX : priorité à nfo_filename, fallback sur formatted_name puis filename brut
// On remplace l'extension du nfo_filename par celle du fichier source (.nfo → .mkv)
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,
nfo_filename : destName,
fullPath : matchedPath,
})
}
}
@@ -225,7 +239,18 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string { return `Saison ${n}` }
// FIX : remplace l'extension d'un nom de fichier par celle du fichier source
// ex: "[Shalouf] Dandadan 01.nfo" + ".mkv" → "[Shalouf] Dandadan 01.mkv"
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 }
@@ -255,13 +280,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
}
const { torrent, serieData } = found
const { usePlexTitles } = readSettings()
const rawTitle = serieData.title ?? serieData.show_title ?? name
const serieTitle = sanitizeDirName(
usePlexTitles && serieData.title_for_plex
? serieData.title_for_plex
: rawTitle
)
const serieTitle = sanitizeDirName(rawTitle)
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
@@ -283,9 +303,15 @@ 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)
// FIX : priorité à nfo_filename pour épisode unique aussi
// On swap l'extension (.nfo → extension du fichier source)
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)
@@ -334,7 +360,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) {
// FIX : destructure nfo_filename au lieu de original_filename
for (const [filename, { season_number, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue }
const candidates = [
@@ -353,7 +380,7 @@ 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 }
@@ -367,7 +394,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
}
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}`)
+4 -3
View File
@@ -13,7 +13,7 @@ import { readSettings } from './settings.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
let workerRunning = false
export let workerRunning = false
const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string {
@@ -211,7 +211,8 @@ export async function autoOrganizeAll(
workerRunning = false
})
worker.on('exit', (code) => {
if (code !== 0) logger.error('organize', `Worker exit avec code ${code}`)
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
else logger.debug('organize', `Worker terminé (code ${code})`)
workerRunning = false
})
@@ -261,7 +262,7 @@ export async function organizeTorrent(
reject(err)
})
worker.on('exit', (code) => {
if (code !== 0) reject(new Error(`Worker exit ${code}`))
if (code > 1) reject(new Error(`Worker exit inattendu: code ${code}`))
})
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
+14 -14
View File
@@ -5,23 +5,23 @@ import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'move'
category : string
nfoSupport : boolean
autoImport : boolean
usePlexTitles : boolean // utilise title_for_plex pour les noms de dossiers
mediaPath : string
completePath : string
organizeMode : 'hardlink' | 'move'
category : string
nfoSupport : boolean
autoImport : boolean
devMode : boolean
}
const defaults: Settings = {
mediaPath : '',
completePath : '',
organizeMode : 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
usePlexTitles: false,
mediaPath : '',
completePath: '',
organizeMode: 'hardlink',
category : 'fankai',
nfoSupport : false,
autoImport : true,
devMode : false,
}
export function readSettings(): Settings {
+15
View File
@@ -0,0 +1,15 @@
import fs from 'fs'
import os from 'os'
import type { Request, Response } from 'express'
// GET /api/system/info
export function systemInfo(_req: Request, res: Response): void {
const isTermux = fs.existsSync('/data/data/com.termux')
const platform = os.platform()
res.json({
platform,
isTermux,
defaultPath: isTermux ? '/sdcard/' : '/',
})
}
+24
View File
@@ -103,6 +103,30 @@ export function addClient(name: string, type: string, config: Record<string, str
return client
}
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
const oldClient = clients[index]
const definition = getDriver(type)?.definition
// Conserver les mots de passe si "••••••••" est envoyé
if (definition) {
for (const field of definition.fields) {
if (field.type === 'password' && config[field.key] === '••••••••') {
config[field.key] = oldClient.config[field.key]
}
}
}
const client: SavedClient = { uuid, name, type, config }
clients[index] = client
saveClients(clients)
logger.info('torrent-clients', `Client mis à jour : "${name}" (${type})`)
return client
}
export function removeClient(uuid: string): boolean {
const clients = loadClients()
const target = clients.find(c => c.uuid === uuid)
+135
View File
@@ -0,0 +1,135 @@
/**
* Transmission Driver
*/
import type { TorrentClientDriver, TorrentInfo } from './index.js'
import { logger } from '../logger.js'
function mapState(status: number): TorrentInfo['state'] {
// Transmission status codes :
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
// 4 = downloading, 5 = seed queued, 6 = seeding
if (status === 4 || status === 3) return 'downloading'
if (status === 6 || status === 5) return 'seeding'
if (status === 0) return 'paused'
if (status === 1 || status === 2) return 'checking'
return 'unknown'
}
async function trRequest(
config: Record<string, string | number>,
method: string,
args: Record<string, unknown> = {},
sessionId = ''
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
const headers: Record<string, string> = {
'Content-Type' : 'application/json',
'X-Transmission-Session-Id': sessionId,
}
if (config.username && config.password) {
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
}
const res = await fetch(`${config.url}/transmission/rpc`, {
method : 'POST',
headers,
body : JSON.stringify({ method, arguments: args }),
})
// Transmission renvoie 409 avec le session ID à la première requête
if (res.status === 409) {
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
return trRequest(config, method, args, newSessionId)
}
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
const data = await res.json()
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
return { ...data, sessionId }
}
const TR: TorrentClientDriver = {
definition: {
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: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
],
},
async test(config) {
try {
await trRequest(config, 'session-get')
logger.info('transmission', `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('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const data = await trRequest(config, 'session-get')
const version = data.arguments?.version ?? 'inconnue'
logger.debug('transmission', `Healthcheck OK — version ${version}`)
return { online: true, version }
} catch (err) {
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
return { online: false }
}
},
async list(config, category) {
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 uniquement, comme qBittorrent filtre par catégorie
return t.labels?.includes(category)
})
.map(t => ({
hash : t.hashString,
name : t.name,
state : mapState(t.status),
progress : Math.round(t.percentDone * 100),
size : t.totalSize,
downloaded: t.downloadedEver,
speed : t.rateDownload,
eta : t.eta ?? -1,
save_path : t.downloadDir,
category : t.labels?.[0] ?? '',
} satisfies TorrentInfo))
},
async add(config, url) {
const args: Record<string, unknown> = { filename: url }
if (config.savePath) {
args['download-dir'] = String(config.savePath)
}
if (config.category) {
args['labels'] = [String(config.category)]
}
await trRequest(config, 'torrent-add', args)
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
},
}
export default TR
+16 -2
View File
@@ -14,6 +14,12 @@
<!-- Chemin éditable -->
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" title="Retour à la racine">
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</button>
<input
v-model="inputPath"
@keydown.enter="navigateTo(inputPath)"
@@ -62,7 +68,8 @@
<!-- Footer -->
<div class="flex items-center justify-between px-5 py-4 border-t border-border shrink-0">
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
<button @click="$emit('select', current)" class="btn-primary">
<!-- FIX : regex sortie dans selectCurrent() pour éviter l'erreur de parsing Vue -->
<button @click="selectCurrent()" class="btn-primary">
Choisir
</button>
</div>
@@ -85,8 +92,15 @@ const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
emit('select', current.value.replace(/\/+$/, '') || '/')
}
function joinPath(base: string, name: string): string {
return base === '/' ? `/${name}` : `${base}/${name}`
if (base === '/') return `/${name}`
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
return `${cleanBase}/${name}`
}
async function navigateTo(p: string) {
+1
View File
@@ -74,6 +74,7 @@ const navItems = computed<NavItem[]>(() => [
{ label: 'Gestion des médias', to: '/settings/media-management' },
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
{ label: 'Journaux', to: '/settings/logs' },
{ label: 'Avancé', to: '/settings/advanced' },
],
},
])
+5
View File
@@ -73,6 +73,11 @@ const router = createRouter({
name: 'settings-logs',
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
},
{
path: 'advanced',
name: 'settings-advanced',
component: () => import('@/views/settings/AdvancedView.vue'),
},
],
},
],
+85 -70
View File
@@ -11,9 +11,7 @@
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Importées</span>
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
<span class="text-xs text-muted">
{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue
</span>
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">En cours</span>
@@ -22,28 +20,21 @@
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Erreurs</span>
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">
{{ stats.errors }}
</span>
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="grid md:grid-cols-2 gap-4 mb-6">
<!-- Téléchargements actifs -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">
Aucun téléchargement en cours
</div>
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
<div v-else class="flex flex-col gap-3">
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
<div class="flex items-center justify-between gap-2">
@@ -51,17 +42,11 @@
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
</div>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
:class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'"
:style="{ width: `${t.progress}%` }"
/>
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
</div>
<div class="flex items-center justify-between">
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">
{{ formatEta(t.eta) }}
</span>
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
</div>
</div>
</div>
@@ -70,28 +55,16 @@
<!-- Derniers imports -->
<div class="settings-card flex flex-col gap-3">
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
Chargement...
</div>
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">
Aucun import récent
</div>
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
<div v-else class="flex flex-col divide-y divide-border/50">
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
<div
class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5"
:class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'"
>
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" 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="#f87171" stroke-width="2.5" fill="none">
<line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/>
</svg>
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" 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="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary truncate">{{ n.name }}</p>
@@ -106,6 +79,51 @@
</div>
</div>
<!-- Panneau debug -->
<div v-if="devMode" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-medium text-secondary">Debug</h2>
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
</div>
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Heap utilisé</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">RSS (process)</span>
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
<span class="text-xs text-muted">mémoire système</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Uptime</span>
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
<span class="text-xs text-muted">depuis le démarrage</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Cache GitHub</span>
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Fichiers trackés</span>
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
<span class="text-xs text-muted">dans organized.json</span>
</div>
<div class="settings-card flex flex-col gap-1">
<span class="text-xs text-muted">Worker organize</span>
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
</span>
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
</div>
</div>
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
</div>
</div>
</template>
@@ -114,15 +132,26 @@ import { ref, onMounted, onUnmounted } from 'vue'
const loadingTorrents = ref(true)
const loadingNotifs = ref(true)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const devMode = ref(false)
const debug = ref<any>(null)
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
const activeTorrents = ref<any[]>([])
const recentNotifs = ref<any[]>([])
let interval: ReturnType<typeof setInterval> | null = null
async function fetchAll() {
await Promise.all([fetchStats(), fetchTorrents(), fetchNotifs()])
async function fetchSettings() {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
if (devMode.value) fetchDebug()
}
}
async function fetchDebug() {
const res = await fetch('/api/debug/stats', { credentials: 'include' })
if (res.ok) debug.value = await res.json()
}
async function fetchStats() {
@@ -132,18 +161,12 @@ async function fetchStats() {
fetch('/api/series', { credentials: 'include' }),
fetch('/api/downloads', { credentials: 'include' }),
])
if (statusRes.ok) {
const s = await statusRes.json()
stats.value.catalogue = s.count ?? 0
}
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
if (seriesRes.ok) {
const s = await seriesRes.json()
const s = await seriesRes.json()
const list = Array.isArray(s) ? s : (s.series ?? [])
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
}
if (downloadsRes.ok) {
const torrents = await downloadsRes.json()
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
@@ -153,18 +176,14 @@ async function fetchStats() {
}
async function fetchTorrents() {
// Premier chargement seulement → affiche le spinner
const isFirst = loadingTorrents.value
try {
const res = await fetch('/api/downloads', { credentials: 'include' })
if (res.ok) {
const list = await res.json()
activeTorrents.value = list
.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state))
.slice(0, 5)
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
}
} catch {
} finally {
} catch {} finally {
if (isFirst) loadingTorrents.value = false
}
}
@@ -174,14 +193,13 @@ async function fetchNotifs() {
try {
const res = await fetch('/api/organize/recent', { credentials: 'include' })
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
} catch {
} finally {
} catch {} finally {
loadingNotifs.value = false
}
}
function formatSize(bytes: number): string {
if (!bytes || bytes === 0) return '0 B'
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]}`
@@ -189,9 +207,7 @@ function formatSize(bytes: number): string {
function formatEta(seconds: number): string {
if (seconds < 0 || seconds > 86400 * 7) return '∞'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
return `${s}s`
@@ -199,21 +215,20 @@ function formatEta(seconds: number): string {
function formatRelative(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const m = Math.floor(diff / 60000)
const h = Math.floor(diff / 3600000)
const d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
if (m < 1) return "à l'instant"
if (m < 60) return `il y a ${m}min`
if (h < 24) return `il y a ${h}h`
return `il y a ${d}j`
}
onMounted(() => {
fetchAll()
interval = setInterval(fetchTorrents, 10000)
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
interval = setInterval(() => {
fetchTorrents()
if (devMode.value) fetchDebug()
}, 10000)
})
onUnmounted(() => {
if (interval) clearInterval(interval)
})
onUnmounted(() => { if (interval) clearInterval(interval) })
</script>
+109 -10
View File
@@ -32,6 +32,47 @@
</div>
</div>
<!-- Toolbar : recherche + tri -->
<div class="flex items-center gap-2 mb-4">
<div class="relative flex-1 max-w-xs">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
<input
v-model="search"
type="text"
placeholder="Rechercher..."
class="settings-input pl-9 w-full py-1.5 text-sm"
/>
</div>
<div class="relative shrink-0" ref="sortRef">
<button
@click="sortOpen = !sortOpen"
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
>
<ArrowUpDown :size="14" />
<span class="hidden md:inline text-xs">Trier</span>
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
</button>
<div
v-if="sortOpen"
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
>
<button
v-for="s in sortOptions" :key="s.value"
@click="setSort(s.value)"
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
>
{{ s.label }}
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
</button>
</div>
</div>
</div>
<!-- Onglets + filtre -->
<div class="flex items-center justify-between mb-4">
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
@@ -118,7 +159,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>
@@ -127,10 +168,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>
@@ -140,9 +181,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">
@@ -193,6 +235,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Search, ArrowUpDown } from 'lucide-vue-next'
import { onClickOutside } from '@vueuse/core'
import { useToast } from '@/composables/useToast'
const { add: toast } = useToast()
@@ -205,21 +249,70 @@ const importing = ref<Record<string, boolean>>({})
const importingAll = ref(false)
const activeTab = ref<'active' | 'done'>('active')
const hideImported = ref(false)
const search = ref('')
const sortOpen = ref(false)
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state'>('none')
const sortDir = ref<'asc' | 'desc'>('asc')
const sortRef = ref<HTMLElement | null>(null)
onClickOutside(sortRef, () => { sortOpen.value = false })
const sortOptions = [
{ label: 'Nom', value: 'name' },
{ label: 'Taille', value: 'size' },
{ label: 'Progression', value: 'progress' },
{ label: 'État', value: 'state' },
]
function setSort(val: typeof activeSort.value) {
if (activeSort.value === val) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
} else {
activeSort.value = val
sortDir.value = 'asc'
}
sortOpen.value = false
}
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(() => {
if (activeTab.value === 'active') return activeTorrents.value
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
return doneTorrents.value
let list = activeTab.value === 'active'
? activeTorrents.value
: hideImported.value
? doneTorrents.value.filter(t => t.organizeState !== 'done')
: doneTorrents.value
// Recherche
if (search.value.trim()) {
const q = search.value.toLowerCase()
list = list.filter(t => t.name.toLowerCase().includes(q))
}
// Tri
if (activeSort.value !== 'none') {
const dir = sortDir.value === 'asc' ? 1 : -1
list = [...list].sort((a, b) => {
switch (activeSort.value) {
case 'name' : return a.name.localeCompare(b.name, 'fr') * dir
case 'size' : return (a.size - b.size) * dir
case 'progress': return (a.progress - b.progress) * dir
case 'state' : return a.state.localeCompare(b.state) * dir
default : return 0
}
})
}
return list
})
const stats = computed(() => [
@@ -229,6 +322,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' })
@@ -315,7 +413,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
}
+13 -2
View File
@@ -151,8 +151,19 @@
</button>
</div>
<!-- Progression saison -->
<ProgressBar :progress="torrentProgress(extractHash(season.torrent))?.progress" class="px-5 py-2 border-b border-border" />
<!-- Progression saison — dans le header, sous les contrôles -->
<div
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
class="px-5 pb-3 -mt-1"
>
<div class="h-0.5 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-accent rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
/>
</div>
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
</div>
<!-- Épisodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
+3 -3
View File
@@ -220,7 +220,7 @@ const filtersDisponibilite = [
const filtersImport = [
{ label: 'Importés', value: 'complete', color: '#22c55e' },
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
{ label: 'Partiel', value: 'partial', color: 'var(--accent)' },
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
]
const sortOptions = [
@@ -233,7 +233,7 @@ const sortOptions = [
const legend = [
{ label: 'Importé', color: '#22c55e' },
{ label: 'En cours', color: '#3b82f6' },
{ label: 'Partiel', color: 'var(--accent)' },
{ label: 'Partiel', color: '#9b59b6' },
{ label: 'Non importé', color: 'var(--border)' },
]
@@ -271,7 +271,7 @@ function stateColor(state: string): string {
return ({
complete : '#22c55e',
downloading: '#3b82f6',
partial : 'var(--accent)',
partial : '#9b59b6',
none : 'transparent',
} as Record<string, string>)[state] ?? 'transparent'
}
+49
View File
@@ -0,0 +1,49 @@
<template>
<div class="flex flex-col gap-6">
<div>
<h2 class="text-base font-semibold text-primary">Avancé</h2>
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
</div>
<div class="settings-card flex flex-col gap-5">
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
/>
</div>
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
const devMode = ref(false)
const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const res = await fetch('/api/settings', { credentials: 'include' })
if (res.ok) {
const s = await res.json()
devMode.value = s.devMode ?? false
}
})
watch(devMode, async (val) => {
await fetch('/api/settings', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ devMode: val }),
})
saved.value = true
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => { saved.value = false }, 2000)
})
</script>
+31 -7
View File
@@ -34,6 +34,7 @@
</div>
<div class="flex items-center gap-1">
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
<button @click="editClient(client)" class="btn-ghost text-xs text-blue-400 hover:text-blue-300">Modifier</button>
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
</div>
</div>
@@ -49,7 +50,7 @@
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-primary">Ajouter un client</h3>
<h3 class="text-sm font-semibold text-primary">{{ modal.uuid ? 'Modifier un client' : 'Ajouter un client' }}</h3>
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
</div>
@@ -116,6 +117,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 +173,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 }
@@ -193,7 +208,7 @@ async function testNewClient() {
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 +224,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 +238,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')
+39 -15
View File
@@ -15,10 +15,13 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
:class="form.completePath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.completePath) ? 'text-yellow-500 border-yellow-500/40' : form.completePath ? 'text-primary' : 'text-muted'">
{{ form.completePath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
<p v-if="isRootPath(form.completePath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
</div>
<div>
@@ -27,14 +30,29 @@
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
</div>
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
:class="form.mediaPath ? 'text-primary' : 'text-muted'">
:class="isRootPath(form.mediaPath) ? 'text-yellow-500 border-yellow-500/40' : form.mediaPath ? 'text-primary' : 'text-muted'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
<p v-if="isRootPath(form.mediaPath)" class="text-xs text-yellow-500 mt-1.5 flex items-center gap-1">
<span>⚠</span> Chemin non configuré — cliquez pour sélectionner un dossier
</p>
<p v-else class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
</div>
</div>
<!-- Alerte globale si un des deux paths est sur / -->
<div v-if="hasUnconfiguredPaths" class="flex items-start gap-3 px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5">
<p class="text-sm text-yellow-500 font-medium">Chemins non configurés</p>
<p class="text-xs text-muted">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés.
Configurez-les ci-dessus avant de sauvegarder.
</p>
</div>
</div>
<!-- Mode import -->
<div class="settings-card flex flex-col gap-2">
<label class="settings-label">Mode d'import</label>
@@ -72,12 +90,6 @@
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
/>
<SettingsToggle
v-model="form.usePlexTitles"
label="Titres optimisés Plex"
description="Utilise title_for_plex pour les noms de dossiers — évite les caractères spéciaux."
/>
<SettingsToggle
v-model="form.nfoSupport"
label="NFO / Métadonnées"
@@ -115,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'
@@ -133,15 +145,25 @@ const form = ref({
organizeMode : 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
autoImport : true,
usePlexTitles: false,
})
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/',
open: false,
field: 'completePath',
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) {
@@ -150,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() {