Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e284b0de | ||
|
|
0b98269695 | ||
|
|
402946b462 | ||
|
|
c374ceb0a7 | ||
|
|
6f21b0a5cd | ||
|
|
1c36bb7688 | ||
|
|
82a9d51731 | ||
|
|
ac03aa3c11 |
No files matched your search
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.0](https://github.com/Masutayunikon/FanKarr/compare/v2.3.3...v2.4.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** option titres optimisés Plex (title_for_plex) ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** activeTorrents filtre désormais uniquement state=downloading ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
* **organize:** retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
* **organize:** sanitisation des caractères spéciaux dans les noms de dossiers ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
|
||||
## [2.3.3](https://github.com/Masutayunikon/FanKarr/compare/v2.3.2...v2.3.3) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* recuperation des episodes avec un delai ([dfc6c13](https://github.com/Masutayunikon/FanKarr/commit/dfc6c1300e3dd8323e422ce3e71465bac77f94cf))
|
||||
|
||||
## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24)
|
||||
|
||||
|
||||
|
||||
+43
-14
@@ -42,8 +42,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport }))
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, usePlexTitles }))
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -279,16 +279,43 @@ function normalizeEpisode(ep: any): any {
|
||||
// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename
|
||||
// dans chaque épisode du serieData, indexé par episode_id
|
||||
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
|
||||
return Promise.all(seriesData.map(async (sd) => {
|
||||
if (!sd) return sd
|
||||
const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => {
|
||||
const results: any[] = []
|
||||
|
||||
for (const sd of seriesData) {
|
||||
if (!sd) { results.push(sd); continue }
|
||||
|
||||
// Fetcher title_for_plex depuis l'API série
|
||||
let title_for_plex: string | null = null
|
||||
try {
|
||||
const serieApi = await fankaiGet(`/series/${sd.id}`)
|
||||
title_for_plex = serieApi.title_for_plex ?? null
|
||||
} catch {}
|
||||
|
||||
const enrichedSeasons: any[] = []
|
||||
for (const season of sd.seasons ?? []) {
|
||||
let epsData: any[] = []
|
||||
try {
|
||||
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
||||
} catch { return season }
|
||||
// 2 tentatives en cas d'erreur réseau ponctuelle
|
||||
let lastErr: any
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
||||
break
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
if (attempt === 0) await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
}
|
||||
if (epsData.length === 0 && lastErr) throw lastErr
|
||||
} catch {
|
||||
enrichedSeasons.push(season)
|
||||
continue
|
||||
}
|
||||
|
||||
// Petit délai pour ne pas saturer l'API
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// Index episode_id → original_filename + formatted_name
|
||||
const origMap = new Map<number, { original_filename: string; formatted_name: string }>()
|
||||
for (const ep of epsData) {
|
||||
if (ep.id) origMap.set(ep.id, {
|
||||
@@ -297,17 +324,19 @@ async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise
|
||||
})
|
||||
}
|
||||
|
||||
// Injecter dans les épisodes du serieData
|
||||
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
|
||||
...ep,
|
||||
original_filename: origMap.get(ep.id)?.original_filename ?? null,
|
||||
formatted_name : origMap.get(ep.id)?.formatted_name ?? null,
|
||||
}))
|
||||
|
||||
return { ...season, episodes: enrichedEpisodes }
|
||||
}))
|
||||
return { ...sd, seasons: enrichedSeasons }
|
||||
}))
|
||||
enrichedSeasons.push({ ...season, episodes: enrichedEpisodes })
|
||||
}
|
||||
|
||||
results.push({ ...sd, title_for_plex, seasons: enrichedSeasons })
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Helper réutilisable : charge + enrichit toutes les seriesData du cache
|
||||
|
||||
@@ -118,12 +118,18 @@ function buildFileMap(
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
// nfoSupport → original_filename (correspond aux .nfo GitLab)
|
||||
// sinon → formatted_name de l'API (nommage Jellyfin/Plex standardisé)
|
||||
// sinon → formatted_name de l'API, ou fallback original_filename
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
warn(`[organize] formatted_name manquant pour ep ${ep.id} (ep ${ep.episode_number}) — fallback original_filename`)
|
||||
}
|
||||
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
@@ -243,6 +249,16 @@ function tryHardlink(src: string, dest: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sanitise un nom de dossier ──────────────────────────────
|
||||
// Remplace les caractères interdits sur les systèmes de fichiers communs
|
||||
function sanitizeDirName(name: string): string {
|
||||
return name
|
||||
.replace(/:/g, ' -') // "Kaguya-sama : Love" → "Kaguya-sama - Love"
|
||||
.replace(/[<>"/\\|?*]/g, '') // autres caractères interdits
|
||||
.replace(/\s+/g, ' ') // espaces multiples
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
|
||||
@@ -258,7 +274,13 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const serieTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const { usePlexTitles } = readSettings()
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(
|
||||
usePlexTitles && serieData.title_for_plex
|
||||
? serieData.title_for_plex
|
||||
: rawTitle
|
||||
)
|
||||
const torrentTitle = torrent.title ?? name
|
||||
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
|
||||
|
||||
@@ -278,11 +300,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
|
||||
+3
-1
@@ -10,7 +10,8 @@ export interface Settings {
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean // import automatique quand un torrent passe en seeding
|
||||
autoImport : boolean
|
||||
usePlexTitles : boolean // utilise title_for_plex pour les noms de dossiers
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -20,6 +21,7 @@ const defaults: Settings = {
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
usePlexTitles: false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -154,6 +154,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Titres Plex -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NOMS DE DOSSIERS</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="form.usePlexTitles = !form.usePlexTitles"
|
||||
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
|
||||
:class="form.usePlexTitles ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="form.usePlexTitles ? 'translate-x-5' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs text-[#c8d8e8]">Utiliser les titres optimisés Plex</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
Utilise <span class="text-white">title_for_plex</span> pour les noms de dossiers —
|
||||
évite les caractères spéciaux (ex: <span class="text-white">Kaguya-sama - Love is War Henshū</span>
|
||||
au lieu de <span class="text-white">Kaguya-sama : Love is War Henshū</span>)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Support NFO -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NFO / MÉTADONNÉES</label>
|
||||
@@ -277,6 +296,7 @@ const form = ref({
|
||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
usePlexTitles: false,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
Reference in new issue
Block a user