feat(import): catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage

This commit is contained in:
ElPouki committed 2026-09-11 22:42:38 +02:00
1 parent 67794d92c8
commit 0ff7f21958
28 files changed
+806 -152

No files matched your search

+2 -2
View File
@@ -118,7 +118,7 @@
<div v-for="r in req.requesters" :key="r.userId"
class="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-shell border border-border text-xs">
<span class="w-4 h-4 rounded-full bg-border flex items-center justify-center text-[9px] font-semibold text-muted shrink-0">
{{ r.username[0].toUpperCase() }}
{{ r.username.charAt(0).toUpperCase() }}
</span>
<span class="text-muted">{{ r.username }}</span>
<span v-if="r.episodes?.length > 0" class="text-muted/60">
@@ -179,7 +179,7 @@ const filters = [
{ value: 'approved', label: 'Approuvées' },
{ value: 'completed', label: 'Disponibles' },
{ value: 'rejected', label: 'Refusées' },
]
] as const
const counts = computed(() => ({
all : requests.value.length,
+47 -5
View File
@@ -114,6 +114,23 @@
</div>
</div>
<!-- Dossier série à renommer -->
<div v-if="auth.isAdmin && staleFolders.length > 0" class="mx-4 md:mx-8 mt-4 flex items-center justify-between gap-3 flex-wrap px-4 py-3 rounded-lg border border-yellow-500/40 bg-yellow-500/5">
<div class="flex items-start gap-3 min-w-0">
<span class="text-yellow-500 text-sm mt-0.5">⚠</span>
<div class="flex flex-col gap-0.5 min-w-0">
<p class="text-sm text-yellow-500 font-medium">Dossier différent du titre de la série</p>
<p class="text-xs text-muted break-all">
<span class="font-mono text-primary">{{ staleFolders.map(f => basename(f.path)).join(', ') }}</span>
→ <span class="font-mono text-primary">{{ folderTarget }}</span>
</p>
</div>
</div>
<button @click="renameFolder" :disabled="renamingFolder" class="btn-secondary text-xs shrink-0">
{{ renamingFolder ? 'Renommage…' : 'Renommer le dossier' }}
</button>
</div>
<!-- Barre utilisateur (mode demande) -->
<div v-if="!auth.isAdmin" class="px-4 md:px-8 py-3 flex items-center justify-between gap-3 border-b border-border flex-wrap">
<div v-if="myRequest" class="flex items-center gap-3">
@@ -388,6 +405,12 @@ const downloadMenuOpen = ref(false)
const downloadingAll = ref(false)
const downloadingSeason = ref<Record<number, boolean>>({})
const rssSync = ref(false)
const folderStatus = ref<{ expected: string; current: { path: string; entries: number; exists: boolean }[] } | null>(null)
const renamingFolder = ref(false)
const basename = (p: string) => p.split(/[\\/]/).pop() ?? p
const staleFolders = computed(() => folderStatus.value?.current.filter(f => f.exists && f.path !== folderStatus.value?.expected) ?? [])
const folderTarget = computed(() => folderStatus.value ? basename(folderStatus.value.expected) : '')
export interface ActiveTorrent { hash: string; progress: number; state: string; files?: { index: number; progress: number }[]; save_path?: string; name?: string }
const activeTorrents = ref<ActiveTorrent[]>([])
@@ -484,6 +507,27 @@ async function fetchOrganized() {
const res = await fetch(`/api/organized/${route.params.id}`, { credentials: 'include' })
if (res.ok) organizedByEpisode.value = await res.json()
} catch {}
fetchFolderStatus()
}
async function fetchFolderStatus() {
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { credentials: 'include' })
folderStatus.value = res.ok ? await res.json() : null
} catch {}
}
async function renameFolder() {
if (!confirm(`Déplacer le contenu vers « ${folderTarget.value} » ? Les fichiers existants ne sont jamais écrasés.`)) return
renamingFolder.value = true
try {
const res = await fetch(`/api/organized/${route.params.id}/folder`, { method: 'POST', credentials: 'include' })
const d = await res.json()
if (!res.ok) { toast(d.error ?? 'Erreur lors du renommage du dossier', 'error'); return }
toast(`Dossier renommé ✓ (${d.moved} fichier${d.moved > 1 ? 's' : ''} déplacé${d.moved > 1 ? 's' : ''})`, 'success')
await fetchOrganized()
} catch { toast('Impossible de contacter le serveur', 'error') }
finally { renamingFolder.value = false }
}
async function fetchSettings() {
@@ -647,10 +691,8 @@ function collectDownloadables(integraleIndex?: number): DlItem[] {
if (!hasUncovered) continue
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
const alreadyHandled = season.torrents.some((t: any, i: number) => isAlreadyQueued(t) || isDownloaded(packKey(i)))
if (!alreadyHandled) {
const t = season.torrents[0]
result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
}
const t = season.torrents[0]
if (!alreadyHandled && t) result.push({ key: packKey(0), torrent_url: t.torrent_url, magnet: t.magnet })
for (const ep of season.episodes) covered.add(ep.id)
}
@@ -683,7 +725,7 @@ async function downloadAll(integraleIndex?: number) {
async function downloadSeason(season: Season, packHash?: string) {
downloadingSeason.value[season.id] = true
const torrents: { key: string; torrent_url: string | null; magnet: string | null; file_index?: number | null; file_path?: string | null }[] = []
const torrents: DlItem[] = []
const seasonAny = season as any
if (seasonAny.torrents && seasonAny.torrents.length > 0 && seasonAny.organized_state !== 'complete') {
const packKey = (i: number) => (i === 0 ? `season-${season.id}` : `season-${season.id}-${i}`)
+44
View File
@@ -48,6 +48,29 @@
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
</div>
<!-- Surveillances orphelines -->
<div v-if="syncOrphans.length > 0" class="settings-card flex flex-col gap-2">
<div>
<p class="text-sm font-medium text-primary">Surveillances orphelines ({{ syncOrphans.length }})</p>
<p class="text-xs text-muted mt-0.5">
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.
</p>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="s in syncOrphans" :key="s.serieId" class="flex items-center gap-3 py-2">
<span class="flex-1 min-w-0 text-sm text-primary truncate">{{ s.serieName }}</span>
<span class="text-xs text-muted font-mono shrink-0">#{{ s.serieId }}</span>
<button
@click="removeSyncOrphan(s.serieId)"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
</div>
</div>
</div>
</template>
</div>
@@ -62,13 +85,33 @@ const { add: toast } = useToast()
const updating = ref(false)
const loaded = ref(false)
const status = ref({ exists: false, count: 0, empty: true })
const syncOrphans = ref<{ serieId: number; serieName: string }[]>([])
onMounted(async () => {
fetchSyncOrphans()
const res = await fetch('/api/torrents/status', { credentials: 'include' })
if (res.ok) status.value = await res.json()
loaded.value = true
})
async function fetchSyncOrphans() {
try {
const res = await fetch('/api/rss-sync/orphans', { credentials: 'include' })
if (res.ok) syncOrphans.value = (await res.json()).orphans ?? []
} catch {}
}
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 }
syncOrphans.value = syncOrphans.value.filter(s => s.serieId !== serieId)
toast('Surveillance retirée ✓', 'success')
} catch {
toast('Impossible de contacter le serveur', 'error')
}
}
async function update() {
updating.value = true
try {
@@ -77,6 +120,7 @@ async function update() {
const { count } = await res.json()
status.value = { exists: true, count, empty: count === 0 }
toast(`${count} séries chargées ✓`, 'success')
fetchSyncOrphans()
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
+1 -1
View File
@@ -407,7 +407,7 @@ watch(() => modal.value.config, (newConfig) => {
// Si au moins un champ non-password a changé → reset tested
// Pour les passwords, on reset seulement si la valeur n'est plus le masque
for (const [key, val] of Object.entries(newConfig)) {
const field = driver.fields.find(f => f.key === key)
const field = driver.fields.find((f: any) => f.key === key)
if (!field) continue
if (field.type === 'password' && val === '••••••••') continue
modal.value.tested = false
@@ -172,6 +172,35 @@
</div>
</div>
<!-- Entrées orphelines -->
<div v-if="!loading && orphans.length > 0" class="settings-card flex flex-col gap-2">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-primary">Entrées orphelines ({{ orphans.length }})</p>
<p class="text-xs text-muted mt-0.5">Épisodes suivis qui n'existent plus dans le catalogue. Les retirer du suivi ne supprime aucun fichier.</p>
</div>
<button @click="removeOrphans()" :disabled="removingOrphans" class="btn-secondary text-xs shrink-0">
{{ removingOrphans ? '…' : 'Tout retirer' }}
</button>
</div>
<div class="flex flex-col divide-y divide-border/50 -mx-4 px-4">
<div v-for="o in orphans" :key="`${o.hash}:${o.episode_id}`" class="flex items-center gap-3 py-2">
<span class="text-xs text-muted font-mono shrink-0 w-14">
{{ 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-[11px] font-mono text-muted truncate" :title="o.dest_path ?? ''">{{ o.dest_path ?? '—' }}</span>
<span v-if="!o.file_exists" class="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20 shrink-0">Introuvable</span>
<button
@click="removeOrphans([o.episode_id])"
:disabled="removingOrphans"
class="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:text-primary transition-colors shrink-0"
>
Retirer
</button>
</div>
</div>
</div>
</div>
</template>
@@ -192,6 +221,10 @@ const collapsed = ref<Set<number>>(new Set())
const renamingEp = ref<Record<number, boolean>>({})
const renameResult = ref<{ done: number; errors: any[] } | null>(null)
const showOnlyNeedsRename = ref(false)
const orphans = ref<{ hash: string; episode_id: number; season: number | null; episode: number | null; dest_path: string | null; file_exists: boolean }[]>([])
const removingOrphans = ref(false)
const pad = (n: number) => String(n).padStart(2, '0')
// ─── Computed ──────────────────────────────────────────────────
const totalNeedsRename = computed(() => series.value.reduce((acc, s) => acc + s.needs_rename, 0))
@@ -209,6 +242,7 @@ async function load() {
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 {
@@ -340,5 +374,26 @@ 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
removingOrphans.value = true
try {
const res = await fetch('/api/organized-summary/orphans', {
method : 'DELETE',
headers : { 'Content-Type': 'application/json' },
credentials: 'include',
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')
await load()
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
removingOrphans.value = false
}
}
onMounted(load)
</script>
-10
View File
@@ -103,7 +103,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { copyToClipboard } from '@/composables/useClipboard'
const jellyfinUrl = ref('')
const jellyfinAdminToken = ref('')
@@ -116,8 +115,6 @@ const syncing = ref(false)
const syncResult = ref<{ created: number; skipped: number; users: string[] } | null>(null)
const syncError = ref<string | null>(null)
const origin = window.location.origin
const apiEndpoints = [
{ method: 'POST', path: '/api/v1/auth/jellyfin', desc: 'Échanger un token Jellyfin contre un token FanKarr' },
{ method: 'GET', path: '/api/v1/auth/me', desc: 'Infos de l\'utilisateur authentifié' },
@@ -179,13 +176,6 @@ async function syncUsers() {
syncing.value = false
}
async function copyToken() {
try {
await copyToClipboard(myToken.value)
tokenCopied.value = true
setTimeout(() => { tokenCopied.value = false }, 2000)
} catch {}
}
</script>
+2 -2
View File
@@ -25,7 +25,7 @@
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>
{{ u.username[0].toUpperCase() }}
{{ u.username.charAt(0).toUpperCase() }}
</span>
<div>
<p class="text-sm text-primary font-medium">{{ u.username }}</p>
@@ -137,7 +137,7 @@
<span
class="w-6 h-6 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
:class="u.role === 'admin' ? 'bg-accent/20 text-accent' : 'bg-border text-muted'"
>{{ u.username[0].toUpperCase() }}</span>
>{{ u.username.charAt(0).toUpperCase() }}</span>
<span class="text-sm text-primary">{{ u.username }}</span>
<span class="text-xs text-muted">{{ u.role === 'admin' ? 'Admin' : 'Utilisateur' }}</span>
</div>