Compare commits

...
15 Commits
Author SHA1 Message Date
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
12 changed files with 331 additions and 47 deletions

No files matched your search

+28
View File
@@ -1,5 +1,33 @@
# Changelog
## [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)
+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:
+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
+11 -1
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'
@@ -109,6 +109,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) {
+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.2