Compare commits

...
29 Commits
Author SHA1 Message Date
MasutaYunikon 53c226eba8 Merge pull request #102 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.5
2026-04-26 23:50:39 +02:00
MasutaYunikon 3b74d93b74 chore(main): release 3.11.5 2026-04-26 23:50:03 +02:00
MasutaYunikon ff54ab4ef6 Merge remote-tracking branch 'origin/main' 2026-04-26 23:49:38 +02:00
MasutaYunikon a7c90b63df fix: le folder picker affiche tous les disques depuis le bouton homes sous windows 2026-04-26 23:49:28 +02:00
github-actions[bot] 872962c435 chore: update version.txt to v3.11.4 2026-04-26 21:11:56 +00:00
MasutaYunikon b9c4627a77 Merge pull request #101 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.4
2026-04-26 23:11:37 +02:00
MasutaYunikon eff7a288cf chore(main): release 3.11.4 2026-04-26 23:08:13 +02:00
MasutaYunikon 94d419cf01 Merge remote-tracking branch 'origin/main' 2026-04-26 23:07:49 +02:00
MasutaYunikon 4760d576f3 fix: ajout de la mise a jour des noms toutes les 1h 2026-04-26 23:07:39 +02:00
github-actions[bot] 800c9f3f02 chore: update version.txt to v3.11.3 2026-04-26 18:29:57 +00:00
MasutaYunikon 6c7def3184 Merge pull request #100 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.3
2026-04-26 20:29:34 +02:00
MasutaYunikon 962279db29 chore(main): release 3.11.3 2026-04-26 20:27:48 +02:00
MasutaYunikon bffe286f40 fix: dockerfile node version to 22 2026-04-26 20:27:27 +02:00
github-actions[bot] abc96dd648 chore: update version.txt to v3.11.2 2026-04-26 14:35:36 +00:00
MasutaYunikon 6142dbdb25 Merge pull request #99 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.2
2026-04-26 16:35:15 +02:00
MasutaYunikon 01040ada26 chore(main): release 3.11.2 2026-04-26 16:34:33 +02:00
MasutaYunikon ee2d83993a Merge remote-tracking branch 'origin/main' 2026-04-26 16:34:09 +02:00
MasutaYunikon 0103960645 fix: ajout de undici dans le package.json 2026-04-26 16:34:00 +02:00
github-actions[bot] b8e30e13f2 chore: update version.txt to v3.11.1 2026-04-26 14:16:42 +00:00
MasutaYunikon b8554af71b Merge pull request #98 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.1
2026-04-26 16:16:22 +02:00
MasutaYunikon e130e945d5 chore(main): release 3.11.1 2026-04-26 15:57:11 +02:00
MasutaYunikon 7c4496ae1d Merge remote-tracking branch 'origin/main' 2026-04-26 15:56:44 +02:00
MasutaYunikon cfd0914b9b fix: le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) 2026-04-26 15:56:35 +02:00
github-actions[bot] 53a6f0abfc chore: update version.txt to v3.11.0 2026-04-23 20:06:51 +00:00
MasutaYunikon 0c2c9ca456 Merge pull request #95 from Masutayunikon/release-please--branches--main
chore(main): release 3.11.0
2026-04-23 22:06:23 +02:00
MasutaYunikon 74a6d0a895 chore(main): release 3.11.0 2026-04-23 22:05:02 +02:00
MasutaYunikon 01e02502c0 Merge remote-tracking branch 'origin/main' 2026-04-23 22:04:28 +02:00
MasutaYunikon e1e68ef2bf fix: renommage d'episodes qui ne fonctionne pas en 1 par 1
feat: ajout de log pour le bouton tout renommer

feat: ajout du support multifile sur utorrent

feat: ajout d'un bouton pour supprimer les nfo / png
2026-04-23 22:04:19 +02:00
github-actions[bot] ea3bf622e9 chore: update version.txt to v3.10.0 2026-04-22 13:36:02 +00:00
15 changed files with 412 additions and 65 deletions

No files matched your search

+49
View File
@@ -1,5 +1,54 @@
# Changelog
## [3.11.5](https://github.com/Masutayunikon/FanKarr/compare/v3.11.4...v3.11.5) (2026-04-26)
### Bug Fixes
* le folder picker affiche tous les disques depuis le bouton homes sous windows ([7149022](https://github.com/Masutayunikon/FanKarr/commit/7149022c8701f79d194c675da87c6cb2d5793a35))
## [3.11.4](https://github.com/Masutayunikon/FanKarr/compare/v3.11.3...v3.11.4) (2026-04-26)
### Bug Fixes
* ajout de la mise a jour des noms toutes les 1h ([0fe5c8c](https://github.com/Masutayunikon/FanKarr/commit/0fe5c8ccb7ecdc1643b5e69de3957c39dfd8085e))
## [3.11.3](https://github.com/Masutayunikon/FanKarr/compare/v3.11.2...v3.11.3) (2026-04-26)
### Bug Fixes
* dockerfile node version to 22 ([5983494](https://github.com/Masutayunikon/FanKarr/commit/598349407750c729579bd639680ebc6a8ebcc12f))
## [3.11.2](https://github.com/Masutayunikon/FanKarr/compare/v3.11.1...v3.11.2) (2026-04-26)
### Bug Fixes
* ajout de undici dans le package.json ([459e602](https://github.com/Masutayunikon/FanKarr/commit/459e602c1df1c610466be5015a660624b1189620))
## [3.11.1](https://github.com/Masutayunikon/FanKarr/compare/v3.11.0...v3.11.1) (2026-04-26)
### Bug Fixes
* le setup plex peux maintenant etre utilisé sur un serveur local (probleme de certificat) ([6907584](https://github.com/Masutayunikon/FanKarr/commit/69075842be396b149949b158922a9dc84dbc7ccf))
## [3.11.0](https://github.com/Masutayunikon/FanKarr/compare/v3.10.0...v3.11.0) (2026-04-23)
### Features
* ajout d'un bouton pour supprimer les nfo / png ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout de log pour le bouton tout renommer ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
* ajout du support multifile sur utorrent ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
### Bug Fixes
* renommage d'episodes qui ne fonctionne pas en 1 par 1 ([bf0989d](https://github.com/Masutayunikon/FanKarr/commit/bf0989db17ebb83045ed8d2d1f3f8b11fbd3f2f5))
## [3.10.0](https://github.com/Masutayunikon/FanKarr/compare/v3.9.2...v3.10.0) (2026-04-22)
+3 -3
View File
@@ -1,4 +1,4 @@
FROM node:20-slim AS base
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -15,7 +15,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM node:20-slim
FROM node:22-slim
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -28,4 +28,4 @@ RUN mkdir -p /config
EXPOSE 3001
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
ENTRYPOINT ["/entrypoint.sh"]
+1
View File
@@ -26,6 +26,7 @@
"node-fetch": "^3.3.2",
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"undici": "^8.1.0",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
},
+9
View File
@@ -38,6 +38,9 @@ importers:
tailwindcss:
specifier: ^4.2.1
version: 4.2.1
undici:
specifier: ^8.1.0
version: 8.1.0
vue:
specifier: ^3.5.25
version: 3.5.29(typescript@5.9.3)
@@ -1343,6 +1346,10 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@8.1.0:
resolution: {integrity: sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==}
engines: {node: '>=22.19.0'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
@@ -2645,6 +2652,8 @@ snapshots:
undici-types@7.16.0: {}
undici@8.1.0: {}
unpipe@1.0.0: {}
unplugin-utils@0.3.1:
+14 -1
View File
@@ -11,7 +11,7 @@ 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 { autoOrganizeAll, scanMediaPath } from './organize.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
@@ -139,4 +139,17 @@ server.listen(PORT, async () => {
checkNfoUpdates().catch(err => logger.error('nfo-update', `Vérif horaire échouée : ${err instanceof Error ? err.message : err}`))
}, 60 * 60_000)
}, 30_000)
// Sync automatique des noms de fichiers toutes les heures.
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.error('api', `Sync noms auto échoué : ${err instanceof Error ? err.message : err}`)
}
}, 60 * 60_000)
})
+1 -1
View File
@@ -5,7 +5,7 @@ import fs from 'fs'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
const CACHE_TTL_MS = 1 * 60 * 60 * 1000
interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>()
+77
View File
@@ -327,6 +327,83 @@ export async function autoOrganizeAll(
worker.postMessage({ type: 'run', torrents, seriesData })
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges(
seriesData : any[],
organizedPath: string,
): Promise<{ renamed: number; errors: number }> {
const { nfoSupport } = readSettings()
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { renamed: 0, errors: 0 } }
let renamed = 0
let errors = 0
let changed = false
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if ((eps as any)[String(ep.id)]) {
orgEntry = (eps as any)[String(ep.id)]
orgHash = hash
break
}
}
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
const expectedName: string = nfoSupport
? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
if (expectedName === currentName) continue
const oldPath = orgEntry.dest_path
if (!oldPath || !fs.existsSync(oldPath)) continue
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (fs.existsSync(newPath)) {
logger.warn('organize', `Sync rename : "${expectedName}" existe déjà — skip`)
continue
}
fs.renameSync(oldPath, newPath)
;(organized[orgHash] as any)[String(ep.id)] = {
...orgEntry,
dest_filename: expectedName,
dest_path : newPath,
at : new Date().toISOString(),
}
renamed++
changed = true
logger.info('organize', `Sync rename : "${currentName}" → "${expectedName}"`)
} catch (err) {
errors++
logger.error('organize', `Sync rename échoué pour ep ${ep.id} : ${err instanceof Error ? err.message : err}`)
}
}
}
}
if (changed)
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
if (renamed > 0 || errors > 0)
logger.info('organize', `Sync noms scraper — ${renamed} renommé(s)${errors > 0 ? `, ${errors} erreur(s)` : ''}`)
return { renamed, errors }
}
// ── Import manuel ─────────────────────────────────────────────
export async function organizeTorrent(
+57 -5
View File
@@ -175,7 +175,13 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (!foundEp) { res.status(404).json({ error: 'Épisode introuvable' }); return }
const { ep, season } = foundEp
const hash = torrent_hash?.toLowerCase() ?? 'manual'
const orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let orgEntry = organized[hash]?.[String(episode_id)] ?? organized['manual']?.[String(episode_id)]
let entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
if (!orgEntry) {
for (const [h, eps] of Object.entries(organized)) {
if (eps[String(episode_id)]) { orgEntry = eps[String(episode_id)]; entryHash = h; break }
}
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
@@ -192,7 +198,6 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
if (!fs.existsSync(oldPath)) throw new Error('Fichier source introuvable sur le disque')
if (fs.existsSync(newPath)) throw new Error(`Un fichier avec ce nom existe déjà : ${newName}`)
fs.renameSync(oldPath, newPath)
const entryHash = organized[hash]?.[String(episode_id)] ? hash : 'manual'
organized[entryHash][String(episode_id)] = { ...orgEntry, dest_filename: newName, dest_path: newPath, at: new Date().toISOString() }
saveOrganizedJson(organized)
logger.info('api', `Rename : "${orgEntry.dest_filename}" → "${newName}"`)
@@ -335,14 +340,24 @@ router.post('/rename-all', requireAuth, async (req, res) => {
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
if (!fs.existsSync(oldPath)) { errors.push({ episode_id: ep.id, error: 'Fichier introuvable' }); continue }
if (fs.existsSync(newPath)) { errors.push({ episode_id: ep.id, error: `Fichier existant : ${expectedName}` }); continue }
if (!fs.existsSync(oldPath)) {
const msg = `Fichier introuvable : "${oldPath}"`
logger.error('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
if (fs.existsSync(newPath)) {
const msg = `Fichier existant : "${expectedName}"`
logger.warn('api', `Rename masse ep ${ep.id} — ${msg}`)
errors.push({ episode_id: ep.id, error: msg }); continue
}
fs.renameSync(oldPath, newPath)
organized[orgHash][String(ep.id)] = { ...orgEntry, dest_filename: expectedName, dest_path: newPath, at: new Date().toISOString() }
done.push(ep.id)
logger.info('api', `Rename masse : "${currentName}" → "${expectedName}"`)
} catch (err) {
errors.push({ episode_id: ep.id, error: err instanceof Error ? err.message : 'Erreur' })
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Rename masse ep ${ep.id} — exception : ${msg}`)
errors.push({ episode_id: ep.id, error: msg })
}
}
}
@@ -351,4 +366,41 @@ router.post('/rename-all', requireAuth, async (req, res) => {
res.json({ ok: true, done: done.length, errors })
})
// ── Purge NFO / images des dossiers série ─────────────────────
router.post('/purge-nfo', requireAuth, (req, res) => {
const { mediaPath } = readSettings()
if (!mediaPath || !fs.existsSync(mediaPath)) {
res.status(400).json({ error: 'Chemin médiathèque non configuré ou introuvable' }); return
}
const NFO_EXTS = new Set(['.nfo', '.png', '.jpg', '.jpeg', '.tbn', '.xml'])
let deleted = 0
const errors: string[] = []
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
walk(full)
} else if (entry.isFile() && NFO_EXTS.has(path.extname(entry.name).toLowerCase())) {
try {
fs.unlinkSync(full)
deleted++
logger.debug('api', `Purge NFO : supprimé "${full}"`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.error('api', `Purge NFO : impossible de supprimer "${full}" : ${msg}`)
errors.push(full)
}
}
}
}
walk(mediaPath)
logger.info('api', `Purge NFO — ${deleted} fichier(s) supprimé(s)${errors.length > 0 ? `, ${errors.length} erreur(s)` : ''}`)
res.json({ ok: true, deleted, errors })
})
export default router
+41 -12
View File
@@ -1,4 +1,5 @@
import { Router } from 'express'
import { Agent, fetch as undiciFetch } from 'undici'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
@@ -6,6 +7,16 @@ const router = Router()
const PLEX_TV_API = 'https://plex.tv/api/v2'
// ── Fetch avec bypass SSL pour les serveurs locaux ────────────────────────────
function getServerFetch(serverUrl: string) {
return (url: string, opts: any = {}) =>
undiciFetch(url, {
...opts,
dispatcher: new Agent({ connect: { rejectUnauthorized: false } }),
})
}
// ── Fetch vers plex.tv (certificat valide, pas besoin de bypass) ──────────────
async function plexFetch(url: string, options: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...options,
@@ -105,6 +116,10 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
if (!token || !serverUrl || !libraryName || !libraryPath) {
res.status(400).json({ error: 'token, serverUrl, libraryName, libraryPath requis' }); return
}
// Fetch avec SSL bypass pour les appels vers le serveur Plex local
const serverFetch = getServerFetch(serverUrl)
const headers: Record<string, string> = { 'X-Plex-Token': token, 'Accept': 'application/json' }
const TARGET_AGENT_URI = 'https://metadata.fankai.fr/plex'
const steps: { step: string; ok: boolean; message: string }[] = []
@@ -114,27 +129,30 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
// ── Étape 1 : Enregistrer le provider Fankai ─────────────
try {
const providersRes = await fetch(`${serverUrl}/media/providers/metadata`, { headers })
const providersRes = await serverFetch(`${serverUrl}/media/providers/metadata`, { headers })
if (providersRes.ok) {
const data = await providersRes.json()
const data = await providersRes.json() as any
const providers: any[] = data?.MediaContainer?.MetadataAgentProvider ?? []
let provider = providers.find((p: any) => p.uri === TARGET_AGENT_URI)
if (!provider) {
const r = await fetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
if (r.ok) provider = (await r.json())?.MediaContainer?.MetadataAgentProvider?.[0]
const r = await serverFetch(`${serverUrl}/media/providers/metadata?uri=${encodeURIComponent(TARGET_AGENT_URI)}`, { method: 'POST', headers })
if (r.ok) provider = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProvider?.[0]
}
if (provider) {
agentIdentifier = provider.identifier ?? agentIdentifier
steps.push({ step: 'provider', ok: true, message: `Provider enregistré (${agentIdentifier})` })
}
const groupsRes = await fetch(`${serverUrl}/media/providers/metadata/group`, { headers })
const groupsRes = await serverFetch(`${serverUrl}/media/providers/metadata/group`, { headers })
if (groupsRes.ok) {
const gdata = await groupsRes.json()
const gdata = await groupsRes.json() as any
const groups: any[] = gdata?.MediaContainer?.MetadataAgentProviderGroup ?? []
let group = groups.find((g: any) => g.primaryIdentifier === agentIdentifier)
if (!group) {
const r = await fetch(`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`, { method: 'POST', headers })
if (r.ok) group = (await r.json())?.MediaContainer?.MetadataAgentProviderGroup?.[0]
const r = await serverFetch(
`${serverUrl}/media/providers/metadata/group?title=Fankai&primaryIdentifier=${encodeURIComponent(agentIdentifier)}`,
{ method: 'POST', headers },
)
if (r.ok) group = ((await r.json()) as any)?.MediaContainer?.MetadataAgentProviderGroup?.[0]
}
if (group) {
groupId = String(group.id ?? '')
@@ -142,18 +160,29 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
agentSetupOk = true
}
}
} else {
const status = providersRes.status
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (HTTP ${status} — Plex < 1.43 ?) — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai — HTTP ${status} depuis ${serverUrl}`)
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur'
steps.push({ step: 'agent', ok: false, message: `Agent non configuré (Plex < 1.43 ?) — setup manuel requis` })
steps.push({ step: 'agent', ok: false, message: `Agent non configuré — setup manuel requis` })
logger.warn('plex', `Setup agent Fankai échoué : ${msg}`)
}
// ── Étape 2 : Créer la bibliothèque ──────────────────────
try {
const params = new URLSearchParams({ type: 'show', name: libraryName, agent: agentIdentifier, scanner: 'Plex TV Series', language: 'fr-FR', location: libraryPath })
const params = new URLSearchParams({
type : 'show',
name : libraryName,
agent : agentIdentifier,
scanner : 'Plex TV Series',
language: 'fr-FR',
location: libraryPath,
})
if (groupId) params.set('metadataAgentProviderGroupId', groupId)
const libRes = await fetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
const libRes = await serverFetch(`${serverUrl}/library/sections?${params}`, { method: 'POST', headers })
if (!libRes.ok) throw new Error(`HTTP ${libRes.status}: ${await libRes.text()}`)
steps.push({ step: 'library', ok: true, message: `Bibliothèque "${libraryName}" créée` })
logger.info('plex', `Bibliothèque "${libraryName}" créée sur ${serverUrl}`)
@@ -166,4 +195,4 @@ router.post('/plex/setup', requireAuth, async (req, res) => {
}
})
export default router
export default router
+38 -3
View File
@@ -6,7 +6,7 @@ import { logger, readLogs, clearLogs, logsFileSize } from '../logger.js'
import { DATA_DIR, BASE_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { systemInfo } from '../system.js'
import { scanMediaPath } from '../organize.js'
import { scanMediaPath, syncFilenameChanges } from '../organize.js'
import { readAvailable, loadEnrichedSeriesData, cacheClear } from '../lib/github-cache.js'
import { cacheSize } from '../lib/github-cache.js'
import { recentOrganized } from '../lib/notifs.js'
@@ -47,11 +47,36 @@ router.get('/version', (_req, res) => {
// ── Parcours fichiers ──────────────────────────────────────────
router.get('/browse', requireAuth, (req, res) => {
const dirPath = (req.query.path as string) || '/'
const isWindows = process.platform === 'win32'
const dirPath = (req.query.path as string) || '/'
// ── Racine virtuelle Windows : liste les lecteurs disponibles ──
if (isWindows && dirPath === '/') {
const drives: string[] = []
for (const letter of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') {
const d = `${letter}:\\`
try { fs.readdirSync(d); drives.push(d) } catch {}
}
res.json({ path: '/', parent: null, dirs: drives, drivesRoot: true })
return
}
try {
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).filter(n => !n.startsWith('.')).sort((a, b) => a.localeCompare(b))
res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
// Sur Windows, remonter à '/' quand on est à la racine d'un lecteur (ex: C:\)
let parent: string | null
if (dirPath === '/') {
parent = null
} else if (isWindows && /^[A-Za-z]:\\?$/.test(dirPath)) {
parent = '/'
} else {
const up = path.dirname(dirPath)
parent = up === dirPath ? null : up // dirname de C:\ retourne C:\ → null
}
res.json({ path: dirPath, parent, dirs })
} catch (err) {
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : err}`)
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
@@ -109,6 +134,16 @@ router.post('/update', requireAuth, async (_req, res) => {
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
res.json({ ok: true, count: availableIds.length })
;(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
if (renamed > 0)
logger.info('api', `Sync noms scraper après mise à jour — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
logger.warn('api', `Sync noms après mise à jour échoué : ${err instanceof Error ? err.message : err}`)
}
})()
} catch (err) {
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
+77 -23
View File
@@ -21,6 +21,56 @@ function mapState(status: number): TorrentInfo['state'] {
// uTorrent nécessite un token CSRF + cookie à chaque session
interface UTSession { token: string; cookie: string }
async function utTorrentExists(config: Record<string, string | number>, hash: string): Promise<boolean> {
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: hash.toUpperCase() }, session)
const files = data?.files?.[1]
return Array.isArray(files) && files.length > 0
} catch { return false }
}
/**
* Attend que les métadonnées soient disponibles (polling), puis applique les priorités :
* tous les fichiers à 0 (skip), sauf le fichier cible à 2 (normal).
*/
async function utApplyFilePriority(
config : Record<string, string | number>,
hash : string,
fileIndex: number,
): Promise<void> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const HASH = hash.toUpperCase()
// 100 tentatives × 500 ms = 50 s max
for (let attempt = 0; attempt < 100; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const session = await utGetSession(config)
const data = await utRequest(config, { action: 'getfiles', hash: HASH }, session)
const files = data?.files?.[1] as any[][] | undefined
if (!Array.isArray(files) || files.length === 0) continue
// Passer tous les fichiers à priorité 0 (skip)
const skipQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '0' })
for (let i = 0; i < files.length; i++) skipQs.append('f', String(i))
await fetch(`${config.url}/gui/?${skipQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
// Activer le fichier cible (priorité 2 = normal)
const selectQs = new URLSearchParams({ token: session.token, action: 'setprio', hash: HASH, p: '2', f: String(fileIndex) })
await fetch(`${config.url}/gui/?${selectQs}`, {
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
logger.info('utorrent', `Fichier ${fileIndex} sélectionné pour ${HASH.slice(0, 8)}…`)
return
} catch {}
}
throw new Error(`Timeout : fichiers non disponibles pour ${hash.slice(0, 8)}…`)
}
async function utGetSession(config: Record<string, string | number>): Promise<UTSession> {
const auth = btoa(`${config.username ?? ''}:${config.password ?? ''}`)
const headers : Record<string, string> = {
@@ -142,43 +192,49 @@ const UT: TorrentClientDriver = {
},
async add(config, url, options?: DownloadOptions) {
if (options?.file_index != null) logger.warn('utorrent', 'Sélection de fichier non supportée — téléchargement complet')
const hashMatch = url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
?? options?.magnet?.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)
const hash = hashMatch?.[1]?.toLowerCase() ?? null
if (options?.file_index != null && hash) {
const exists = await utTorrentExists(config, hash)
if (exists) {
logger.info('utorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
return
}
}
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('path', String(config.savePath))
}
const qs = new URLSearchParams({ token: session.token, action: 'add-url', s: url })
if (config.savePath) qs.set('path', String(config.savePath))
const res = await fetch(`${config.url}/gui/?${qs}`, {
method : 'GET',
headers: {
'Authorization': `Basic ${auth}`,
'Cookie' : session.cookie,
},
headers: { Authorization: `Basic ${auth}`, Cookie: session.cookie },
})
if (!res.ok) throw new Error(`Ajout échoué : HTTP ${res.status}`)
if (options?.file_index != null && hash) {
logger.info('utorrent', `Torrent ajouté (sélection fichier ${options.file_index} en attente de métadonnées)`)
utApplyFilePriority(config, hash, options.file_index).catch(err =>
logger.warn('utorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
} else {
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
}
// 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, {
@@ -191,8 +247,6 @@ const UT: TorrentClientDriver = {
} catch {}
}, 3000)
}
logger.info('utorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'}${config.savePath ? `, dossier: ${config.savePath}` : ''})`)
},
async remove(config, hash, deleteFiles = false) {
+16 -12
View File
@@ -14,7 +14,7 @@
<!-- 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">
<button @click="navigateTo('/')" class="btn-secondary px-2 text-muted hover:text-primary" :title="drivesRoot ? 'Lecteurs' : '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"/>
@@ -85,12 +85,13 @@ import { ref, watch } from 'vue'
const props = defineProps<{ initialPath?: string }>()
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const current = ref(props.initialPath || '/')
const inputPath = ref(current.value)
const dirs = ref<string[]>([])
const parent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
const drivesRoot = ref(false) // true quand la liste affiche les lecteurs Windows
// FIX : la regex /\/+$/ dans un @click inline fait planter le parser Vue
function selectCurrent() {
@@ -98,8 +99,10 @@ function selectCurrent() {
}
function joinPath(base: string, name: string): string {
// Lecteur Windows complet (ex: "C:\") → chemin absolu, pas de jointure
if (/^[A-Za-z]:\\$/.test(name)) return name
if (base === '/') return `/${name}`
const cleanBase = base.endsWith('/') ? base.slice(0, -1) : base
const cleanBase = base.replace(/[\\/]+$/, '')
return `${cleanBase}/${name}`
}
@@ -114,10 +117,11 @@ async function navigateTo(p: string) {
return
}
const data = await res.json()
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
current.value = data.path
inputPath.value = data.path
parent.value = data.parent
dirs.value = data.dirs
drivesRoot.value = data.drivesRoot ?? false
} catch {
error.value = 'Impossible de lire ce dossier'
} finally {
+1 -1
View File
@@ -166,7 +166,7 @@ const clientMatrix = [
{ id: 'qbittorrent', label: 'qBittorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'transmission', label: 'Transmission', fileSelect: '✓ Natif', categories: true },
{ id: 'rtorrent', label: 'rTorrent / ruTorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'utorrent', label: 'uTorrent', fileSelect: '✗', categories: true },
{ id: 'utorrent', label: 'uTorrent', fileSelect: 'Async ✓', categories: true },
{ id: 'synology-ds', label: 'Synology Download Station', fileSelect: '✗', categories: false },
]
+27 -3
View File
@@ -22,6 +22,14 @@
</svg>
Actualiser
</button>
<button
@click="purgeNfo"
:disabled="purgingNfo"
class="btn-secondary text-red-400 hover:text-red-300"
title="Supprime tous les .nfo, .png, .jpg, .tbn dans les dossiers séries"
>
{{ purgingNfo ? 'Suppression…' : 'Purger NFO / images' }}
</button>
<button
v-if="totalNeedsRename > 0"
@click="renameAll()"
@@ -129,7 +137,7 @@
<span v-else-if="!ep.needs_rename" class="text-[10px] text-green-400">✓</span>
<button
v-else
@click="renameSingle(ep)"
@click="renameSingle(ep, serie)"
:disabled="renamingEp[ep.episode_id]"
class="text-xs px-2 py-1 rounded-lg border border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10 transition-colors"
>
@@ -168,6 +176,7 @@ const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
const series = ref<any[]>([])
const nfoSupport = ref(false)
const collapsed = ref<Set<number>>(new Set())
@@ -225,14 +234,14 @@ async function renameAll(serieId?: number) {
}
}
async function renameSingle(ep: any) {
async function renameSingle(ep: any, serie: any) {
renamingEp.value[ep.episode_id] = true
try {
const res = await fetch('/api/rename-episode', {
method : 'POST',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
body : JSON.stringify({ episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
body : JSON.stringify({ serie_id: serie.serie_id, episode_id: ep.episode_id, torrent_hash: ep.torrent_hash }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
@@ -245,6 +254,21 @@ async function renameSingle(ep: any) {
}
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
purgingNfo.value = true
try {
const res = await fetch('/api/purge-nfo', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.deleted} fichier${data.deleted > 1 ? 's' : ''} supprimé${data.deleted > 1 ? 's' : ''}${data.errors.length > 0 ? ` (${data.errors.length} erreur${data.errors.length > 1 ? 's' : ''})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
purgingNfo.value = false
}
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
renamingAll.value = true
+1 -1
View File
@@ -1 +1 @@
3.10.0
3.11.5