diff --git a/server/index.ts b/server/index.ts index 98412e3..da11c5c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -8,7 +8,7 @@ import { registerDriver, getDriver, getAvailableClients, listClients, addClient, removeClient, getClient, sanitizeClient, dispatchDownload, dispatchList -} from './torrent-clients/index.js' +} from './torrent-clients' import qbittorrentDriver from './torrent-clients/qbittorrent.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' @@ -433,6 +433,22 @@ app.get('/api/system', requireAuth, (_req, res) => { res.json({ isDocker }) }) +app.get('/api/browse', requireAuth, (req, res) => { + const dirPath = (req.query.path as string) || '/' + 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)) + const parent = dirPath === '/' ? null : path.dirname(dirPath) + res.json({ path: dirPath, parent, dirs }) + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' }) + } +}) + app.get('/api/torrents/status', requireAuth, (_req, res) => { try { if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return } @@ -459,6 +475,18 @@ app.post('/api/update', requireAuth, async (_req, res) => { } }) +app.post('/api/scan', requireAuth, async (_req, res) => { + try { + const { mediaPath } = readSettings() + const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') + const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH) + logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) + res.json({ ok: true, ...result }) + } catch (err) { + res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' }) + } +}) + // ── SPA fallback (prod) — doit être après toutes les routes API ─ if (fs.existsSync(PUBLIC_PATH)) { app.get('*path', (_req, res) => { diff --git a/server/organize-worker.ts b/server/organize-worker.ts index 8d537b6..bd2ecd4 100644 --- a/server/organize-worker.ts +++ b/server/organize-worker.ts @@ -20,19 +20,22 @@ const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json') const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') // ─── Utils log ──────────────────────────────────────────────── +// Le worker tourne dans un thread séparé — il passe les logs au thread principal +// qui les écrit dans le fichier via logger.ts function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) } function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) } +function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) } // ─── Settings (lu directement, pas d'import circulaire) ────── -function readSettings(): { mediaPath: string; completePath: string; organizeMode: string } { +function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { try { const p = path.join(process.cwd(), 'data', 'settings.json') - if (!fs.existsSync(p)) return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } + if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } return JSON.parse(fs.readFileSync(p, 'utf-8')) - } catch { return { mediaPath: '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' } } + } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } } } // ─── Organized log ──────────────────────────────────────────── @@ -84,10 +87,98 @@ function isInExcludedFolder(filePath: string[]): boolean { return false } +// ─── Map correction titres API → titres GitLab ─────────────── + +const SERIE_TITLE_GITLAB_MAP: Record = { + 'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū', + 'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū', + 'Hikaru No Go Henshū' : 'Hikaru no Go Henshū', + 'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï', + 'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū', + 'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū', + 'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū', + 'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū', + 'Tower Of God Henshū' : 'Tower of God Henshū', +} + +function getGitlabSerieTitle(serieTitle: string): string { + return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle +} + + + +// ─── GitLab NFO downloader ──────────────────────────────────── + +const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository' +const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' + +async function fetchJson(url: string): Promise { + const { default: nodeFetch } = await import('node-fetch') + const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) + if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`) + return res.json() +} + +async function fetchBinary(url: string): Promise { + const { default: nodeFetch } = await import('node-fetch') + const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } }) + if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`) + return Buffer.from(await res.arrayBuffer()) +} + +async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise { + const gitlabTitle = getGitlabSerieTitle(serieTitle) + const folderPath = `pack/${gitlabTitle}` + + log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`) + + // Lister récursivement tous les fichiers du dossier + let files: any[] + try { + files = await fetchJson( + `${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main` + ) + } catch (err) { + warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`) + return + } + + if (!Array.isArray(files) || files.length === 0) { + warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`) + return + } + + const fileEntries = files.filter((f: any) => f.type === 'blob') + log(`[nfo] ${fileEntries.length} fichiers à télécharger`) + + for (const entry of fileEntries) { + // entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo" + // On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif + const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') + const destPath = path.join(destRoot, relativePath) + const destDir = path.dirname(destPath) + + // Skip si déjà présent + if (fs.existsSync(destPath)) continue + + try { + fs.mkdirSync(destDir, { recursive: true }) + const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` + const data = await fetchBinary(rawUrl) + fs.writeFileSync(destPath, data) + log(`[nfo] ✓ ${relativePath}`) + } catch (err) { + warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`) + } + } + + log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`) +} + // ─── Filesystem ops ─────────────────────────────────────────── function seasonFolder(n: number): string { - return `Saison ${String(n).padStart(3, '0')}` + return `Saison ${n}` } function tryHardlink(src: string, dest: string): boolean { @@ -101,7 +192,7 @@ function tryHardlink(src: string, dest: string): boolean { // ─── Organise un torrent ────────────────────────────────────── async function organizeTorrent(hash: string, name: string, savePath: string) { - const { mediaPath, completePath, organizeMode } = readSettings() + const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings() const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] } log(`[organize] ── ${name} ──`) @@ -118,13 +209,20 @@ async function organizeTorrent(hash: string, name: string, savePath: string) { const torrentFiles: any[] = torrent.torrent_files ?? [] log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`) + // ── Téléchargement NFO/images depuis GitLab ─────────────── + if (nfoSupport) { + const serieDestRoot = path.join(mediaPath, serieTitle) + await downloadGitlabFolder(serieTitle, serieDestRoot) + } + // ── Cas fichier unique ──────────────────────────────────── if (torrentFiles.length === 0) { log(`[organize] torrent_files vide → tentative fichier unique`) const candidates = [path.join(savePath, name), path.join(completePath, name)] let src: string | null = null for (const c of candidates) { - log(`[organize] candidate: ${c} → ${fs.existsSync(c) ? '✓' : '✗'}`) + if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`) + else debug(`[organize] candidate absent: ${c}`) if (fs.existsSync(c)) { src = c; break } } if (!src) throw new Error(`Source introuvable pour "${name}"`) @@ -196,9 +294,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) { const seasonNum = filenameSeasonMap.get(filename) ?? 1 const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum)) - const resolvedEp = torrent.resolved_episodes?.find((e: any) => e.filename === filename) - const destName = resolvedEp?.original_filename ?? filename - const dest = path.join(destDir, destName) + const dest = path.join(destDir, filename) if (fs.existsSync(dest)) { markOrganized(hash, filename) diff --git a/server/settings.ts b/server/settings.ts index bde8903..ab02bf3 100644 --- a/server/settings.ts +++ b/server/settings.ts @@ -4,17 +4,19 @@ import path from 'path' const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json') export interface Settings { - mediaPath : string // dossier Jellyfin destination ex: /media/Kai - completePath : string // dossier qB complétion ex: /downloads/complete + mediaPath : string + completePath : string organizeMode : 'hardlink' | 'move' - category : string // catégorie qBittorrent + category : string + nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack } const defaults: Settings = { - mediaPath : '/media/Kai', - completePath : '/downloads/complete', + mediaPath : '', + completePath : '', organizeMode : 'hardlink', category : 'fankai', + nfoSupport : false, } export function readSettings(): Settings { diff --git a/server/torrent-clients/index.ts b/server/torrent-clients/index.ts index eb6aa8e..5fd6818 100644 --- a/server/torrent-clients/index.ts +++ b/server/torrent-clients/index.ts @@ -168,7 +168,9 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & { const driver = getDriver(client.type) if (!driver) continue try { - const torrents = await driver.list(client.config, category) + // La catégorie du client est prioritaire sur la catégorie globale + const clientCategory = (client.config.category as string) || category + const torrents = await driver.list(client.config, clientCategory) for (const t of torrents) { results.push({ ...t, client_uuid: client.uuid, client_name: client.name }) } diff --git a/src/components/FolderPicker.vue b/src/components/FolderPicker.vue new file mode 100644 index 0000000..fdb818d --- /dev/null +++ b/src/components/FolderPicker.vue @@ -0,0 +1,117 @@ + + + \ No newline at end of file diff --git a/src/views/CatalogView.vue b/src/views/CatalogView.vue index 775974f..4e621c6 100644 --- a/src/views/CatalogView.vue +++ b/src/views/CatalogView.vue @@ -4,9 +4,9 @@
- + Fankarr - +
-
+
+ + + + + + +
{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}
@@ -133,8 +148,14 @@
-
+
{{ serie.torrent_count }} +
@@ -201,12 +222,15 @@ const search = ref('') const activeFilter = ref('all') const activeDownloads = ref(0) -const filters = [ - { label: 'Toutes', value: 'all' }, - { label: 'Disponibles', value: 'available' }, - { label: 'Téléchargés', value: 'complete' }, - { label: 'En cours', value: 'downloading' }, - { label: 'Sans torrent', value: 'unavailable' }, +const filtersGroupe1 = [ + { label: 'Toutes', value: 'all' }, + { label: 'Disponibles', value: 'available' }, + { label: 'Sans torrent', value: 'unavailable' }, +] + +const filtersGroupe2 = [ + { label: 'DL complet', value: 'complete' }, + { label: 'DL partiel', value: 'downloading' }, ] const filtered = computed(() => { diff --git a/src/views/DownloadsView.vue b/src/views/DownloadsView.vue index 08b4d5a..4c96358 100644 --- a/src/views/DownloadsView.vue +++ b/src/views/DownloadsView.vue @@ -10,7 +10,6 @@

Téléchargements

-
{{ polling ? 'EN DIRECT' : 'PAUSE' }} @@ -30,6 +29,47 @@
+ +
+
+ + +
+ + +
+ + MASQUER ORGANISÉS +
+
+

Aucun client torrent configuré

@@ -46,14 +86,20 @@
-
-

Aucun téléchargement en cours

-

Les torrents lancés depuis Fankarr apparaîtront ici

+
+

+ {{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }} +

+

+ {{ activeTab === 'active' + ? 'Les torrents avec la catégorie « fankai » apparaîtront ici' + : hideOrganized ? 'Tous les torrents terminés sont organisés' : '' }} +

-
@@ -61,17 +107,14 @@

{{ t.name }}

- {{ stateBadge(t.state).label }} - {{ t.client_name }}
-

{{ t.progress }}%

@@ -92,26 +135,17 @@

- - ↓ {{ formatSpeed(t.speed) }} - - - ETA {{ formatEta(t.eta) }} - - - ✓ Complété - + ↓ {{ formatSpeed(t.speed) }} + ETA {{ formatEta(t.eta) }} + ✓ Complété
- +
- -
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }} - - ([]) -const loading = ref(true) -const polling = ref(true) -const noClients = ref(false) -const organizing = ref>({}) +const torrents = ref([]) +const loading = ref(true) +const polling = ref(true) +const noClients = ref(false) +const organizing = ref>({}) +const activeTab = ref<'active' | 'done'>('active') +const hideOrganized = ref(false) let pollInterval: ReturnType | null = null +// ── Filtres onglets ──────────────────────────────────────────── +const activeTorrents = computed(() => + torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state)) +) + +const doneTorrents = computed(() => + torrents.value.filter(t => t.state === 'seeding') +) + +const visibleTorrents = computed(() => { + if (activeTab.value === 'active') return activeTorrents.value + if (hideOrganized.value) return doneTorrents.value.filter(t => t.organizeState !== 'done') + return doneTorrents.value +}) + // ── Stats ────────────────────────────────────────────────────── const stats = computed(() => [ { label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length }, @@ -213,7 +262,6 @@ async function organize(torrent: any) { body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name }) }) if (res.ok) { - organized.value[torrent.hash] = true toast(`${torrent.name} organisé ✓`, 'success') } else { const { error } = await res.json() diff --git a/src/views/SerieView.vue b/src/views/SerieView.vue index 0e20ac5..63af5c3 100644 --- a/src/views/SerieView.vue +++ b/src/views/SerieView.vue @@ -1,290 +1,495 @@ +function closeModal() { + modal.value.open = false +} - \ No newline at end of file +function onTypeChange() { + modal.value.config = {} + modal.value.tested = false + const def = currentDefinition.value + if (def) { + for (const field of def.fields) { + if (field.default !== undefined) modal.value.config[field.key] = String(field.default) + } + } +} + +async function testNewClient() { + modal.value.testing = true + modal.value.tested = false + try { + const res = await fetch('/api/torrent-clients/test-config', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ type: modal.value.type, config: modal.value.config }) + }) + const { ok, message } = await res.json() + modal.value.tested = ok + toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error') + } catch { + toast('Impossible de contacter le serveur', 'error') + } finally { + modal.value.testing = false + } +} + +async function saveClient() { + if (!modal.value.tested) return + modal.value.saving = true + try { + const res = await fetch('/api/torrent-clients', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + name : modal.value.name || clientLabel(modal.value.type), + type : modal.value.type, + config: modal.value.config + }) + }) + if (res.ok) { + const client = await res.json() + clients.value.push(client) + refreshHealth(client.uuid) + closeModal() + toast('Client enregistré ✓', 'success') + } else { + const { error } = await res.json() + toast(error ?? "Erreur lors de l'enregistrement", 'error') + } + } finally { + modal.value.saving = false + } +} + +// ── Données torrents ─────────────────────────────────────────── +async function update() { + updating.value = true + try { + const res = await fetch('/api/update', { method: 'POST', credentials: 'include' }) + if (res.ok) { + const { count } = await res.json() + torrentsStatus.value = { exists: true, count, empty: count === 0 } + toast(`${count} torrents téléchargés ✓`, 'success') + } else { + const { error } = await res.json() + toast(error ?? 'Erreur lors de la mise à jour', 'error') + } + } catch { + toast('Impossible de contacter le serveur', 'error') + } finally { + updating.value = false + } +} + +// ── Scan bibliothèque ────────────────────────────────────────── +async function scan() { + scanning.value = true + scanResult.value = null + try { + const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' }) + if (res.ok) { + const data = await res.json() + scanResult.value = { found: data.found, added: data.added } + toast( + data.added > 0 + ? `${data.found} fichiers scannés — ${data.added} ajoutés` + : `${data.found} fichiers scannés — rien de nouveau`, + 'success' + ) + } else { + toast('Erreur lors du scan', 'error') + } + } catch { + toast('Impossible de contacter le serveur', 'error') + } finally { + scanning.value = false + } +} + +// ── Sauvegarder settings ─────────────────────────────────────── +async function save() { + saving.value = true + try { + const res = await fetch('/api/settings', { + method : 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(form.value) + }) + if (res.ok) toast('Configuration sauvegardée', 'success') + else toast('Erreur lors de la sauvegarde', 'error') + } finally { + saving.value = false + } +} + +async function logout() { + await auth.logout() + await router.push('/auth') +} + \ No newline at end of file diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 56192cc..03c4585 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -91,10 +91,15 @@ DOCKER
- + +
+ {{ form.completePath || '/downloads/complete' }} +

Défini par le volume Docker — non modifiable ici Dossier de complétion de qBittorrent @@ -107,10 +112,15 @@ DOCKER

- + +
+ {{ form.mediaPath || '/media/Kai' }} +

Défini par le volume Docker — non modifiable ici Dossier racine de la bibliothèque Fankai dans Jellyfin @@ -141,11 +151,37 @@

-
+ +
+ +
+ + Télécharger les NFO et images depuis GitLab +
+

+ Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis + fankai_pack au moment de l'organisation +

+
+ +
+ + + {{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés +
@@ -160,6 +196,14 @@
+ + +
@@ -217,6 +261,7 @@ import { ref, computed, onMounted } from 'vue' import { useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { useToast } from '@/composables/useToast' +import FolderPicker from '@/components/FolderPicker.vue' const router = useRouter() const auth = useAuthStore() @@ -227,10 +272,31 @@ const form = ref({ mediaPath : '/media/Kai', completePath: '/downloads/complete', organizeMode: 'hardlink' as 'hardlink' | 'move', + nfoSupport : false, }) const saving = ref(false) const updating = ref(false) +const scanning = ref(false) +const scanResult = ref<{ found: number; added: number } | null>(null) + +// ── Folder picker ────────────────────────────────────────────── +const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({ + open: false, field: 'completePath', currentPath: '/' +}) + +function openPicker(field: 'completePath' | 'mediaPath') { + picker.value = { + open : true, + field, + currentPath: (form.value as any)[field] || '/', + } +} + +function onPickerSelect(path: string) { + (form.value as any)[picker.value.field] = path + picker.value.open = false +} const isDocker = ref(false) const torrentsStatus = ref({ exists: false, count: 0, empty: true }) @@ -272,7 +338,7 @@ onMounted(async () => { if (availRes.ok) availableClients.value = await availRes.json() if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker - for (const c of clients.value) await refreshHealth(c.uuid) + for (const c of clients.value) refreshHealth(c.uuid) }) // ── Healthcheck ──────────────────────────────────────────────── @@ -362,7 +428,7 @@ async function saveClient() { if (res.ok) { const client = await res.json() clients.value.push(client) - await refreshHealth(client.uuid) + refreshHealth(client.uuid) closeModal() toast('Client enregistré ✓', 'success') } else { @@ -394,6 +460,31 @@ async function update() { } } +// ── Scan bibliothèque ────────────────────────────────────────── +async function scan() { + scanning.value = true + scanResult.value = null + try { + const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' }) + if (res.ok) { + const data = await res.json() + scanResult.value = { found: data.found, added: data.added } + toast( + data.added > 0 + ? `${data.found} fichiers scannés — ${data.added} ajoutés` + : `${data.found} fichiers scannés — rien de nouveau`, + 'success' + ) + } else { + toast('Erreur lors du scan', 'error') + } + } catch { + toast('Impossible de contacter le serveur', 'error') + } finally { + scanning.value = false + } +} + // ── Sauvegarder settings ─────────────────────────────────────── async function save() { saving.value = true