refacto: réécriture des textes, messages et commentaires

This commit is contained in:
ElPouki committed 2026-09-15 15:30:20 +02:00
1 parent 6a3cb21adf
commit 586eccbbb3
118 files changed
+1621 -2159

No files matched your search

+7 -11
View File
@@ -8,7 +8,6 @@
</button>
</Teleport>
<!-- ── Version ────────────────────────────────────────────── -->
<SettingsSection title="Version">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -18,7 +17,7 @@
<span v-else-if="latestVersion" class="pill pill-ok h-5 px-[9px] text-[11px]">À jour</span>
</span>
<span class="text-meta text-muted">
<template v-if="debug">Démarré il y a {{ uptime }} · </template>vérification automatique toutes les 6 h
<template v-if="debug">Serveur démarré il y a {{ uptime }} · </template>mises à jour vérifiées toutes les 6 h
</span>
</div>
<a :href="releaseUrl" target="_blank" rel="noopener" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -27,8 +26,7 @@
</div>
</SettingsSection>
<!-- ── Diag ─────────────────────────────────────────── -->
<SettingsSection title="Diagnostic" description="Chiffres du processus en cours, utiles pour un rapport de bug.">
<SettingsSection title="Diagnostic" description="État du serveur, utile pour signaler un bug.">
<template #actions>
<button @click="fetchDebug" class="btn-ghost btn-sm"><RefreshCw :size="13" /> Actualiser</button>
</template>
@@ -41,7 +39,6 @@
<p v-else class="text-meta text-muted">Chargement…</p>
</SettingsSection>
<!-- ── Dev ──────────────────────────────────────── -->
<SettingsSection title="Développement">
<template #actions>
<span v-if="saved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -49,11 +46,10 @@
<SettingsToggle
v-model="devMode"
label="Mode développeur"
description="Affiche le panneau de diagnostic en bas de l'accueil : mémoire, cache, uptime, worker d'import."
description="Affiche aussi ce diagnostic en bas de l'accueil."
/>
</SettingsSection>
<!-- ── Assistant et visite ────────────────────────────────── -->
<SettingsSection title="Assistant et visite guidée">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
@@ -85,7 +81,7 @@ const saved = ref(false)
let saveTimer: ReturnType<typeof setTimeout> | null = null
let loaded = false
// ── Version ───────────────────────────────────────────────────
// ── Version ──
const GITHUB_REPO = 'masutayunikon/FanKarr'
const currentVersion = ref('')
const latestVersion = ref('')
@@ -120,7 +116,7 @@ async function checkForUpdates() {
}
}
// ── Diag ────────────────────────────────────────────────
// ── Diagnostic ──
const debug = ref<any>(null)
async function fetchDebug() {
@@ -139,8 +135,8 @@ const diagnostics = computed(() => debug.value ? [
{ label: `mémoire utilisée sur ${debug.value.memory.heapTotal} Mo`, value: `${debug.value.memory.heapUsed} Mo` },
{ label: 'mémoire système (RSS)', value: `${debug.value.memory.rss} Mo` },
{ label: 'fichiers suivis', value: debug.value.organized.trackedFiles.toLocaleString('fr-FR') },
{ label: `entrées en cache (${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: 'worker d’import', value: debug.value.worker.running ? 'Actif' : 'Inactif', accent: debug.value.worker.running },
{ label: `entrées en cache (gardées ${debug.value.cache.ttlHours} h)`, value: debug.value.cache.entries },
{ label: "tâche d'import", value: debug.value.worker.running ? 'Active' : 'Inactive', accent: debug.value.worker.running },
] : [])
onMounted(async () => {
+21 -26
View File
@@ -8,14 +8,12 @@
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Données manquantes -->
<div v-if="status.empty" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-center justify-between gap-4 flex-wrap">
<div class="flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
@@ -23,17 +21,16 @@
<p class="card-title text-accent">Données manquantes</p>
<p class="text-meta text-secondary">
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
Téléchargez les données pour utiliser FanKarr.
Synchronisez le catalogue pour utiliser FanKarr.
</p>
</div>
</div>
<button @click="update" :disabled="updating" class="btn-primary">
{{ updating ? 'Téléchargement…' : 'Télécharger' }}
{{ updating ? 'Synchronisation…' : 'Synchroniser' }}
</button>
</div>
<!-- Sources -->
<SettingsSection title="Sources" description="Le scraper sert de source principale ; l’API Fankai prend le relais pour ce qui lui manque.">
<SettingsSection title="Sources" description="Les séries et les torrents viennent du scraper GitHub. L'API Fankai complète les séries qui n'y figurent pas encore.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -41,36 +38,35 @@
<span class="pill h-5 px-[9px] text-[11px]" :class="status.empty ? 'pill-err' : 'pill-ok'">{{ status.empty ? 'Vide' : 'Chargé' }}</span>
</span>
<span class="text-meta text-muted">
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
{{ status.empty ? 'Aucune donnée chargée' : plural(status.count, 'série disponible', 'séries disponibles') }}<template v-if="status.syncedAt"> · synchronisé {{ formatRelative(status.syncedAt) }}</template>
</span>
</div>
<span class="text-meta text-muted shrink-0">Cache d’une heure</span>
<span class="text-meta text-muted shrink-0">Données gardées en cache 1 h</span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">API Fankai</span>
<span class="text-meta text-muted">
<template v-if="apiOnlyCount > 0">{{ apiOnlyCount }} série{{ apiOnlyCount > 1 ? 's' : '' }} au catalogue sans données du scraper : fiches et images lues depuis l’API, sans torrents.</template>
<template v-else>Titres à jour et séries pas encore scrapées, lues depuis l’API.</template>
<template v-if="apiOnlyCount > 0">{{ plural(apiOnlyCount, 'série vient', 'séries viennent') }} uniquement de l'API : fiche et images, mais pas de torrents.</template>
<template v-else>Fournit les titres à jour et les séries absentes du scraper.</template>
</span>
</div>
</div>
</SettingsSection>
<!-- Surveillance RSS -->
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés au client sans rien demander. Vérification automatique toutes les 6 heures.">
<SettingsSection title="Surveillance RSS" description="Les nouveaux épisodes des séries surveillées sont envoyés automatiquement au client. Vérification toutes les 6 heures.">
<template #actions>
<button @click="runRss" :disabled="runningRss || watched.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px]">
<Loader v-if="runningRss" :size="14" class="animate-spin" />
<RefreshCw v-else :size="14" />
{{ runningRss ? 'Synchro…' : 'Lancer une synchro' }}
{{ runningRss ? 'Vérification…' : 'Vérifier maintenant' }}
</button>
</template>
<div class="flex items-baseline gap-[9px]">
<span class="font-display text-[30px] font-bold text-primary">{{ watched.length }}</span>
<span class="text-meta text-muted">série{{ watched.length > 1 ? 's' : '' }} surveillée{{ watched.length > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ watched.length > 1 ? 'séries surveillées' : 'série surveillée' }}</span>
</div>
<div v-if="watched.length > 0" class="flex items-center gap-2 flex-wrap">
<RouterLink
@@ -79,17 +75,16 @@
class="h-[26px] px-2.5 rounded-full bg-hover text-meta text-secondary hover:text-primary transition-colors flex items-center"
>{{ s.serieName }}</RouterLink>
<button v-if="watched.length > visibleWatched.length" @click="showAllWatched = true" class="text-meta text-muted hover:text-primary">
+ {{ watched.length - visibleWatched.length }} autre{{ watched.length - visibleWatched.length > 1 ? 's' : '' }}
+ {{ plural(watched.length - visibleWatched.length, 'autre') }}
</button>
</div>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou par la sélection de la médiathèque.</p>
<p v-else class="text-meta text-muted">Activez la surveillance depuis une fiche série ou en sélectionnant plusieurs séries dans la médiathèque.</p>
</SettingsSection>
<!-- Surveillances orphelines -->
<SettingsSection
v-if="syncOrphans.length > 0"
:title="`Surveillances orphelines · ${syncOrphans.length}`"
description="Séries surveillées qui n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
:title="`Séries surveillées disparues du catalogue · ${syncOrphans.length}`"
description="Ces séries n'existent plus dans le catalogue (supprimées ou recréées sous un autre nom). Réactivez la surveillance sur la nouvelle fiche si besoin."
>
<div class="flex flex-col rounded-field bg-main border border-border-light">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 px-3.5 py-2 border-b border-hover last:border-b-0">
@@ -111,7 +106,7 @@ import { RouterLink } from 'vue-router'
import { Loader, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { useSeriesStore } from '@/stores/series'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
@@ -153,10 +148,10 @@ async function fetchSyncOrphans() {
async function removeSyncOrphan(serieId: number) {
try {
const res = await fetch(`/api/rss-sync/${serieId}`, { method: 'DELETE', credentials: 'include' })
if (!res.ok) { toast('Erreur lors du retrait de la surveillance', 'error'); return }
if (!res.ok) { toast('Impossible de retirer la surveillance', 'error'); return }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
watched.value = watched.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
toast('Surveillance retirée', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
@@ -167,8 +162,8 @@ async function runRss() {
try {
const res = await fetch('/api/rss-sync/run', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la synchro', 'error'); return }
toast(data.sent > 0 ? `${data.sent} épisode(s) envoyé(s) au client ✓` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de vérifier les séries surveillées', 'error'); return }
toast(data.sent > 0 ? `${plural(data.sent, 'épisode envoyé', 'épisodes envoyés')} au client` : 'Aucun nouvel épisode', data.errors > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -183,11 +178,11 @@ async function update() {
if (res.ok) {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0, syncedAt: new Date().toISOString() }
toast(`${count} séries chargées ✓`, 'success')
toast(`Catalogue synchronisé : ${plural(count, 'série')}`, 'success')
fetchSyncOrphans()
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
toast(error ?? 'Impossible de synchroniser le catalogue', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+21 -20
View File
@@ -7,11 +7,10 @@
</button>
</Teleport>
<!-- Clients configurés -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Clients configurés</h3>
<p class="text-meta text-muted">FanKarr envoie tout dans sa catégorie et ne touche qu’à celle-là.</p>
<p class="text-meta text-muted">FanKarr range ses torrents dans une catégorie dédiée (« fankai » par défaut) et ne touche pas aux autres.</p>
</div>
<div v-if="clients.length === 0" class="flex items-center justify-between gap-4 flex-wrap px-5 py-4 border-t border-hover">
@@ -57,11 +56,10 @@
</div>
</section>
<!-- Types pris en charge -->
<section class="bg-card rounded-card overflow-hidden">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">Types pris en charge</h3>
<p class="text-meta text-muted">Ce que chaque client sait faire avec FanKarr. Un même serveur peut cumuler plusieurs clients.</p>
<h3 class="card-title">Clients compatibles</h3>
<p class="text-meta text-muted">Fonctions disponibles selon le client. Vous pouvez en configurer plusieurs.</p>
</div>
<div class="overflow-x-auto border-t border-hover">
<table class="w-full min-w-[720px] text-meta">
@@ -73,7 +71,7 @@
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Progression par fichier</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Envoi du .torrent</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Catégories</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppr. fichiers</th>
<th class="text-center px-3 h-[34px] tag-label tracking-[0.12em] font-bold">Suppression des fichiers</th>
</tr>
</thead>
<tbody>
@@ -91,9 +89,9 @@
</div>
</td>
<td class="px-3 py-2.5 text-secondary whitespace-nowrap">{{ row.auth }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Async ✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileSelect ? 'text-ok' : 'text-muted'">{{ row.fileSelect ? 'Immédiat' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.fileProgress ? 'text-ok' : 'text-muted'">{{ row.fileProgress ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Proxy ✓' : row.torrentUpload === 'url' ? 'URL' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.torrentUpload === 'proxy' ? 'text-ok' : 'text-secondary'">{{ row.torrentUpload === 'proxy' ? 'Via FanKarr' : row.torrentUpload === 'url' ? 'Lien direct' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.categories ? 'text-ok' : 'text-muted'">{{ row.categories ? '✓' : '—' }}</td>
<td class="text-center px-3 py-2.5" :class="row.deleteFiles ? 'text-ok' : 'text-muted'">{{ row.deleteFiles ? '✓' : '—' }}</td>
</tr>
@@ -101,13 +99,12 @@
</table>
</div>
<div class="flex flex-col gap-1 px-5 py-3.5 border-t border-hover text-xs text-muted">
<span><span class="text-ok">Async ✓</span> — sélection dès réception des métadonnées, aucun octet superflu téléchargé.</span>
<span><span class="text-ok">Proxy ✓</span> — FanKarr télécharge le .torrent et l'envoie au client (liens protégés pris en charge).</span>
<span><span class="text-secondary">URL</span> — le client télécharge le .torrent directement depuis l'adresse.</span>
<span><span class="text-ok">Immédiat</span> : seuls les fichiers choisis sont téléchargés, dès la réception des métadonnées.</span>
<span><span class="text-ok">Via FanKarr</span> : FanKarr récupère le fichier .torrent et le transmet au client (fonctionne avec les liens protégés).</span>
<span><span class="text-secondary">Lien direct</span> : le client récupère lui-même le fichier .torrent.</span>
</div>
</section>
<!-- Modale ajout / modification -->
<Teleport to="body">
<div v-if="modal.open" class="modal-backdrop" @click.self="closeModal">
<div class="modal max-w-lg max-h-[90vh] overflow-y-auto" role="dialog" aria-modal="true" aria-labelledby="client-modal-title">
@@ -155,28 +152,32 @@ function matrixHealth(type: string) {
const clientMatrix = [
{
id: 'qbittorrent', label: 'qBittorrent', version: '4.1+',
auth: 'Login / MdP · Clé API (5.2+)', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe · Clé API (5.2+)", fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
{
id: 'transmission', label: 'Transmission', version: '3.x / 4.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'rtorrent', label: 'rTorrent / ruTorrent', version: '0.9+',
auth: 'URL xmlrpc', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: 'Adresse XML-RPC', fileSelect: true, fileProgress: true, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'utorrent', label: 'uTorrent', version: '3.x',
auth: 'Login / MdP', fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: true, fileProgress: false, torrentUpload: 'url', categories: true, deleteFiles: true,
},
{
id: 'synology-ds', label: 'Synology Download Station', version: '6.x+',
auth: 'Login / MdP', fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
auth: "Nom d'utilisateur et mot de passe", fileSelect: false, fileProgress: false, torrentUpload: 'url', categories: false, deleteFiles: true,
},
{
id: 'real-debrid', label: 'Real-Debrid', version: 'API v1',
auth: 'Clé API', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: false, deleteFiles: true,
}
},
{
id: 'deluge', label: 'Deluge', version: 'Interface web',
auth: 'Mot de passe', fileSelect: true, fileProgress: true, torrentUpload: 'proxy', categories: true, deleteFiles: true,
},
]
const modal = ref({ open: false, key: 0, client: null as SavedTorrentClient | null })
@@ -211,7 +212,7 @@ async function testClient(uuid: string) {
try {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
toast(ok ? 'Connexion réussie' : (message ?? 'Échec de la connexion'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -229,7 +230,7 @@ async function deleteClient(client: SavedTorrentClient) {
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
} else {
toast('Erreur lors de la suppression', 'error')
toast('Impossible de supprimer le client', 'error')
}
}
+71 -72
View File
@@ -5,18 +5,17 @@
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:h-[38px] pointer-fine:w-[38px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]" title="Repère les épisodes présents sur le disque mais pas encore suivis">
<Loader v-if="scanning" :size="15" class="animate-spin" />
<ScanSearch v-else :size="15" />
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
</Teleport>
<!-- ── Nommage ────────────────────────────────────────────── -->
<SettingsSection title="Nommage">
<template #description>
FanKarr renomme les fichiers d’après le catalogue Fankai, en
<span class="text-secondary">{{ nfoSupport ? 'mode NFO' : 'mode formaté' }}</span>.
FanKarr renomme les fichiers d'après le catalogue Fankai.
<span class="text-secondary">{{ nfoSupport ? 'NFO activés : chaque fichier prend le nom de son NFO.' : 'NFO désactivés : noms du type Titre.S01E01.' }}</span>
</template>
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
@@ -24,7 +23,8 @@
<span class="text-sm font-medium text-primary">Renommer tous les fichiers</span>
<span class="text-meta text-muted">
<template v-if="loading">Analyse des fichiers importés…</template>
<template v-else-if="totalNeedsRename > 0">{{ totalNeedsRename }} épisode{{ totalNeedsRename > 1 ? 's ne portent' : ' ne porte' }} pas le nom attendu, sur {{ seriesNeedingRename }} série{{ seriesNeedingRename > 1 ? 's' : '' }}.</template>
<template v-else-if="seriesNeedingRename > 1">{{ totalNeedsRename }} épisodes répartis sur {{ seriesNeedingRename }} séries ne portent pas le nom attendu.</template>
<template v-else-if="totalNeedsRename > 0">{{ plural(totalNeedsRename, 'épisode ne porte', 'épisodes ne portent') }} pas le nom attendu.</template>
<template v-else>Tous les fichiers portent le nom attendu.</template>
</span>
</div>
@@ -33,7 +33,7 @@
</button>
</div>
<p v-if="renameResult" class="text-meta flex items-center gap-2" :class="renameResult.errors.length > 0 ? 'text-err' : 'text-ok'">
{{ renameResult.done }} fichier{{ renameResult.done > 1 ? 's' : '' }} renommé{{ renameResult.done > 1 ? 's' : '' }}<template v-if="renameResult.errors.length > 0"> · {{ renameResult.errors.length }} erreur{{ renameResult.errors.length > 1 ? 's' : '' }}</template>
{{ plural(renameResult.done, 'fichier renommé', 'fichiers renommés') }}<template v-if="renameResult.errors.length > 0"> · {{ plural(renameResult.errors.length, 'erreur') }}</template>
<button @click="renameResult = null" class="text-muted hover:text-primary" aria-label="Masquer"><X :size="13" /></button>
</p>
@@ -43,7 +43,7 @@
<span class="text-sm font-medium text-primary">Renommer les dossiers de série</span>
<span class="text-meta text-muted">
<template v-if="staleFolders.length === 0">Chaque dossier porte le titre de sa série.</template>
<template v-else>{{ staleFolders.length }} dossier{{ staleFolders.length > 1 ? 's diffèrent' : ' diffère' }} du titre de la série : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
<template v-else>{{ staleFolders.length > 1 ? `${staleFolders.length} dossiers ne portent pas le titre de leur série` : 'Un dossier ne porte pas le titre de sa série' }} : {{ staleFolders.map(f => `« ${f.current.map(basename).join(', ')} »`).join(', ') }}.</template>
</span>
</div>
<button @click="renameFolders" :disabled="renamingFolders || staleFolders.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -54,24 +54,23 @@
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Forcer la mise à jour des correspondances</span>
<span class="text-meta text-muted">Recharge le catalogue en ignorant les caches, migre les identifiants d'épisodes, puis renomme les fichiers importés.</span>
<span class="text-sm font-medium text-primary">Tout resynchroniser</span>
<span class="text-meta text-muted">Recharge entièrement le catalogue, met à jour les identifiants d'épisodes et renomme les fichiers. Utile si des épisodes ne sont plus reconnus.</span>
</div>
<button @click="forceMetadataRefresh" :disabled="forcingRefresh" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ forcingRefresh ? 'Rechargement…' : 'Forcer' }}
{{ forcingRefresh ? 'Resynchronisation…' : 'Lancer' }}
</button>
</div>
</SettingsSection>
<!-- ── Métadonnées ────────────────────────────────────────── -->
<SettingsSection title="Métadonnées" description="Fichiers NFO et images pour Kodi, Infuse ou Plex.">
<SettingsSection title="Métadonnées" description="Fichiers NFO et images, pour Infuse ou les lecteurs sans agent Fankai.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Vérifier les NFO</span>
<span class="text-meta text-muted">
<template v-if="!nfoSupport">Compare les NFO locaux avec GitLab. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux avec GitLab. Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux avec GitLab et télécharge ceux qui ont changé. Vérification automatique toutes les heures.</template>
<template v-if="!nfoSupport">Compare les NFO locaux à ceux du dépôt Fankai. Activez d'abord les NFO dans Gestion des médias.</template>
<template v-else-if="lastNfoUpdate">Compare les NFO locaux à ceux du dépôt Fankai (toutes les heures). Dernière mise à jour : {{ lastNfoUpdate.serieTitle }}, {{ formatRelative(lastNfoUpdate.updatedAt) }}.</template>
<template v-else>Compare les NFO locaux à ceux du dépôt Fankai et télécharge ceux qui ont changé (toutes les heures).</template>
</span>
</div>
<button @click="checkNfo" :disabled="checkingNfo || !nfoSupport" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
@@ -90,22 +89,22 @@
</div>
</SettingsSection>
<!-- ── Cohérence de la bibliothèque ───────────────────────── -->
<SettingsSection title="Cohérence de la bibliothèque" description="Ce que FanKarr croit avoir importé, comparé à ce qui est réellement sur le disque.">
<SettingsSection title="Cohérence de la médiathèque" description="Compare les épisodes suivis par FanKarr au contenu du disque et au catalogue.">
<div class="flex items-baseline gap-x-7 gap-y-2 flex-wrap">
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">fichier{{ trackedFiles > 1 ? 's' : '' }} suivi{{ trackedFiles > 1 ? 's' : '' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">entrée{{ orphans.length > 1 ? 's' : '' }} orpheline{{ orphans.length > 1 ? 's' : '' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">fichier{{ missingFiles > 1 ? 's' : '' }} introuvable{{ missingFiles > 1 ? 's' : '' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis le dernier scan</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ trackedFiles.toLocaleString('fr-FR') }}</span><span class="text-meta text-muted">{{ trackedFiles > 1 ? 'fichiers suivis' : 'fichier suivi' }}</span></span>
<span class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold" :class="orphans.length > 0 ? 'text-accent' : 'text-primary'">{{ orphans.length }}</span><span class="text-meta text-muted">{{ orphans.length > 1 ? 'épisodes disparus du catalogue' : 'épisode disparu du catalogue' }}</span></span>
<span v-if="missingFiles > 0" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-err">{{ missingFiles }}</span><span class="text-meta text-muted">{{ missingFiles > 1 ? 'fichiers introuvables' : 'fichier introuvable' }}</span></span>
<span v-if="lastScan" class="flex items-baseline gap-[9px]"><span class="font-display text-[30px] font-bold text-primary">{{ sinceScan }}</span><span class="text-meta text-muted">depuis la dernière analyse</span></span>
</div>
<div class="h-px bg-hover" />
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Nettoyer les orphelins</span>
<span class="text-sm font-medium text-primary">Retirer les épisodes disparus du catalogue</span>
<span class="text-meta text-muted">
<template v-if="orphans.length === 0">Aucune entrée suivie hors du catalogue.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n’existent plus dans le catalogue. Aucun fichier n’est supprimé.</template>
<template v-if="orphans.length === 0">Aucun épisode suivi n'a disparu du catalogue.</template>
<template v-else-if="orphans.length === 1">Retire du suivi l'épisode qui n'existe plus dans le catalogue. Aucun fichier n'est supprimé.</template>
<template v-else>Retire du suivi les {{ orphans.length }} épisodes qui n'existent plus dans le catalogue. Aucun fichier n'est supprimé.</template>
</span>
<button v-if="orphans.length > 0" @click="orphansOpen = !orphansOpen" class="text-meta text-secondary hover:text-primary w-fit flex items-center gap-1 mt-0.5">
{{ orphansOpen ? 'Masquer le détail' : 'Voir le détail' }}
@@ -113,7 +112,7 @@
</button>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans || orphans.length === 0" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ removingOrphans ? '…' : 'Nettoyer' }}
{{ removingOrphans ? 'Retrait…' : 'Retirer' }}
</button>
</div>
<div v-if="orphansOpen && orphans.length > 0" class="flex flex-col rounded-field bg-main border border-border-light">
@@ -121,7 +120,7 @@
<span class="text-meta text-muted tabular-nums shrink-0 w-16">
{{ o.season != null && o.episode != null ? `S${pad(o.season)}E${pad(o.episode)}` : `#${o.episode_id}` }}
</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span class="flex-1 min-w-0 text-meta text-secondary truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? 'Chemin inconnu' }}</span>
<span v-if="!o.file_exists" class="pill pill-err h-5 px-2 text-[10.5px] shrink-0">Introuvable</span>
<button @click="removeOrphans([o.episode_id])" :disabled="removingOrphans" class="btn-ghost btn-sm h-7 shrink-0">Retirer</button>
</div>
@@ -131,7 +130,7 @@
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="text-sm font-medium text-primary">Migrer les identifiants</span>
<span class="text-meta text-muted">À lancer après un renommage de série côté Fankai, quand les identifiants d’épisodes ont changé.</span>
<span class="text-meta text-muted">À utiliser si Fankai a renommé une série et que ses épisodes ne sont plus reconnus.</span>
</div>
<button @click="migrateIds" :disabled="migrating" class="btn-secondary btn-sm pointer-fine:h-[34px] shrink-0">
{{ migrating ? 'Migration…' : 'Migrer' }}
@@ -139,12 +138,11 @@
</div>
</SettingsSection>
<!-- ── Fichiers importés, série par série ──────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5">
<div class="flex flex-col gap-0.5">
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ series.length }} série{{ series.length > 1 ? 's' : '' }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode, avec renommage et désimport au cas par cas.</p>
<h3 class="card-title">Fichiers importés <span class="font-normal text-muted">· {{ plural(series.length, 'série') }}</span></h3>
<p class="text-meta text-muted">Nom actuel et nom attendu de chaque épisode. Renommez ou retirez les épisodes un par un.</p>
</div>
<button
v-if="totalNeedsRename > 0"
@@ -162,7 +160,7 @@
<div v-else-if="series.length === 0" class="flex flex-col items-center gap-1.5 py-12 text-center border-t border-hover">
<p class="text-body text-primary">Aucune série importée</p>
<p class="text-meta text-muted">Importez des séries depuis l’Activité ou par l’import manuel d’une fiche série.</p>
<p class="text-meta text-muted">Importez des séries depuis la page Activité ou par l'import manuel d'une fiche série.</p>
</div>
<div v-for="serie in filteredSeries" :key="serie.serie_id" class="border-t border-hover">
@@ -176,13 +174,13 @@
<ChevronUp :size="15" class="transition-transform" :class="collapsed.has(serie.serie_id) ? 'rotate-180' : ''" />
</button>
<RouterLink :to="`/series/${serie.serie_id}`" class="text-sm font-medium text-primary hover:text-accent transition-colors truncate">{{ serie.serie_title }}</RouterLink>
<span class="text-meta text-muted">{{ serie.total }} épisode{{ serie.total > 1 ? 's' : '' }}</span>
<span class="text-meta text-muted">{{ plural(serie.total, 'épisode') }}</span>
<span v-if="serie.needs_rename > 0" class="pill pill-wait h-5 px-2 text-[11px]">{{ serie.needs_rename }} à renommer</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ missingIn(serie) }} introuvable{{ missingIn(serie) > 1 ? 's' : '' }}</span>
<span v-if="missingIn(serie) > 0" class="pill pill-err h-5 px-2 text-[11px]">{{ plural(missingIn(serie), 'introuvable') }}</span>
<span v-else-if="serie.needs_rename === 0" class="flex items-center gap-1 text-meta text-ok"><Check :size="13" /> À jour</span>
<div class="flex items-center gap-1.5 ml-auto">
<button v-if="serie.needs_rename > 0" @click="renameAll(serie.serie_id)" :disabled="renamingAll" class="btn-secondary btn-sm h-7 px-3">Tout renommer</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportSerie(serie, false)" :disabled="renamingAll" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportSerie(serie, true)" :disabled="renamingAll" class="btn-danger btn-sm h-7 px-3">Supprimer les fichiers</button>
</div>
</div>
@@ -210,10 +208,10 @@
<span v-if="!ep.file_exists" class="pill pill-err h-5 px-2 text-[10.5px]">Introuvable</span>
<Check v-else-if="!ep.needs_rename" :size="14" class="text-ok mx-1" />
<button v-else @click="renameSingle(ep, serie)" :disabled="renamingEp[ep.episode_id]" class="pill pill-wait h-7 px-3 hover:bg-accent-muted transition-colors">
{{ renamingEp[ep.episode_id] ? '…' : 'Renommer' }}
{{ renamingEp[ep.episode_id] ? 'Renommage…' : 'Renommer' }}
</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Désimporter</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer</button>
<button @click="unimportEp(ep, false)" :disabled="renamingEp[ep.episode_id]" class="btn-ghost btn-sm h-7 px-3">Retirer</button>
<button @click="unimportEp(ep, true)" :disabled="renamingEp[ep.episode_id]" class="btn-danger btn-sm h-7 px-3">Supprimer le fichier</button>
</div>
</div>
</div>
@@ -228,12 +226,12 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowRight, Check, ChevronDown, ChevronUp, Loader, RefreshCw, ScanSearch, X } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
const { add: toast } = useToast()
// ─── State ────────────────────────────────────────────────────
// ── État ──
const loading = ref(true)
const renamingAll = ref(false)
const purgingNfo = ref(false)
@@ -259,7 +257,7 @@ const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
// ── Calculs ──
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
const seriesNeedingRename = computed(() => series.value.filter(s => s.needs_rename > 0).length)
const trackedFiles = computed(() => series.value.reduce((acc, s) => acc + s.total, 0) + orphans.value.length)
@@ -277,17 +275,16 @@ const filteredSeries = computed(() =>
showOnlyNeedsRename.value ? series.value.filter(s => s.needs_rename > 0) : series.value
)
// ─── Actions ──────────────────────────────────────────────────
// ── Actions ──
async function load() {
loading.value = true
try {
const res = await fetch('/api/organized-summary', { credentials: 'include' })
if (!res.ok) { toast('Erreur chargement', 'error'); return }
if (!res.ok) { toast('Impossible de charger les fichiers importés', 'error'); return }
const data = await res.json()
series.value = data.series ?? []
nfoSupport.value = data.nfo_support ?? false
orphans.value = data.orphans ?? []
// Replier les séries sans rename par défaut
collapsed.value = new Set(series.value.filter(s => s.needs_rename === 0).map((s: any) => s.serie_id))
} finally {
loading.value = false
@@ -313,8 +310,8 @@ async function scan() {
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? "Erreur lors de l'analyse", 'error'); return }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
if (!res.ok) { toast(data.error ?? "Impossible d'analyser la médiathèque", 'error'); return }
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -327,17 +324,17 @@ async function renameFolders() {
const targets = staleFolders.value
if (!targets.length) return
const names = targets.map(f => `« ${basename(f.expected)} »`).join(', ')
if (!confirm(`Déplacer le contenu vers ${names} ? Les fichiers existants ne sont jamais écrasés.`)) return
if (!confirm(`Renommer ${targets.length > 1 ? 'les dossiers' : 'le dossier'} en ${names} ? Les fichiers déjà présents à destination ne seront pas écrasés.`)) return
renamingFolders.value = true
let moved = 0
try {
for (const f of targets) {
const res = await fetch(`/api/organized/${f.serie_id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'erreur'}`, 'error'); continue }
if (!res.ok) { toast(`${f.serie_title} : ${d.error ?? 'impossible de renommer le dossier'}`, 'error'); continue }
moved += d.moved ?? 0
}
if (moved > 0) toast(`${moved} fichier${moved > 1 ? 's' : ''} déplacé${moved > 1 ? 's' : ''} ✓`, 'success')
if (moved > 0) toast(plural(moved, 'fichier déplacé', 'fichiers déplacés'), 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -351,7 +348,7 @@ async function checkNfo() {
try {
const res = await fetch('/api/nfo-updates/check', { method: 'POST', credentials: 'include' })
const data = await res.json()
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Erreur'), res.ok ? 'success' : 'error')
toast(res.ok ? 'Vérification des NFO lancée en arrière-plan' : (data.error ?? 'Impossible de lancer la vérification des NFO'), res.ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -364,8 +361,8 @@ async function migrateIds() {
try {
const res = await fetch('/api/organize/migrate-ids', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors de la migration', 'error'); return }
toast(data.updated > 0 ? `${data.updated} identifiant${data.updated > 1 ? 's' : ''} migré${data.updated > 1 ? 's' : ''} ✓` : 'Aucun identifiant à migrer', 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de migrer les identifiants', 'error'); return }
toast(data.updated > 0 ? plural(data.updated, 'identifiant migré', 'identifiants migrés') : 'Aucun identifiant à migrer', 'success')
if (data.updated > 0) await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -391,7 +388,8 @@ async function renameAll(serieId?: number) {
credentials: 'include',
body : JSON.stringify(serieId ? { serie_id: serieId } : {}),
})
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { toast(data.error ?? 'Impossible de renommer les fichiers', 'error'); return }
renameResult.value = { done: data.done, errors: data.errors ?? [] }
if (data.done > 0) await load()
} catch {
@@ -411,7 +409,7 @@ async function renameSingle(ep: any, serie: any) {
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 }
if (!res.ok) { toast(data.error ?? "Impossible de renommer l'épisode", 'error'); return }
toast(`Renommé : ${data.new_name}`, 'success')
await load()
} catch {
@@ -431,12 +429,13 @@ async function forceMetadataRefresh() {
body : JSON.stringify({ force: true }),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur lors du rechargement', 'error'); return }
const parts = [`${data.count} séries rechargées`]
if (data.migrated > 0) parts.push(`${data.migrated} ID(s) migré(s)`)
if (data.deduped > 0) parts.push(`${data.deduped} doublon(s) retiré(s)`)
parts.push(`${data.renamed} fichier${data.renamed > 1 ? 's' : ''} renommé${data.renamed > 1 ? 's' : ''}`)
toast(parts.join(' · ') + ' ✓', data.errors > 0 ? 'error' : 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de tout resynchroniser', 'error'); return }
const parts = [plural(data.count, 'série rechargée', 'séries rechargées')]
if (data.migrated > 0) parts.push(plural(data.migrated, 'identifiant migré', 'identifiants migrés'))
if (data.deduped > 0) parts.push(plural(data.deduped, 'doublon retiré', 'doublons retirés'))
parts.push(plural(data.renamed, 'fichier renommé', 'fichiers renommés'))
if (data.errors > 0) parts.push(plural(data.errors, 'erreur'))
toast(parts.join(' · '), data.errors > 0 ? 'error' : 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -446,13 +445,13 @@ async function forceMetadataRefresh() {
}
async function purgeNfo() {
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg, .tbn dans les dossiers de la médiathèque ?')) return
if (!confirm('Supprimer tous les fichiers .nfo, .png, .jpg et .tbn des dossiers de la médiathèque ? Les vidéos ne sont pas touchées.')) 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')
if (!res.ok) { toast(data.error ?? 'Impossible de purger les NFO', 'error'); return }
toast(`${plural(data.deleted, 'fichier supprimé', 'fichiers supprimés')}${data.errors.length > 0 ? ` (${plural(data.errors.length, 'erreur')})` : ''}`, data.errors.length > 0 ? 'error' : 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
@@ -461,15 +460,16 @@ async function purgeNfo() {
}
async function unimportSerie(serie: any, deleteFile: boolean) {
if (!confirm(`Désimporter "${serie.serie_title}"${deleteFile ? ' et supprimer les fichiers' : ''} ?`)) return
if (!confirm(deleteFile
? `Retirer « ${serie.serie_title} » et supprimer ses fichiers du disque ? Cette action est irréversible.`
: `Retirer « ${serie.serie_title} » de la médiathèque ? Les fichiers restent sur le disque.`)) return
renamingAll.value = true
try {
const res = await fetch(`/api/organized/${serie.serie_id}?deleteFile=${deleteFile}`, {
method: 'DELETE', credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
const data = await res.json()
toast(`${data.removed} épisode(s) désimporté(s) ✓`, 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer les fichiers de la série' : 'Impossible de retirer la série'), 'error'); return }
toast(deleteFile ? 'Épisodes retirés et fichiers supprimés' : 'Épisodes retirés de la médiathèque', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -481,15 +481,14 @@ async function unimportSerie(serie: any, deleteFile: boolean) {
async function unimportEp(ep: any, deleteFile: boolean) {
renamingEp.value[ep.episode_id] = true
try {
// On cherche le serie_id depuis la liste
const serie = series.value.find(s => s.episodes.some((e: any) => e.episode_id === ep.episode_id))
if (!serie) return
const res = await fetch(`/api/organized/${serie.serie_id}/${ep.episode_id}?deleteFile=${deleteFile}`, {
method : 'DELETE',
credentials: 'include',
})
if (!res.ok) { const d = await res.json(); toast(d.error ?? 'Erreur', 'error'); return }
toast(deleteFile ? 'Fichier supprimé ✓' : 'Désimporté ✓', 'success')
if (!res.ok) { const d = await res.json(); toast(d.error ?? (deleteFile ? 'Impossible de supprimer le fichier' : "Impossible de retirer l'épisode"), 'error'); return }
toast(deleteFile ? 'Fichier supprimé' : 'Épisode retiré', 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
@@ -499,7 +498,7 @@ async function unimportEp(ep: any, deleteFile: boolean) {
}
async function removeOrphans(episodeIds?: number[]) {
if (!episodeIds && !confirm(`Retirer ${orphans.value.length} entrée(s) orpheline(s) du suivi ? Aucun fichier n'est supprimé.`)) return
if (!episodeIds && !confirm(`Retirer du suivi ${plural(orphans.value.length, 'épisode disparu', 'épisodes disparus')} du catalogue ? Aucun fichier n'est supprimé.`)) return
removingOrphans.value = true
try {
const res = await fetch('/api/organized-summary/orphans', {
@@ -509,8 +508,8 @@ async function removeOrphans(episodeIds?: number[]) {
body : JSON.stringify(episodeIds ? { episode_ids: episodeIds } : {}),
})
const data = await res.json()
if (!res.ok) { toast(data.error ?? 'Erreur', 'error'); return }
toast(`${data.removed} entrée${data.removed > 1 ? 's' : ''} retirée${data.removed > 1 ? 's' : ''} du suivi ✓`, 'success')
if (!res.ok) { toast(data.error ?? 'Impossible de retirer les épisodes disparus', 'error'); return }
toast(`${plural(data.removed, 'épisode retiré', 'épisodes retirés')} du suivi`, 'success')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
+16 -18
View File
@@ -2,41 +2,38 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Configurez d’abord la clé API'">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]" :title="hasToken ? undefined : 'Renseignez d\'abord la clé API Jellyfin.'">
<RefreshCw :size="15" :class="{ 'animate-spin': syncing }" />
{{ syncing ? 'Synchronisation…' : 'Synchroniser Jellyfin' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
</Teleport>
<!-- ── Connexion Jellyfin ─────────────────────────────────── -->
<SettingsSection title="Jellyfin" description="La connexion au serveur Jellyfin sert à synchroniser les comptes et à authentifier les utilisateurs du plugin FanKarr Search.">
<JellyfinConnectionForm @change="hasToken = $event.hasToken" />
</SettingsSection>
<!-- ── Synchronisation des utilisateurs ──────────────────── -->
<SettingsSection
title="Synchronisation des utilisateurs"
description="Importe les utilisateurs Jellyfin comme comptes FanKarr. Les comptes existants sont ignorés ; les utilisateurs se connectent ensuite depuis le plugin sans autre réglage. Synchronisation automatique toutes les heures."
description="Crée un compte FanKarr pour chaque utilisateur Jellyfin actif (les comptes existants ne sont pas modifiés). Synchronisation automatique toutes les heures."
>
<div class="flex items-center gap-3 flex-wrap">
<button @click="syncUsers" :disabled="syncing || !hasToken" class="btn-secondary pointer-fine:h-[38px]">
{{ syncing ? 'Synchronisation…' : 'Synchroniser maintenant' }}
{{ syncing ? 'Synchronisation…' : 'Synchroniser les utilisateurs' }}
</button>
<span v-if="!hasToken" class="text-meta text-muted">Configurez d'abord la clé API.</span>
<span v-if="!hasToken" class="text-meta text-muted">Renseignez d'abord la clé API Jellyfin.</span>
<span v-else-if="syncError" class="text-meta text-err">{{ syncError }}</span>
<span v-else-if="syncResult" class="text-meta text-secondary">
<span class="text-ok">{{ syncResult.created }} compte{{ syncResult.created > 1 ? 's' : '' }} créé{{ syncResult.created > 1 ? 's' : '' }}</span>
· {{ syncResult.skipped }} ignoré{{ syncResult.skipped > 1 ? 's' : '' }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
<span class="text-ok">{{ plural(syncResult.created, 'compte créé', 'comptes créés') }}</span>
· {{ plural(syncResult.skipped, 'déjà existant ou désactivé', 'déjà existants ou désactivés') }}<template v-if="syncResult.users.length > 0"> · nouveaux : {{ syncResult.users.join(', ') }}</template>
</span>
</div>
</SettingsSection>
<!-- ── API publique ───────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex flex-col gap-0.5 px-5 pt-4 pb-3.5">
<h3 class="card-title">API publique</h3>
<p class="text-meta text-muted">
Base : <span class="text-secondary">{{ apiBase }}</span> — authentification par jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
Adresse : <span class="text-secondary">{{ apiBase }}</span> · Authentification : jeton FanKarr (en-tête <span class="text-secondary">Authorization: Bearer</span>) ou compte Jellyfin.
</p>
</div>
<div
@@ -57,6 +54,7 @@ import { ref } from 'vue'
import { RefreshCw } from 'lucide-vue-next'
import JellyfinConnectionForm from '@/components/settings/JellyfinConnectionForm.vue'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import { plural } from '@/utils/format'
const hasToken = ref(false)
const syncing = ref(false)
@@ -66,12 +64,12 @@ const syncError = ref<string | null>(null)
const apiBase = `${window.location.origin}/api/v1`
const apiEndpoints = [
{ method: 'GET', path: '/v1/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/v1/series/:id', desc: 'Fiche complète, saisons et épisodes' },
{ method: 'POST', path: '/v1/requests', desc: 'Créer ou compléter une demande au nom de l’utilisateur' },
{ method: 'GET', path: '/v1/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/v1/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/v1/auth/me', desc: 'Vérifier le jeton courant' },
{ method: 'GET', path: '/series/search?q=', desc: 'Chercher une série dans le catalogue' },
{ method: 'GET', path: '/series/:id', desc: 'Obtenir la fiche complète (saisons et épisodes)' },
{ method: 'POST', path: '/requests', desc: 'Créer ou compléter une demande au nom de l\'utilisateur' },
{ method: 'GET', path: '/requests', desc: 'Lister ses demandes et leur statut' },
{ method: 'POST', path: '/auth/jellyfin', desc: 'Échanger un jeton Jellyfin contre un jeton FanKarr' },
{ method: 'GET', path: '/auth/me', desc: 'Vérifier le jeton utilisé' },
]
async function syncUsers() {
@@ -81,7 +79,7 @@ async function syncUsers() {
const res = await fetch('/api/jellyfin/sync', { method: 'POST', credentials: 'include' })
const data = await res.json()
if (res.ok) syncResult.value = data
else syncError.value = data.error ?? 'Erreur lors de la synchronisation'
else syncError.value = data.error ?? 'Impossible de synchroniser les utilisateurs Jellyfin.'
syncing.value = false
}
</script>
+8 -15
View File
@@ -8,7 +8,6 @@
<button @click="showConfirm = true" class="btn-danger pointer-fine:h-[38px]">Vider les journaux</button>
</Teleport>
<!-- Filtres -->
<div class="flex items-center justify-between gap-x-4 gap-y-2.5 flex-wrap">
<div class="flex items-center gap-1.5 flex-wrap">
<button
@@ -43,26 +42,23 @@
class="segmented-item"
:class="{ 'is-active': limit === n }"
:aria-pressed="limit === n"
>{{ n === 2000 ? 'Tout' : n }}</button>
>{{ n.toLocaleString('fr-FR') }}</button>
</div>
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Rafraîchir" aria-label="Rafraîchir">
<button @click="load" :disabled="loading" class="btn-icon pointer-fine:w-[34px] pointer-fine:h-[34px]" title="Actualiser" aria-label="Actualiser">
<RefreshCw :size="15" :class="{ 'animate-spin': loading }" />
</button>
</div>
</div>
<!-- Chargement -->
<div v-if="loading && entries.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
Chargement…
</div>
<!-- Vide -->
<div v-else-if="entries.length === 0" class="card flex items-center justify-center py-16 text-muted text-body">
Aucune entrée{{ filterLevel !== 'all' || filterSource ? ' pour ce filtre' : '' }}.
</div>
<!-- Table -->
<section v-else class="bg-card rounded-card overflow-hidden">
<div class="overflow-x-auto">
<div class="min-w-[640px]">
@@ -90,9 +86,8 @@
</div>
</section>
<p class="text-meta text-muted">{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }} affichée{{ entries.length > 1 ? 's' : '' }} · fichier de {{ formatSize(fileSize) }}</p>
<p class="text-meta text-muted">{{ plural(entries.length, 'entrée affichée', 'entrées affichées') }} · fichier de {{ formatSize(fileSize) }}</p>
<!-- Confirmation -->
<Teleport to="body">
<div v-if="showConfirm" class="modal-backdrop" @click.self="showConfirm = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="logs-clear-title">
@@ -115,7 +110,7 @@
import { ref, computed, onMounted } from 'vue'
import { Download, RefreshCw, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatSize } from '@/utils/format'
import { formatSize, plural } from '@/utils/format'
const { add: toast } = useToast()
@@ -130,13 +125,12 @@ const limit = ref(100)
const levels = [
{ label: 'Tout', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Informations', value: 'info' },
{ label: 'Avertissements', value: 'warn' },
{ label: 'Erreurs', value: 'error' },
{ label: 'Debug', value: 'debug' },
{ label: 'Débogage', value: 'debug' },
]
// Compteurs et sources
const counts = computed(() => {
const c: Record<string, number> = {}
for (const e of allEntries.value) c[e.level] = (c[e.level] ?? 0) + 1
@@ -172,7 +166,6 @@ async function doClear() {
await load()
}
// Fichier texte
function exportLogs() {
const lines = entries.value.map(e => `${e.at}\t${e.level.toUpperCase()}\t${e.source}\t${e.msg}${e.meta ? `\t${JSON.stringify(e.meta)}` : ''}`)
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' })
@@ -196,8 +189,8 @@ function fullDate(iso: string): string {
function levelInfo(level: string): { label: string; class: string } {
return ({
debug: { label: 'Debug', class: 'pill-muted' },
info : { label: 'Info', class: 'border-transparent px-0 text-muted' },
debug: { label: 'Débogage', class: 'pill-muted' },
info : { label: 'Information', class: 'border-transparent px-0 text-muted' },
warn : { label: 'Avertissement', class: 'pill-wait' },
error: { label: 'Erreur', class: 'pill-err' },
} as Record<string, { label: string; class: string }>)[level] ?? { label: level, class: 'pill-muted' }
+18 -28
View File
@@ -2,20 +2,18 @@
<div class="flex flex-col gap-4">
<Teleport defer to="#settings-actions">
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ dirtyCount }} modification{{ dirtyCount > 1 ? 's' : '' }} non enregistrée{{ dirtyCount > 1 ? 's' : '' }}</span>
<span v-if="dirtyCount > 0" class="text-meta text-accent">{{ plural(dirtyCount, 'modification non enregistrée', 'modifications non enregistrées') }}</span>
<button @click="save" :disabled="saving || !loaded" class="btn-primary pointer-fine:h-[38px]">
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
</button>
</Teleport>
<!-- Chargement -->
<div v-if="!loaded" class="flex items-center justify-center gap-2 py-16 text-muted text-body">
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
</div>
<template v-else>
<!-- Dossiers -->
<SettingsSection title="Dossiers" :description="isDocker ? 'Ces deux chemins sont vus depuis le conteneur FanKarr.' : 'Où FanKarr trouve les téléchargements terminés et où il range la médiathèque.'">
<div class="grid md:grid-cols-2 gap-4">
<div v-for="field in pathFields" :key="field.key" class="flex flex-col gap-[7px] min-w-0">
@@ -39,18 +37,16 @@
</div>
</SettingsSection>
<!-- Alerte chemins non configurés -->
<div v-if="hasUnconfiguredPaths" class="rounded-card border border-accent/30 bg-accent/5 px-5 py-4 flex items-start gap-3.5">
<TriangleAlert :size="18" :stroke-width="1.75" class="text-accent shrink-0 mt-0.5" />
<div class="flex flex-col gap-1">
<p class="card-title text-accent">Chemins non configurés</p>
<p class="text-meta text-secondary">
L'import automatique est désactivé tant que les dossiers ne sont pas configurés. Configurez-les ci-dessus avant d'enregistrer.
L'import automatique reste désactivé tant que ces deux dossiers ne sont pas choisis.
</p>
</div>
</div>
<!-- Import -->
<SettingsSection title="Import" description="Ce que FanKarr fait des fichiers une fois le téléchargement terminé.">
<div class="flex items-center gap-x-[18px] gap-y-3 flex-wrap">
<div class="segmented segmented-lg" role="group" aria-label="Mode d'import">
@@ -66,40 +62,39 @@
{{ mode === 'hardlink' ? 'Hardlink' : mode === 'copy' ? 'Copier' : 'Déplacer' }}
</button>
</div>
<span class="text-meta text-muted">Le hardlink, recommandé, garde le fichier dans le client torrent pour le ratio, sans occuper deux fois la place.</span>
<span class="text-meta text-muted">Recommandé : le hardlink laisse le fichier en partage dans le client sans occuper deux fois l'espace. Les deux dossiers doivent être sur le même disque.</span>
</div>
<div class="h-px bg-hover" />
<SettingsToggle
v-model="form.autoImport"
label="Import automatique"
description="Importe dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
description="Importe les téléchargements terminés (vérification toutes les 5 minutes)."
/>
<SettingsToggle
:model-value="form.nfoSupport"
@update:model-value="onNfoToggle"
label="NFO et métadonnées"
description="Télécharge les NFO et les images depuis GitLab à l'import, pour Infuse ou un lecteur qui lit les NFO locaux."
label="Fichiers NFO et images"
description="À chaque import, ajoute les fichiers de description (.nfo) et les images. Utile pour Infuse ou les lecteurs sans agent Fankai."
/>
<SettingsToggle
v-if="form.organizeMode === 'move'"
v-model="form.deleteTorrentOnMove"
label="Supprimer le torrent après déplacement"
description="Retire automatiquement le torrent du client après un import en mode Déplacer."
description="Le torrent quitte le client une fois ses fichiers déplacés et n'est plus partagé."
/>
<SettingsToggle
v-model="form.autoUnimportMissing"
label="Désimporter si le fichier a disparu"
description="Au scan, retire de la bibliothèque les épisodes dont le fichier n'est plus sur le disque."
label="Retirer les épisodes dont le fichier a disparu"
description="Lors de l'analyse, retire de la médiathèque les épisodes dont le fichier n'existe plus."
/>
<SettingsToggle
v-model="form.englishDirectory"
label="Dossiers « Season » plutôt que « Saison »"
description="Les dossiers de saisons seront nommés « Season 01 » au lieu de « Saison 01 »."
label="Dossiers de saison en anglais"
description="« Season 01 » au lieu de « Saison 1 »."
/>
</SettingsSection>
<!-- Serveurs média -->
<SettingsSection title="Serveurs média" description="Les applications qui liront la médiathèque.">
<SettingsSection title="Serveurs multimédias" description="Les applications qui liront la médiathèque.">
<div class="flex items-center gap-5 flex-wrap sm:flex-nowrap">
<div class="flex-1 min-w-0 flex flex-col gap-[3px]">
<span class="flex items-center gap-2.5 text-sm font-medium text-primary">
@@ -120,7 +115,6 @@
</div>
</SettingsSection>
<!-- Scan -->
<div class="flex items-center gap-3 flex-wrap">
<button @click="scan" :disabled="scanning" class="btn-secondary pointer-fine:h-[38px]">
<Loader v-if="scanning" :size="15" class="animate-spin" />
@@ -128,13 +122,12 @@
{{ scanning ? 'Analyse…' : 'Analyser la médiathèque' }}
</button>
<span v-if="lastScan" class="text-meta text-muted">
Dernier scan {{ formatRelative(lastScan.at) }} · {{ lastScan.found }} fichier{{ lastScan.found > 1 ? 's' : '' }} trouvé{{ lastScan.found > 1 ? 's' : '' }}<template v-if="lastScan.added"> · {{ lastScan.added }} ajouté{{ lastScan.added > 1 ? 's' : '' }}</template>
Dernière analyse {{ formatRelative(lastScan.at) }} · {{ plural(lastScan.found, 'fichier trouvé', 'fichiers trouvés') }}<template v-if="lastScan.added"> · {{ plural(lastScan.added, 'ajouté') }}</template>
</span>
</div>
</template>
<!-- Choix de dossier -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@@ -142,14 +135,12 @@
@cancel="picker.open = false"
/>
<!-- Assistant Plex -->
<PlexWizard
v-if="plexOpen"
:media-path="form.mediaPath"
@close="plexOpen = false"
/>
<!-- Confirmation NFO -->
<Teleport to="body">
<div v-if="nfoConfirmOpen" class="modal-backdrop" @click.self="nfoConfirmOpen = false">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="nfo-title">
@@ -178,7 +169,7 @@ import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { Loader, ScanSearch, TriangleAlert } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { formatRelative } from '@/utils/format'
import { formatRelative, plural } from '@/utils/format'
import SettingsSection from '@/components/settings/SettingsSection.vue'
import FolderPicker from '@/components/FolderPicker.vue'
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
@@ -198,7 +189,7 @@ const savedSnapshot = ref('')
const pathFields = [
{ key: 'completePath', label: 'Dossier des téléchargements terminés', hint: 'Là où le client torrent dépose ses fichiers.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Racine de la bibliothèque lue par Jellyfin, Plex ou Kodi.' },
{ key: 'mediaPath', label: 'Médiathèque Fankai', hint: 'Dossier racine lu par Jellyfin, Plex ou Kodi.' },
] as const
function onNfoToggle(val: boolean) {
@@ -231,7 +222,6 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
function isRootPath(p: string): boolean { return !p || p === '/' }
// Réglages modifiés
const dirtyCount = computed(() => {
if (!savedSnapshot.value) return 0
const saved = JSON.parse(savedSnapshot.value)
@@ -259,7 +249,7 @@ async function save() {
body: JSON.stringify(form.value),
})
if (res.ok) { toast('Configuration enregistrée', 'success'); savedSnapshot.value = JSON.stringify(form.value) }
else toast('Erreur lors de la sauvegarde', 'error')
else toast("Impossible d'enregistrer les réglages", 'error')
} finally {
saving.value = false
}
@@ -272,9 +262,9 @@ async function scan() {
if (res.ok) {
const data = await res.json()
lastScan.value = { at: new Date().toISOString(), found: data.found, added: data.added }
toast(data.added > 0 ? `${data.found} fichiers analysés — ${data.added} ajoutés` : `${data.found} fichiers analysés — rien de nouveau`, 'success')
toast(data.added > 0 ? `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · ${plural(data.added, 'ajouté')}` : `${plural(data.found, 'fichier analysé', 'fichiers analysés')} · rien de nouveau`, 'success')
} else {
toast("Erreur lors de l'analyse", 'error')
toast("Impossible d'analyser la médiathèque", 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
+9 -13
View File
@@ -1,8 +1,7 @@
<template>
<div class="flex flex-col gap-4">
<!-- ── Compte ─────────────────────────────────────────────── -->
<SettingsSection title="Compte" description="Le nom d’utilisateur sert à la connexion et apparaît sur vos demandes.">
<SettingsSection title="Compte" description="Le nom d'utilisateur sert à la connexion et apparaît sur vos demandes.">
<div class="flex items-center gap-[18px]">
<span class="w-16 h-16 rounded-full bg-hover text-accent flex items-center justify-center font-display text-[28px] font-bold shrink-0">
{{ auth.username?.charAt(0).toUpperCase() }}
@@ -21,7 +20,6 @@
</div>
</SettingsSection>
<!-- ── Mot de passe ──────────────────────────────────────── -->
<SettingsSection title="Mot de passe" description="Au moins 6 caractères.">
<div class="grid sm:grid-cols-3 gap-4">
<div class="flex flex-col gap-[7px]">
@@ -48,14 +46,12 @@
</SettingsSection>
<div class="grid lg:grid-cols-[1fr_1.1fr] gap-4 items-start">
<!-- ── Apparence ───────────────────────────────────────── -->
<SettingsSection title="Apparence" description="Trois accents sur le même fond sombre. Le réglage est propre à ce navigateur.">
<SettingsSection title="Apparence" description="Couleur d'accent de l'interface. Ce choix ne s'applique qu'à ce navigateur.">
<ThemePicker />
</SettingsSection>
<!-- ── Jeton d'API ─────────────────────────────────────── -->
<div data-tour="profile-token">
<SettingsSection title="Jeton d’API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<SettingsSection title="Jeton d'API" description="Pour les applications tierces qui lisent la médiathèque ou envoient des demandes, comme le plugin Jellyfin.">
<div class="flex items-center gap-2.5 flex-wrap">
<code class="flex-1 min-w-[180px] field font-sans truncate" :title="showToken ? myToken : undefined">
{{ showToken ? myToken : maskedToken }}
@@ -74,8 +70,8 @@
</button>
</div>
<p class="text-xs text-muted">
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l’ancien.
Authentification par l’en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
Régénérer le jeton déconnecte immédiatement les applications qui utilisent l'ancien.
Authentification par l'en-tête <code class="px-1.5 py-0.5 rounded bg-main text-secondary">Authorization: Bearer &lt;jeton&gt;</code>.
</p>
</SettingsSection>
</div>
@@ -101,7 +97,7 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
// ── Mot de passe ──────────────────────────────────────────────
// ── Mot de passe ──
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
@@ -120,7 +116,7 @@ async function changePassword() {
pwdError.value = 'Les mots de passe ne correspondent pas.'; return
}
if (newPassword.value.length < 6) {
pwdError.value = 'Le mot de passe doit faire au moins 6 caractères.'; return
pwdError.value = 'Le mot de passe doit contenir au moins 6 caractères.'; return
}
changingPwd.value = true
@@ -138,12 +134,12 @@ async function changePassword() {
confirmPassword.value = ''
setTimeout(() => { pwdSuccess.value = false }, 3000)
} else {
pwdError.value = data.error ?? 'Erreur lors du changement de mot de passe'
pwdError.value = data.error ?? 'Impossible de changer le mot de passe.'
}
changingPwd.value = false
}
// ── Jeton d'API 2───────────────────────────────────────────────
// ── Jeton d'API ──
const myToken = ref('')
const showToken = ref(false)
const tokenCopied = ref(false)
+12 -11
View File
@@ -1,11 +1,12 @@
<template>
<div class="flex flex-col md:flex-row min-h-full">
<!-- h-auto! annule le h-full du layout, sinon le menu sticky défile avec la page -->
<div class="flex flex-col md:flex-row min-h-full h-auto!">
<nav
v-if="groups.length > 1"
ref="navRef"
data-tour="settings-menu"
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-screen md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
class="md:w-[236px] shrink-0 md:sticky md:top-0 md:self-start md:h-dvh md:overflow-y-auto border-b md:border-b-0 md:border-r border-card
px-4 md:px-3.5 py-4 md:py-[26px] flex md:flex-col gap-1 md:gap-[18px] overflow-x-auto"
>
<h1 class="hidden md:block font-display text-2xl font-bold text-primary ml-2.5">Paramètres</h1>
@@ -56,15 +57,15 @@ watch(() => route.path, async () => {
}, { immediate: true })
const pages: Record<string, { title: string; description: string }> = {
'/settings/profile' : { title: 'Mon profil', description: 'Votre compte, votre mot de passe et l’apparence de l’application.' },
'/settings/users' : { title: 'Utilisateurs et invitations', description: 'Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l’activité ni les paramètres.' },
'/settings/media-management' : { title: 'Gestion des médias', description: 'Où FanKarr prend les fichiers, où il les range, et comment il les importe.' },
'/settings/import-management': { title: 'Gestion des séries', description: 'Les opérations d’entretien sur les fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'D’où viennent les séries, les épisodes et les torrents.' },
'/settings/download-client' : { title: 'Clients de téléchargement', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Le serveur média qui lit la bibliothèque, et l’API ouverte aux applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Ce que le serveur a fait, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic et opérations à ne sortir qu’en cas de besoin.' },
'/settings/profile' : { title: 'Mon profil', description: "Votre compte, votre mot de passe et l'apparence de l'application." },
'/settings/users' : { title: 'Utilisateurs et invitations', description: "Les invités parcourent la médiathèque et demandent des séries. Ils ne voient ni l'activité ni les paramètres." },
'/settings/media-management' : { title: 'Gestion des médias', description: "Dossiers, mode d'import et serveurs multimédias." },
'/settings/import-management': { title: 'Gestion des séries', description: 'Renommage, métadonnées NFO et vérification des fichiers déjà importés.' },
'/settings/catalogue' : { title: 'Catalogue Fankai', description: 'Sources du catalogue et surveillance des nouveaux épisodes.' },
'/settings/download-client' : { title: 'Clients torrent', description: 'Les clients qui reçoivent les torrents envoyés par FanKarr.' },
'/settings/jellyfin' : { title: 'Jellyfin et API', description: 'Connexion Jellyfin, synchronisation des comptes et API pour les applications tierces.' },
'/settings/logs' : { title: 'Journaux', description: 'Événements du serveur, du plus récent au plus ancien.' },
'/settings/advanced' : { title: 'Avancé', description: 'Mises à jour, diagnostic, mode développeur, assistant et visite guidée.' },
}
const page = computed(() => pages[route.path] ?? { title: 'Paramètres', description: '' })
+30 -35
View File
@@ -7,7 +7,6 @@
</button>
</Teleport>
<!-- ── Comptes ────────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Comptes <span class="font-normal text-muted">· {{ users.length }}</span></h3>
@@ -18,7 +17,7 @@
</div>
<div class="hidden md:grid grid-cols-[minmax(0,1fr)_150px_150px_210px_40px] items-center h-[34px] px-5 border-b border-hover tag-label tracking-[0.12em]">
<span>Utilisateur</span><span>Rôle</span><span>Dernier accès</span><span>Jeton API</span><span />
<span>Utilisateur</span><span>Rôle</span><span>Dernière connexion</span><span>Jeton d'API</span><span />
</div>
<p v-if="users.length === 0" class="text-body text-muted px-5 py-5">Aucun utilisateur.</p>
@@ -41,7 +40,7 @@
{{ u.role === 'admin' ? 'Administrateur' : 'Invité' }}
</span>
<span class="text-[13px] text-secondary order-4 md:order-none">
<span class="md:hidden text-muted">Dernier accès : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
<span class="md:hidden text-muted">Dernière connexion : </span>{{ u.lastLoginAt ? formatRelative(u.lastLoginAt) : 'jamais' }}
</span>
<div class="flex items-center gap-2.5 min-w-0 order-5 md:order-none col-span-2 md:col-span-1">
<span class="text-meta text-muted truncate">{{ maskToken(u.apiToken) }}</span>
@@ -72,10 +71,9 @@
</div>
</section>
<!-- ── Invitations ────────────────────────────────────────── -->
<section class="bg-card rounded-card">
<div class="flex items-center justify-between gap-4 flex-wrap px-5 pt-4 pb-3.5 border-b border-hover">
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ activeInvites }} lien{{ activeInvites > 1 ? 's' : '' }} actif{{ activeInvites > 1 ? 's' : '' }}</span></h3>
<h3 class="card-title">Invitations <span class="font-normal text-muted">· {{ plural(activeInvites, 'lien actif', 'liens actifs') }}</span></h3>
<span class="text-meta text-muted">Un lien crée un compte à chaque utilisation, dans la limite fixée.</span>
</div>
@@ -116,10 +114,9 @@
</div>
</section>
<!-- ── Téléchargement auto ────────────── -->
<SettingsSection
title="Téléchargement automatique"
description="Quand une demande est approuvée, lancer automatiquement le téléchargement pour les utilisateurs sélectionnés."
title="Approbation automatique"
description="Les demandes de ces utilisateurs sont approuvées et téléchargées sans attendre votre validation."
>
<template #actions>
<span v-if="autoDownloadSaved" class="text-meta text-ok flex items-center gap-1.5"><Check :size="14" /> Enregistré</span>
@@ -129,7 +126,7 @@
:model-value="autoDownloadAll"
@update:model-value="setAutoDownloadAll"
label="Tous les utilisateurs"
description="Chaque approbation déclenche un téléchargement, quel que soit le demandeur."
description="Toutes les demandes sont approuvées et téléchargées automatiquement."
/>
<template v-if="!autoDownloadAll">
@@ -143,7 +140,7 @@
<button
type="button" role="switch"
:aria-checked="autoDownloadUserIds.includes(u.id)"
:aria-label="`Téléchargement automatique pour ${u.username}`"
:aria-label="`Approbation automatique pour ${u.username}`"
@click="toggleAutoDownloadUser(u.id)"
class="switch"
><span class="switch-knob" /></button>
@@ -152,7 +149,6 @@
</template>
</SettingsSection>
<!-- ── Modal créer/modifier utilisateur ───────────────────── -->
<Teleport to="body">
<div v-if="userModal" class="modal-backdrop" @click.self="userModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="user-modal-title">
@@ -162,13 +158,13 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="user-name" class="field-label">Identifiant</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" placeholder="nom d'utilisateur" autocomplete="off" />
<label for="user-name" class="field-label">Nom d'utilisateur</label>
<input id="user-name" v-model="userModal.username" type="text" class="field" autocomplete="off" />
</div>
<div class="flex flex-col gap-[7px]">
<label for="user-pwd" class="field-label">{{ userModal.mode === 'create' ? 'Mot de passe' : 'Nouveau mot de passe' }}</label>
<input id="user-pwd" v-model="userModal.password" type="password" class="field" autocomplete="new-password"
:placeholder="userModal.mode === 'edit' ? 'Laisser vide pour ne pas changer' : '••••••••'" />
:placeholder="userModal.mode === 'edit' ? 'Laissez vide pour le conserver' : '••••••••'" />
</div>
<div class="flex flex-col gap-[7px]">
<span class="field-label">Rôle</span>
@@ -184,14 +180,13 @@
<div class="flex gap-2.5 justify-end">
<button @click="userModal = null" class="btn-ghost">Annuler</button>
<button @click="submitUserModal" :disabled="userModal.loading" class="btn-primary">
{{ userModal.loading ? '…' : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
{{ userModal.loading ? (userModal.mode === 'create' ? 'Création…' : 'Enregistrement…') : userModal.mode === 'create' ? 'Créer' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Modal créer invitation ─────────────────────────────── -->
<Teleport to="body">
<div v-if="inviteModal" class="modal-backdrop" @click.self="inviteModal = null">
<div class="modal max-w-sm" role="dialog" aria-modal="true" aria-labelledby="invite-modal-title">
@@ -199,12 +194,12 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-[7px]">
<label for="invite-note" class="field-label">Note (facultative)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" placeholder="Pour qui est ce lien ?" />
<label for="invite-note" class="field-label">Message pour la personne invitée (facultatif)</label>
<input id="invite-note" v-model="inviteModal.note" type="text" class="field" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-[7px]">
<label for="invite-uses" class="field-label">Utilisations max</label>
<label for="invite-uses" class="field-label">Nombre d'utilisations</label>
<input id="invite-uses" v-model.number="inviteModal.maxUses" type="number" min="1" class="field" placeholder="∞" />
</div>
<div class="flex flex-col gap-[7px]">
@@ -212,7 +207,7 @@
<input id="invite-hours" v-model.number="inviteModal.expiresInHours" type="number" min="1" class="field" placeholder="∞" />
</div>
</div>
<p class="text-xs text-muted">Laisser un champ vide pour ne pas fixer de limite.</p>
<p class="text-xs text-muted">Laissez un champ vide pour ne pas fixer de limite.</p>
</div>
<p v-if="inviteModal.error" class="text-meta text-err">{{ inviteModal.error }}</p>
@@ -220,7 +215,7 @@
<div class="flex gap-2.5 justify-end">
<button @click="inviteModal = null" class="btn-ghost">Annuler</button>
<button @click="submitInviteModal" :disabled="inviteModal.loading" class="btn-primary">
{{ inviteModal.loading ? '…' : 'Créer le lien' }}
{{ inviteModal.loading ? 'Création…' : 'Créer le lien' }}
</button>
</div>
</div>
@@ -267,13 +262,13 @@ const copied = ref<string | null>(null)
const pendingByUser = ref<Map<string, number>>(new Map())
const userMenu = ref<string | null>(null)
// ── Téléchargement auto des demandes ──────────────────────────
// ── Approbation automatique ──
const autoDownloadAll = ref(false)
const autoDownloadUserIds = ref<string[]>([])
const autoDownloadSaved = ref(false)
let autoDownloadTimer: ReturnType<typeof setTimeout> | null = null
// ── Modals ────────────────────────────────────────────────────
// ── Modales ──
const userModal = ref<{
mode : 'create' | 'edit'
id? : string
@@ -292,7 +287,7 @@ const inviteModal = ref<{
loading : boolean
} | null>(null)
// ── Chargement ────────────────────────────────────────────────
// ── Chargement ──
async function loadUsers() {
const res = await fetch('/api/users', { credentials: 'include' })
if (res.ok) users.value = await res.json()
@@ -361,7 +356,7 @@ onMounted(() => {
})
onUnmounted(() => document.removeEventListener('click', closeUserMenu))
// ── Users ─────────────────────────────────────────────────────
// ── Utilisateurs ──
function openCreateUser() {
userModal.value = { mode: 'create', username: '', password: '', role: 'user', error: null, loading: false }
}
@@ -376,8 +371,8 @@ async function submitUserModal() {
m.error = null
m.loading = true
if (!m.username) { m.error = 'Identifiant requis'; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis'; m.loading = false; return }
if (!m.username) { m.error = "Nom d'utilisateur requis."; m.loading = false; return }
if (m.mode === 'create' && !m.password) { m.error = 'Mot de passe requis.'; m.loading = false; return }
const url = m.mode === 'create' ? '/api/users' : `/api/users/${m.id}`
const method = m.mode === 'create' ? 'POST' : 'PATCH'
@@ -385,15 +380,15 @@ async function submitUserModal() {
if (m.password) body.password = m.password
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible d\'enregistrer le compte.'; m.loading = false; return }
userModal.value = null
loadUsers()
}
async function confirmDelete(u: User) {
if (!confirm(`Supprimer l'utilisateur "${u.username}" ?`)) return
if (!confirm(`Supprimer le compte « ${u.username} » ?`)) return
await fetch(`/api/users/${u.id}`, { method: 'DELETE', credentials: 'include' })
loadUsers()
}
@@ -408,7 +403,7 @@ function userSubtitle(u: User) {
}
function maskToken(token: string) {
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : '—'
return token ? `${token.slice(0, 4)}••••••••••${token.slice(-4)}` : 'Aucun jeton'
}
async function copyToken(u: User) {
@@ -419,7 +414,7 @@ async function copyToken(u: User) {
} catch {}
}
// ── Invites ───────────────────────────────────────────────────
// ── Invitations ──
const activeInvites = computed(() => invites.value.filter(inv => !isInvalid(inv)).length)
function openCreateInvite() {
@@ -436,9 +431,9 @@ async function submitInviteModal() {
if (m.expiresInHours) body.expiresInHours = m.expiresInHours
const res = await fetch('/api/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(body) })
const data = await res.json()
const data = await res.json().catch(() => ({}))
if (!res.ok) { m.error = data.error; m.loading = false; return }
if (!res.ok) { m.error = data.error ?? 'Impossible de créer l\'invitation.'; m.loading = false; return }
inviteModal.value = null
loadInvites()
}
@@ -461,7 +456,7 @@ async function copyInvite(code: string) {
} catch {}
}
// ── Helpers ───────────────────────────────────────────────────
// ── Utilitaires ──
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}