Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f5dfb0da | ||
|
|
08531c80f3 | ||
|
|
96ce207027 | ||
|
|
13f9a27af8 |
No files matched your search
@@ -1,5 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||||
|
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||||
|
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||||
|
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||||
|
|
||||||
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+30
-14
@@ -142,22 +142,28 @@ function computeSerieDownloadState(
|
|||||||
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||||
if (hasActive) return 'downloading'
|
if (hasActive) return 'downloading'
|
||||||
|
|
||||||
let totalFiles = 0
|
// Collecter tous les épisodes uniques de la série (par episode_id)
|
||||||
let doneFiles = 0
|
const allEpisodeIds = new Set<number>()
|
||||||
|
const organizedEpisodeIds = new Set<number>()
|
||||||
|
|
||||||
for (const t of serieTorrents) {
|
for (const t of serieTorrents) {
|
||||||
|
const hash = t.infohash?.toLowerCase()
|
||||||
|
const orgFiles = organized[hash] ?? {}
|
||||||
const files: any[] = t.torrent_files ?? []
|
const files: any[] = t.torrent_files ?? []
|
||||||
if (files.length === 0) {
|
|
||||||
totalFiles++
|
for (const ep of t.resolved_episodes ?? []) {
|
||||||
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
allEpisodeIds.add(ep.episode_id)
|
||||||
} else {
|
// Cherche le fichier correspondant à cet épisode dans torrent_files
|
||||||
totalFiles += files.length
|
const epFile = files.find((f: any) => f.filename === ep.filename)
|
||||||
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
|
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
|
||||||
|
organizedEpisodeIds.add(ep.episode_id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalFiles === 0) return 'none'
|
if (allEpisodeIds.size === 0) return 'none'
|
||||||
if (doneFiles === 0) return 'none'
|
if (organizedEpisodeIds.size === 0) return 'none'
|
||||||
if (doneFiles >= totalFiles) return 'complete'
|
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||||
return 'partial'
|
return 'partial'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +248,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
const availableBySnEn = new Set<string>() // "sn:en"
|
const availableBySnEn = new Set<string>() // "sn:en"
|
||||||
const episodeTorrentMap: Record<number, any> = {}
|
const episodeTorrentMap: Record<number, any> = {}
|
||||||
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
||||||
const seasonTorrentMap : Record<number, any> = {}
|
const seasonTorrentMap : Record<number, any> = {} // keyed by season_id (scraper)
|
||||||
|
const seasonTorrentMapBySn: Record<number, any> = {} // keyed by season_number (fallback)
|
||||||
const integraleTorrents: any[] = []
|
const integraleTorrents: any[] = []
|
||||||
const packEpisodesTorrents: any[] = []
|
const packEpisodesTorrents: any[] = []
|
||||||
|
|
||||||
for (const t of torrents) {
|
for (const t of torrents) {
|
||||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw, manual: t.manual ?? false }
|
||||||
for (const ep of t.resolved_episodes ?? []) {
|
for (const ep of t.resolved_episodes ?? []) {
|
||||||
availableEpisodeIds.add(ep.episode_id)
|
availableEpisodeIds.add(ep.episode_id)
|
||||||
const snEnKey = `${ep.season_number}:${ep.episode_number}`
|
const snEnKey = `${ep.season_number}:${ep.episode_number}`
|
||||||
@@ -264,6 +271,8 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
} else {
|
} else {
|
||||||
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
||||||
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
||||||
|
const sns = rs.length > 0 ? rs.map((r: any) => r.season_number) : [t.season_number]
|
||||||
|
for (const sn of sns) { if (sn != null) seasonTorrentMapBySn[sn] = ref }
|
||||||
}
|
}
|
||||||
} else if (t.type === 'pack_integrale') {
|
} else if (t.type === 'pack_integrale') {
|
||||||
integraleTorrents.push(t)
|
integraleTorrents.push(t)
|
||||||
@@ -272,6 +281,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── DEBUG ──────────────────────────────────────────────
|
||||||
|
console.log(`[debug /api/series/${id}] serie="${serie.title}" allSeasonIds=`, [...allSeasonIds])
|
||||||
|
console.log(`[debug /api/series/${id}] seasonTorrentMap=`, seasonTorrentMap)
|
||||||
|
console.log(`[debug /api/series/${id}] seasons from API:`, seasonsWithEpisodes.map((s: any) => ({ id: s.id, season_number: s.season_number, title: s.title })))
|
||||||
|
console.log(`[debug /api/series/${id}] torrents matched:`, torrents.map(t => ({ raw: t.raw, type: t.type, season_id: t.season_id, resolved_seasons: t.resolved_seasons })))
|
||||||
|
// ── END DEBUG ──────────────────────────────────────────
|
||||||
|
|
||||||
let promotedIntegrale: any = null
|
let promotedIntegrale: any = null
|
||||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
||||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
||||||
@@ -349,7 +365,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
||||||
return {
|
return {
|
||||||
...season,
|
...season,
|
||||||
torrent : seasonTorrentMap[season.id] ?? null,
|
torrent : seasonTorrentMap[season.id] ?? seasonTorrentMapBySn[season.season_number] ?? null,
|
||||||
organized_state : seasonOrganizedState,
|
organized_state : seasonOrganizedState,
|
||||||
organized_count : orgCount,
|
organized_count : orgCount,
|
||||||
episodes : eps,
|
episodes : eps,
|
||||||
|
|||||||
+31
-25
@@ -27,7 +27,8 @@
|
|||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
||||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
|
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20"
|
||||||
|
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" />
|
||||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
<button
|
<button
|
||||||
v-for="(t, i) in data.torrents_integrale"
|
v-for="(t, i) in data.torrents_integrale"
|
||||||
:key="i"
|
:key="i"
|
||||||
|
:title="t.raw"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||||
:class="[
|
:class="[
|
||||||
t.label === 'Intégrale'
|
t.label === 'Intégrale'
|
||||||
@@ -81,12 +83,10 @@
|
|||||||
<!-- Barre progression intégrale -->
|
<!-- Barre progression intégrale -->
|
||||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||||
<template v-if="torrentProgress(extractHash(t))">
|
<template v-if="torrentProgress(extractHash(t))">
|
||||||
<!-- Badge 100% -->
|
|
||||||
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
|
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
|
||||||
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||||
✓ Téléchargé
|
✓ Téléchargé
|
||||||
</span>
|
</span>
|
||||||
<!-- Barre en cours -->
|
|
||||||
<div v-else class="mt-2 w-full max-w-sm">
|
<div v-else class="mt-2 w-full max-w-sm">
|
||||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||||
<span>Téléchargement</span>
|
<span>Téléchargement</span>
|
||||||
@@ -160,16 +160,15 @@
|
|||||||
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
|
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Barre progression saison -->
|
<!-- Barre progression saison -->
|
||||||
<template v-if="torrentProgress(extractHash(season.torrent))">
|
<template v-if="torrentProgress(extractHash(season.torrent))">
|
||||||
<!-- Badge 100% -->
|
|
||||||
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
|
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
|
||||||
class="px-5 py-2 border-b border-white/5">
|
class="px-5 py-2 border-b border-white/5">
|
||||||
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||||
✓ Téléchargé
|
✓ Téléchargé
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Barre en cours -->
|
|
||||||
<div v-else class="px-5 py-2 border-b border-white/5">
|
<div v-else class="px-5 py-2 border-b border-white/5">
|
||||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||||
<span>Téléchargement en cours</span>
|
<span>Téléchargement en cours</span>
|
||||||
@@ -202,9 +201,9 @@
|
|||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 mt-0.5">
|
<div class="flex items-center gap-3 mt-0.5">
|
||||||
@@ -213,38 +212,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bouton épisode (type episode uniquement) -->
|
<!-- Badges + bouton download -->
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
|
|
||||||
|
<!-- Badge Hors Fankai (torrent manuel) -->
|
||||||
|
<span
|
||||||
|
v-if="ep.torrent?.manual"
|
||||||
|
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||||
|
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||||
|
>
|
||||||
|
Hors Fankai
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- Badge organisé -->
|
<!-- Badge organisé -->
|
||||||
<span v-if="ep.organized"
|
<span v-if="ep.organized"
|
||||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||||
✓ organisé
|
✓ organisé
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Badge téléchargé à 100% -->
|
<!-- Badge téléchargé à 100% -->
|
||||||
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
|
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
|
||||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||||
✓ téléchargé
|
✓ téléchargé
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Badge dispo/indispo -->
|
<!-- Badge dispo/indispo -->
|
||||||
<span
|
<span
|
||||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
||||||
:class="ep.available
|
:class="ep.available
|
||||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||||
>
|
>
|
||||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Bouton download -->
|
<!-- Bouton download -->
|
||||||
<button
|
<button
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||||
:class="[
|
:class="[
|
||||||
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
||||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||||
]"
|
]"
|
||||||
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
|
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
|
||||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||||
>
|
>
|
||||||
@@ -317,7 +326,7 @@ const store = useSeriesStore()
|
|||||||
|
|
||||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||||
const downloading = ref<Set<string>>(new Set())
|
const downloading = ref<Set<string>>(new Set())
|
||||||
const downloaded = ref<Set<string>>(new Set()) // envoyés avec succès → bloqués
|
const downloaded = ref<Set<string>>(new Set())
|
||||||
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||||
|
|
||||||
// ── Progression downloads ──────────────────────────────────────
|
// ── Progression downloads ──────────────────────────────────────
|
||||||
@@ -339,13 +348,11 @@ async function fetchActiveDownloads() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cherche si un torrent actif correspond à un hash donné
|
|
||||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||||
if (!hash) return null
|
if (!hash) return null
|
||||||
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash d'un torrent de saison ou d'épisode (extrait depuis magnet ou url)
|
|
||||||
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
|
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
|
||||||
if (!torrent) return null
|
if (!torrent) return null
|
||||||
const magnet = torrent.magnet ?? ''
|
const magnet = torrent.magnet ?? ''
|
||||||
@@ -375,7 +382,6 @@ function showToast(type: 'success' | 'error', message: string) {
|
|||||||
setTimeout(() => { toast.value = null }, 3500)
|
setTimeout(() => { toast.value = null }, 3500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifie si un torrent est déjà en cours ou terminé dans qBittorrent
|
|
||||||
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
|
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
|
||||||
const hash = extractHash(torrent)
|
const hash = extractHash(torrent)
|
||||||
if (!hash) return false
|
if (!hash) return false
|
||||||
|
|||||||
Reference in new issue
Block a user