Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c226eba8 | ||
|
|
3b74d93b74 | ||
|
|
ff54ab4ef6 | ||
|
|
a7c90b63df | ||
|
|
872962c435 | ||
|
|
b9c4627a77 | ||
|
|
eff7a288cf | ||
|
|
94d419cf01 | ||
|
|
4760d576f3 | ||
|
|
800c9f3f02 | ||
|
|
6c7def3184 | ||
|
|
962279db29 | ||
|
|
bffe286f40 | ||
|
|
abc96dd648 | ||
|
|
6142dbdb25 | ||
|
|
01040ada26 | ||
|
|
ee2d83993a | ||
|
|
0103960645 | ||
|
|
b8e30e13f2 | ||
|
|
b8554af71b | ||
|
|
e130e945d5 | ||
|
|
7c4496ae1d | ||
|
|
cfd0914b9b | ||
|
|
53a6f0abfc | ||
|
|
0c2c9ca456 | ||
|
|
74a6d0a895 | ||
|
|
01e02502c0 | ||
|
|
e1e68ef2bf | ||
|
|
ea3bf622e9 |
No files matched your search
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+9
@@ -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
@@ -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)
|
||||
})
|
||||
@@ -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>>()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
3.10.0
|
||||
3.11.5
|
||||
Reference in new issue
Block a user