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
@@ -3,7 +3,7 @@
|
||||
<div class="w-24 flex-shrink-0">
|
||||
<img
|
||||
v-if="ep.cover"
|
||||
:src="imageUrl(ep.cover)"
|
||||
:src="imageUrl(ep.cover) ?? undefined"
|
||||
:alt="ep.name"
|
||||
class="w-full aspect-video object-cover"
|
||||
/>
|
||||
|
||||
@@ -20,8 +20,10 @@
|
||||
|
||||
<!-- Dossier vide / introuvable -->
|
||||
<div v-else-if="!scanning && items.length === 0 && !scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-muted">Aucun fichier vidéo trouvé dans ce dossier.</p>
|
||||
<p class="text-xs text-muted">Placez vos fichiers dans <span class="font-mono text-primary">{{ serieFolder }}</span> puis réessayez.</p>
|
||||
<p class="text-sm text-muted">{{ folderMissing ? 'Ce dossier n\'existe pas encore.' : 'Aucun fichier vidéo trouvé dans ce dossier.' }}</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ folderMissing ? 'Créez' : 'Placez vos fichiers dans' }} <span class="font-mono text-primary">{{ serieFolder }}</span>{{ folderMissing ? ', placez-y vos fichiers' : '' }} puis réessayez.
|
||||
</p>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +79,7 @@
|
||||
|
||||
<!-- Sélecteur épisode -->
|
||||
<select
|
||||
v-model="items[i].episode_id"
|
||||
v-model="item.episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
@change="onEpisodeChange(i)"
|
||||
>
|
||||
@@ -154,6 +156,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { matchEpisodeFile } from '@/utils/episode-match'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
@@ -176,6 +179,7 @@ const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const scanError = ref('')
|
||||
const folderMissing = ref(false)
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
|
||||
// Dossier de la série dans la médiathèque
|
||||
@@ -230,33 +234,8 @@ function buildOrganizedByPath(): Map<string, number> {
|
||||
}
|
||||
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of props.seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
|
||||
return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback numéro d'épisode
|
||||
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
if (numMatch) {
|
||||
const num = parseInt(numMatch[1], 10)
|
||||
for (const season of props.seasons) {
|
||||
const ep = season.episodes.find((e: any) => e.episode_number === num)
|
||||
if (ep) return { episode_id: ep.id, hash: getHashForEpisode(ep.id) }
|
||||
}
|
||||
}
|
||||
|
||||
return { episode_id: null, hash: null }
|
||||
const episodeId = matchEpisodeFile(filename, props.seasons)
|
||||
return { episode_id: episodeId, hash: episodeId === null ? null : getHashForEpisode(episodeId) }
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -268,6 +247,7 @@ function formatSize(bytes: number): string {
|
||||
|
||||
function onEpisodeChange(i: number) {
|
||||
const item = items.value[i]
|
||||
if (!item) return
|
||||
const newEpId = item.episode_id // v-model a déjà mis à jour la valeur
|
||||
|
||||
item.hash = getHashForEpisode(Number(newEpId))
|
||||
@@ -297,6 +277,7 @@ async function scan() {
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
folderMissing.value = data.exists === false
|
||||
const files: { name: string; path: string; size: number }[] = data.files ?? []
|
||||
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
@@ -32,7 +32,7 @@ const themes = [
|
||||
|
||||
const current = ref('indigo')
|
||||
|
||||
function setTheme(id) {
|
||||
function setTheme(id: string) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
</button>
|
||||
<div v-if="seasonMenuOpen" class="absolute right-0 top-full mt-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in season.torrents"
|
||||
v-for="(t, i) in (season.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
@@ -231,7 +231,7 @@
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-72 shadow-xl flex flex-col gap-0.5">
|
||||
<p class="px-3 pt-1.5 pb-0.5 text-[10px] text-muted font-medium uppercase tracking-wide">Choisir le torrent</p>
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-start gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors text-left"
|
||||
@@ -246,7 +246,7 @@
|
||||
<div v-if="!requestMode && epOptionsOpen === ep.id && ep.torrents && ep.torrents.length > 1"
|
||||
class="absolute right-0 bottom-full mb-1 bg-card border border-border rounded-xl p-1 z-20 w-52 shadow-xl flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(t, i) in ep.torrents"
|
||||
v-for="(t, i) in (ep.torrents as any[])"
|
||||
:key="i"
|
||||
:title="t.raw ?? t.torrent_name ?? ''"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-primary hover:bg-hover transition-colors"
|
||||
@@ -433,16 +433,6 @@ function isAlreadyQueued(torrent: any): boolean {
|
||||
|
||||
// ── Computed saison ────────────────────────────────────────────
|
||||
const availableCount = computed(() => props.season.episodes.filter((e: any) => e.available).length)
|
||||
const seasonProgress = computed(() => {
|
||||
if (props.season.torrents && props.season.torrents.length > 1) {
|
||||
for (const t of props.season.torrents) {
|
||||
const prog = torrentProgress(extractHash(t))
|
||||
if (prog) return prog
|
||||
}
|
||||
return null
|
||||
}
|
||||
return torrentProgress(extractHash(props.season.torrent))
|
||||
})
|
||||
const hasDownloadable = computed(() =>
|
||||
props.season.episodes.some((ep: any) =>
|
||||
ep.torrent && ep.available && !ep.organized && !isAlreadyQueued(ep.torrent) && !isDownloaded(`ep-${ep.id}`)
|
||||
@@ -540,7 +530,7 @@ function handleEpRequestClick(ep: any) {
|
||||
return
|
||||
}
|
||||
// Torrent unique ou aucun → émettre directement
|
||||
emit('requestEpisode', season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
emit('requestEpisode', props.season.season_number, ep.id, ep.torrent ?? undefined)
|
||||
}
|
||||
|
||||
function epState(ep: any): 'idle' | 'loading' | 'done' | 'unavailable' {
|
||||
@@ -606,7 +596,7 @@ const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parse
|
||||
*/
|
||||
function _parseEpChap(plot: string | null | undefined): {
|
||||
episodes: string | null
|
||||
season: number | null
|
||||
season: string | null
|
||||
chapters: string |null
|
||||
cleanPlot: string
|
||||
} {
|
||||
|
||||
@@ -11,11 +11,8 @@ interface Toast {
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
const DURATION = 3500
|
||||
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
|
||||
|
||||
function add(message: string, type: Toast['type'] = 'info') {
|
||||
const now = Date.now()
|
||||
|
||||
// Cherche un toast récent du même type à grouper
|
||||
const existing = toasts.value.find(t => t.type === type)
|
||||
|
||||
@@ -44,7 +41,7 @@ function buildGroupMessage(type: Toast['type'], count: number): string {
|
||||
function remove(id: number) {
|
||||
const i = toasts.value.findIndex(t => t.id === id)
|
||||
if (i !== -1) {
|
||||
clearTimeout(toasts.value[i].timer)
|
||||
clearTimeout(toasts.value[i]?.timer)
|
||||
toasts.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ import { Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
import type { NavItem, NavChild } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const dlStore = useDownloadsStore()
|
||||
@@ -79,7 +79,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
|
||||
items.push({ separator: true })
|
||||
|
||||
const settingsChildren: NavItem[] = [
|
||||
const settingsChildren: NavChild[] = [
|
||||
{ label: 'Mon profil', to: '/settings/profile' },
|
||||
]
|
||||
if (auth.isAdmin) {
|
||||
|
||||
+15
-3
@@ -2,10 +2,16 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface TorrentRef {
|
||||
torrent_raw: string
|
||||
torrent_url: string | null
|
||||
magnet: string | null
|
||||
type: string
|
||||
raw?: string | null
|
||||
infohash?: string | null
|
||||
torrent_name?: string | null
|
||||
file_index?: number | null
|
||||
file_path?: string | null
|
||||
fankai?: boolean
|
||||
manual?: boolean
|
||||
}
|
||||
|
||||
export interface Episode {
|
||||
@@ -20,7 +26,10 @@ export interface Episode {
|
||||
has_thumbnail: boolean
|
||||
season_id: number
|
||||
available: boolean
|
||||
torrent: TorrentRef | null // non-null seulement si type=episode
|
||||
organized: boolean
|
||||
fankai: boolean | null
|
||||
torrent: TorrentRef | null
|
||||
torrents: TorrentRef[]
|
||||
}
|
||||
|
||||
export interface Season {
|
||||
@@ -33,6 +42,9 @@ export interface Season {
|
||||
premiered: string | null
|
||||
episodes: Episode[]
|
||||
torrent: TorrentRef | null
|
||||
torrents: TorrentRef[]
|
||||
organized_state: 'none' | 'partial' | 'complete'
|
||||
organized_count: number
|
||||
}
|
||||
|
||||
export interface Serie {
|
||||
@@ -56,7 +68,7 @@ export interface SerieDetail {
|
||||
serie: Serie
|
||||
seasons: Season[]
|
||||
scraper_synced: boolean
|
||||
torrents_integrale: { raw: string; torrent_url: string | null; magnet: string | null }[]
|
||||
torrents_integrale: { label: string; raw: string; torrent_url: string | null; magnet: string | null; infohash: string | null; torrent_name: string | null }[]
|
||||
}
|
||||
|
||||
export const useSeriesStore = defineStore('series', () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface MatchEpisode {
|
||||
id : number
|
||||
episode_number : number
|
||||
original_filename?: string | null
|
||||
nfo_filename? : string | null
|
||||
formatted_name? : string | null
|
||||
}
|
||||
|
||||
export interface MatchSeason {
|
||||
season_number: number
|
||||
episodes : MatchEpisode[]
|
||||
}
|
||||
|
||||
// Associe un fichier à un épisode : nom exact, puis SxxEyy, puis numéro seul
|
||||
export function matchEpisodeFile(filename: string, seasons: MatchSeason[]): number | null {
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of seasons) {
|
||||
for (const ep of season.episodes) {
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter((s): s is string => !!s).map(s => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) return ep.id
|
||||
}
|
||||
}
|
||||
|
||||
const se = filename.match(/S(\d{1,2})[\s._-]?E(\d{1,3})/i)
|
||||
if (se) {
|
||||
const ep = seasons.find(s => s.season_number === Number(se[1]))?.episodes.find(e => e.episode_number === Number(se[2]))
|
||||
if (ep) return ep.id
|
||||
}
|
||||
|
||||
const numMatch = filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
if (numMatch) {
|
||||
const num = Number(numMatch[1])
|
||||
for (const season of seasons) {
|
||||
const ep = season.episodes.find(e => e.episode_number === num)
|
||||
if (ep) return ep.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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