feat(import): catalogue unifié, renommage de dossier série et nettoyage des orphelins corection dédoublonnage
This commit is contained in:
1 parent
67794d92c8
commit
0ff7f21958
28 files changed
+806
-152
No files matched your search
@@ -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
@@ -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}`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user