fix: l'import a le meme comportement que radarr/sonarr en ouvrant l'import dans le dossier de la serie attendu directement
This commit is contained in:
1 parent
f7c21f5fd1
commit
73b401f3e7
2 files changed
+95
-110
No files matched your search
@@ -541,6 +541,11 @@ app.get('/api/browse-files', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
const VIDEO_EXTS = new Set(['.mkv', '.mp4', '.avi', '.m4v', '.mov', '.wmv'])
|
||||
try {
|
||||
// Créer le dossier s'il n'existe pas (cas import manuel série non encore importée)
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
logger.info('api', `Dossier créé : ${dirPath}`)
|
||||
}
|
||||
const files: { name: string; path: string; size: number }[] = []
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
|
||||
@@ -7,47 +7,45 @@
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary">Import manuel — {{ serieName }}</h3>
|
||||
<p class="text-xs text-muted mt-0.5">
|
||||
{{ step === 'folder' ? 'Sélectionnez le dossier contenant vos fichiers vidéo' :
|
||||
step === 'match' ? 'Associez vos fichiers aux épisodes du catalogue' :
|
||||
'Import terminé' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted font-mono mt-0.5 truncate">{{ serieFolder }}</p>
|
||||
</div>
|
||||
<button @click="$emit('close')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Étape 1 : Choisir un dossier -->
|
||||
<div v-if="step === 'folder'" class="flex flex-col gap-4 px-6 py-5">
|
||||
<button
|
||||
@click="pickerOpen = true"
|
||||
class="settings-input text-left font-mono truncate"
|
||||
:class="selectedFolder ? 'text-primary' : 'text-muted'"
|
||||
>
|
||||
{{ selectedFolder || 'Choisir un dossier…' }}
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="scanFolder" :disabled="!selectedFolder || scanning" class="btn-primary">
|
||||
{{ scanning ? 'Scan…' : 'Scanner ce dossier' }}
|
||||
</button>
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
</div>
|
||||
<!-- Loading -->
|
||||
<div v-if="scanning" class="flex flex-col items-center justify-center gap-3 py-16 text-muted">
|
||||
<div class="w-5 h-5 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Scan du dossier…</p>
|
||||
</div>
|
||||
|
||||
<!-- Étape 2 : Matcher les fichiers -->
|
||||
<template v-else-if="step === 'match'">
|
||||
<!-- 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>
|
||||
<button @click="scan" class="btn-secondary mt-2">Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Erreur -->
|
||||
<div v-else-if="scanError" class="flex flex-col items-center gap-3 py-16 text-center px-6">
|
||||
<p class="text-sm text-red-400">{{ scanError }}</p>
|
||||
<button @click="scan" class="btn-secondary">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<template v-else-if="items.length > 0">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-border shrink-0 gap-3">
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary font-medium">{{ items.length }}</span> fichier{{ items.length > 1 ? 's' : '' }}
|
||||
· <span class="text-green-400">{{ matchedCount }} associé{{ matchedCount > 1 ? 's' : '' }}</span>
|
||||
<span v-if="alreadyImportedCount > 0"> · <span class="text-blue-400">{{ alreadyImportedCount }} déjà importé{{ alreadyImportedCount > 1 ? 's' : '' }}</span></span>
|
||||
· <span class="text-green-400">{{ newMatchedCount }} nouveau{{ newMatchedCount > 1 ? 'x' : '' }}</span>
|
||||
<span v-if="unmatchedCount > 0"> · <span class="text-yellow-500">{{ unmatchedCount }} non associé{{ unmatchedCount > 1 ? 's' : '' }}</span></span>
|
||||
</p>
|
||||
<button @click="step = 'folder'" class="text-xs text-muted hover:text-primary transition-colors">← Changer de dossier</button>
|
||||
<button @click="scan" class="text-xs text-muted hover:text-primary transition-colors">↺ Rescanner</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste fichiers -->
|
||||
<!-- Fichiers -->
|
||||
<div class="overflow-y-auto flex-1 px-6 py-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
@@ -59,21 +57,21 @@
|
||||
? 'border-green-500/20 bg-green-500/5'
|
||||
: 'border-border bg-shell'"
|
||||
>
|
||||
<!-- Icône état -->
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 text-[10px]"
|
||||
<!-- Icône -->
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0"
|
||||
:class="item.alreadyImported ? 'bg-blue-500/15 text-blue-400' :
|
||||
item.episode_id ? 'bg-green-500/15 text-green-400' : 'bg-border text-muted'">
|
||||
<svg v-if="item.alreadyImported" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/><polyline points="6 2 10 6 6 10" opacity="0.4"/></svg>
|
||||
<svg v-else-if="item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-if="item.alreadyImported || item.episode_id" width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="currentColor" stroke-width="2" fill="none"><line x1="6" y1="2" x2="6" y2="7"/><circle cx="6" cy="9.5" r="0.5" fill="currentColor"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Nom fichier -->
|
||||
<!-- Nom + taille -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary font-mono truncate">{{ item.file.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
{{ formatSize(item.file.size) }}
|
||||
<span v-if="item.alreadyImported" class="text-blue-400 ml-1">— déjà importé</span>
|
||||
<span v-else-if="item.willRename" class="text-accent ml-1">— sera renommé</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +79,7 @@
|
||||
<select
|
||||
v-model="items[i].episode_id"
|
||||
class="settings-input text-xs py-1 max-w-[260px] shrink-0"
|
||||
@change="items[i].hash = getHashForEpisode(Number(items[i].episode_id)); items[i].alreadyImported = false"
|
||||
@change="onEpisodeChange(i)"
|
||||
>
|
||||
<option :value="null">— Non associé —</option>
|
||||
<optgroup v-for="season in seasons" :key="season.id" :label="season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}`">
|
||||
@@ -104,24 +102,23 @@
|
||||
<div class="flex items-center justify-between px-6 py-4 border-t border-border shrink-0">
|
||||
<p v-if="importError" class="text-xs text-red-400">{{ importError }}</p>
|
||||
<p v-else class="text-xs text-muted">
|
||||
Seuls les fichiers associés à un épisode seront importés.
|
||||
<span v-if="isInSeriePath" class="text-accent ml-1">Les fichiers déjà dans la médiathèque seront renommés sur place.</span>
|
||||
Les fichiers dans ce dossier seront <span class="text-accent">renommés sur place</span>.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="$emit('close')" class="btn-secondary">Annuler</button>
|
||||
<button
|
||||
@click="doImport"
|
||||
:disabled="matchedCount === 0 || importing"
|
||||
:disabled="newMatchedCount === 0 || importing"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ importing ? 'Import…' : `Importer ${matchedCount} fichier${matchedCount > 1 ? 's' : ''}` }}
|
||||
{{ importing ? 'Import…' : `Importer ${newMatchedCount} fichier${newMatchedCount > 1 ? 's' : ''}` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Étape 3 : Résultat -->
|
||||
<!-- Résultat -->
|
||||
<div v-else-if="step === 'done'" class="flex flex-col items-center gap-4 px-6 py-10 text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-green-500/10 flex items-center justify-center">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
@@ -146,30 +143,21 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="pickerOpen"
|
||||
:initial-path="selectedFolder || props.initialPath || '/'"
|
||||
@select="onFolderSelect"
|
||||
@cancel="pickerOpen = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
serieId : number
|
||||
serieName : string
|
||||
seasons : any[]
|
||||
organized : Record<string, any>
|
||||
initialPath?: string
|
||||
serieId : number
|
||||
serieName : string
|
||||
seasons : any[]
|
||||
organized : Record<string, any>
|
||||
initialPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -178,34 +166,33 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────
|
||||
const step = ref<'folder' | 'match' | 'done'>('folder')
|
||||
const pickerOpen = ref(false)
|
||||
const selectedFolder = ref(props.initialPath || '')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
const step = ref<'list' | 'done'>('list')
|
||||
const scanning = ref(false)
|
||||
const importing = ref(false)
|
||||
const importError = ref('')
|
||||
const scanError = ref('')
|
||||
const importResult = ref({ done: 0, errors: [] as { file: string; error: string }[] })
|
||||
|
||||
// Dossier de la série dans la médiathèque
|
||||
const serieTitle = computed(() =>
|
||||
props.serieName.replace(/:/g, ' -').replace(/[<>"/\\|?*]/g, '').replace(/\s+/g, ' ').trim()
|
||||
)
|
||||
const serieFolder = computed(() => `${props.initialPath}/${serieTitle.value}`)
|
||||
|
||||
interface FileItem {
|
||||
file : { name: string; path: string; size: number }
|
||||
episode_id : number | null
|
||||
hash : string | null
|
||||
alreadyImported: boolean // fichier déjà dans organized.json
|
||||
alreadyImported: boolean
|
||||
willRename : boolean
|
||||
}
|
||||
|
||||
const items = ref<FileItem[]>([])
|
||||
|
||||
// ─── Computed ──────────────────────────────────────────────────
|
||||
const matchedCount = computed(() => items.value.filter(i => i.episode_id !== null).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
const alreadyImportedCount = computed(() => items.value.filter(i => i.alreadyImported).length)
|
||||
|
||||
// Vérifier si le dossier sélectionné est dans mediaPath/[série]/
|
||||
const isInSeriePath = computed(() => {
|
||||
if (!selectedFolder.value || !props.initialPath) return false
|
||||
const serieFolder = `${props.initialPath}/${props.serieName}`.replace(/\/+/g, '/')
|
||||
return selectedFolder.value.startsWith(serieFolder)
|
||||
})
|
||||
const newMatchedCount = computed(() => items.value.filter(i => i.episode_id !== null && !i.alreadyImported).length)
|
||||
const unmatchedCount = computed(() => items.value.filter(i => i.episode_id === null).length)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
function isEpisodeOrganized(epId: number): boolean {
|
||||
@@ -228,19 +215,15 @@ function getHashForEpisode(episodeId: number): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// Construire un index inversé : dest_path/dest_filename → episode_id depuis organized
|
||||
function buildOrganizedIndex(): Map<string, number> {
|
||||
function buildOrganizedByPath(): Map<string, number> {
|
||||
const index = new Map<string, number>()
|
||||
for (const [epIdStr, entry] of Object.entries(props.organized)) {
|
||||
if (entry?.dest_path) index.set(entry.dest_path, Number(epIdStr))
|
||||
if (entry?.dest_filename) index.set(entry.dest_filename, Number(epIdStr))
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Auto-match : essaie de matcher par original_filename, nfo_filename, formatted_name
|
||||
function autoMatch(filename: string): { episode_id: number | null; hash: string | null } {
|
||||
const lower = filename.toLowerCase()
|
||||
const nameNoExt = filename.replace(/\.[^.]+$/, '').toLowerCase()
|
||||
|
||||
for (const season of props.seasons) {
|
||||
@@ -248,8 +231,7 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
|
||||
const candidates = [
|
||||
ep.original_filename,
|
||||
ep.nfo_filename?.replace(/\.nfo$/, ''),
|
||||
ep.nfo_filename?.replace(/\.nfo$/, '.mkv'),
|
||||
ep.formatted_name ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() : null,
|
||||
ep.formatted_name?.replace(/[<>:"/\\|?*]/g, '').trim(),
|
||||
].filter(Boolean).map((s: string) => s.toLowerCase().replace(/\.[^.]+$/, ''))
|
||||
|
||||
if (candidates.some(c => c === nameNoExt || nameNoExt.startsWith(c + '.'))) {
|
||||
@@ -258,9 +240,8 @@ function autoMatch(filename: string): { episode_id: number | null; hash: string
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback : numéro d'épisode
|
||||
const numMatch = filename.match(/(?:e|ep|episode|épisode)[.\s_-]?0*(\d+)/i)
|
||||
?? filename.match(/[.\s_-]0*(\d{1,3})[.\s_-]/i)
|
||||
// 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) {
|
||||
@@ -279,44 +260,41 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
function onFolderSelect(p: string) {
|
||||
selectedFolder.value = p
|
||||
pickerOpen.value = false
|
||||
function onEpisodeChange(i: number) {
|
||||
items.value[i].hash = getHashForEpisode(Number(items.value[i].episode_id))
|
||||
items.value[i].alreadyImported = false
|
||||
}
|
||||
|
||||
async function scanFolder() {
|
||||
if (!selectedFolder.value) return
|
||||
scanning.value = true
|
||||
// ─── Actions ──────────────────────────────────────────────────
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanError.value = ''
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(selectedFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) { toast('Erreur lors du scan', 'error'); return }
|
||||
const res = await fetch(`/api/browse-files?path=${encodeURIComponent(serieFolder.value)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
scanError.value = data.error ?? 'Erreur lors du scan'
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
const files: { name: string; path: string; size: number }[] = data.files ?? []
|
||||
|
||||
// Construire un index des fichiers déjà importés (dest_path → episode_id)
|
||||
const organizedIndex = buildOrganizedIndex()
|
||||
const organizedByPath = buildOrganizedByPath()
|
||||
|
||||
items.value = files.map(f => {
|
||||
// Vérifier si ce fichier est déjà dans organized via son path ou son nom
|
||||
const existingEpId = organizedIndex.get(f.path) ?? organizedIndex.get(f.name) ?? null
|
||||
|
||||
// Vérifier si déjà importé via dest_path
|
||||
const existingEpId = organizedByPath.get(f.path) ?? null
|
||||
if (existingEpId) {
|
||||
return {
|
||||
file : f,
|
||||
episode_id : existingEpId,
|
||||
hash : getHashForEpisode(existingEpId),
|
||||
alreadyImported: true,
|
||||
}
|
||||
return { file: f, episode_id: existingEpId, hash: getHashForEpisode(existingEpId), alreadyImported: true, willRename: false }
|
||||
}
|
||||
|
||||
const match = autoMatch(f.name)
|
||||
return { file: f, ...match, alreadyImported: false }
|
||||
return { file: f, ...match, alreadyImported: false, willRename: match.episode_id !== null }
|
||||
})
|
||||
|
||||
step.value = 'match'
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
scanError.value = 'Impossible de contacter le serveur'
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
@@ -327,12 +305,14 @@ async function doImport() {
|
||||
importError.value = ''
|
||||
|
||||
const payload = items.value
|
||||
.filter(i => i.episode_id !== null)
|
||||
.map(i => ({
|
||||
file_path : i.file.path,
|
||||
episode_id : i.episode_id,
|
||||
hash : i.hash,
|
||||
}))
|
||||
.filter(i => i.episode_id !== null && !i.alreadyImported)
|
||||
.map(i => ({ file_path: i.file.path, episode_id: i.episode_id, hash: i.hash }))
|
||||
|
||||
if (payload.length === 0) {
|
||||
importError.value = 'Aucun nouveau fichier à importer'
|
||||
importing.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/manual-import', {
|
||||
@@ -341,10 +321,8 @@ async function doImport() {
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ serie_id: props.serieId, items: payload }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) { importError.value = data.error ?? 'Erreur inconnue'; return }
|
||||
|
||||
importResult.value = { done: data.done, errors: data.errors ?? [] }
|
||||
step.value = 'done'
|
||||
} catch {
|
||||
@@ -353,4 +331,6 @@ async function doImport() {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(scan)
|
||||
</script>
|
||||
Reference in new issue
Block a user