Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00894bc069 | ||
|
|
a1b128640d | ||
|
|
8bec4c62fa | ||
|
|
a7ba6a54a3 | ||
|
|
33d0b9a52d | ||
|
|
46546d0178 | ||
|
|
756ced0001 | ||
|
|
2addb39ba3 | ||
|
|
232ede4292 |
No files matched your search
@@ -1,5 +1,20 @@
|
||||
# Changelog
|
||||
|
||||
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
|
||||
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
|
||||
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
+9
-24
@@ -135,21 +135,18 @@ function indexByNormalizedTitle(torrents: any[]): Map<string, any[]> {
|
||||
function computeSerieDownloadState(
|
||||
serieTorrents : any[],
|
||||
organized : Record<string, Record<string, string>>,
|
||||
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
|
||||
activeTorrents: Set<string>
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (serieTorrents.length === 0) return 'none'
|
||||
|
||||
// Un torrent de la série est en cours de téléchargement ?
|
||||
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||
if (hasActive) return 'downloading'
|
||||
|
||||
// Compter les fichiers attendus vs organisés
|
||||
let totalFiles = 0
|
||||
let doneFiles = 0
|
||||
for (const t of serieTorrents) {
|
||||
const files: any[] = t.torrent_files ?? []
|
||||
if (files.length === 0) {
|
||||
// Fichier unique
|
||||
totalFiles++
|
||||
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
||||
} else {
|
||||
@@ -191,7 +188,7 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
|
||||
const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
|
||||
// Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente (ID le plus grand)
|
||||
// Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente
|
||||
const seenTitles = new Map<string, any>()
|
||||
for (const serie of seriesRaw) {
|
||||
const key = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||
@@ -201,7 +198,6 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
const existing = seenTitles.get(key)!
|
||||
const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0
|
||||
const newHasTorrents = (byId.get(serie.id) ?? byNormTitle.get(key) ?? []).length > 0
|
||||
// Préférer celle qui a des torrents, sinon la plus récente
|
||||
if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie)
|
||||
else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie)
|
||||
}
|
||||
@@ -236,7 +232,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
}))
|
||||
|
||||
const allTorrents = readTorrents()
|
||||
const serieMeta = allTorrents.find(t => t.serie_id === id)
|
||||
const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||
const torrents = allTorrents.filter(t =>
|
||||
t.serie_id === id ||
|
||||
@@ -244,7 +239,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
// Fallback par season_number+episode_number pour les séries migrées Kaï→Kai
|
||||
const availableBySnEn = new Set<string>() // "sn:en"
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
||||
@@ -319,8 +313,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Construire un set des episode_id organisés
|
||||
// On croise resolved_episodes de chaque torrent avec organized.json
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
const organizedBySnEn = new Set<string>()
|
||||
for (const t of torrents) {
|
||||
@@ -328,9 +320,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const files = t.torrent_files ?? []
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
// Vérifier si le fichier de cet épisode est organisé
|
||||
const epFile = files.find((f: any) =>
|
||||
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
||||
)
|
||||
@@ -352,7 +342,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
organized : organizedEpisodeIds.has(ep.id)
|
||||
|| organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`),
|
||||
}))
|
||||
// State saison : none / partial / complete
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const seasonOrganizedState =
|
||||
@@ -393,7 +382,6 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
|
||||
// Enrichir avec l'état d'organisation
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
@@ -419,7 +407,6 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||
else if (doneFiles > 0) organizeState = 'partial'
|
||||
|
||||
// Récupérer les erreurs depuis recentOrganized
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const errorFiles = notif?.errorFiles ?? []
|
||||
|
||||
@@ -432,7 +419,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Résultats d'organisation récents (pour notifications frontend) ──
|
||||
// ── Résultats d'organisation récents ──────────────────────────
|
||||
interface OrganizeNotif {
|
||||
hash : string
|
||||
name : string
|
||||
@@ -454,13 +441,11 @@ app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
// Vider les notifs lues
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
@@ -475,7 +460,6 @@ app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Torrents status ────────────────────────────────────────────
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
@@ -551,7 +535,7 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
||||
// ── SPA fallback (prod) ────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
@@ -591,7 +575,8 @@ app.listen(PORT, async () => {
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
// Auto-organise — polling différentiel toutes les 5 minutes
|
||||
// Le worker ne se lance que si un torrent vient de passer en seeding
|
||||
const autoOrganize = async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
@@ -607,9 +592,9 @@ app.listen(PORT, async () => {
|
||||
console.error('[organize] Erreur auto-organise:', err)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
// Premier run 10s après démarrage, puis toutes les 5 minutes
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 30_000)
|
||||
}, 30_000)
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
}, 10_000)
|
||||
})
|
||||
+13
-10
@@ -289,21 +289,23 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
const filenameSeasonMap = new Map<string, number>()
|
||||
|
||||
// Index resolved_episodes par episode_number pour lookup rapide
|
||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
||||
const filenameToSeason = new Map<string, number>()
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.episode_number !== undefined && ep.season_number !== undefined)
|
||||
epByNum.set(Number(ep.episode_number), ep.season_number)
|
||||
if (ep.filename && ep.season_number !== undefined)
|
||||
filenameToSeason.set(ep.filename, ep.season_number)
|
||||
}
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const sn: number | null = tf.season_number != null ? Number(tf.season_number) : null
|
||||
const tfNum = tf.num !== undefined ? Number(tf.num) : undefined
|
||||
const sn = tf.season_number != null ? Number(tf.season_number) : null
|
||||
if (sn !== null) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tfNum !== undefined && epByNum.has(tfNum)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
} else {
|
||||
const fromResolved = filenameToSeason.get(tf.filename)
|
||||
if (fromResolved !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, fromResolved)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +316,8 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
const filePath: string[] = tf.path
|
||||
|
||||
// Skip les fichiers dans des dossiers bonus/musique/images
|
||||
if (isInExcludedFolder(filePath)) {
|
||||
const tfMeta = torrentFiles.find((tf: any) => tf.filename === filename)
|
||||
if (isInExcludedFolder(filePath) && tfMeta?.season_number == null) {
|
||||
log(`[organize] skip bonus: ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
|
||||
+46
-3
@@ -13,6 +13,9 @@ import { logger } from './logger.js'
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
|
||||
// État précédent des torrents pour détecter les passages downloading → seeding
|
||||
const _prevStates = new Map<string, string>() // hash → state
|
||||
|
||||
// Résolution du chemin du worker
|
||||
// En binaire Bun compilé : le worker est à côté de l'exécutable
|
||||
// En Docker/dev : à côté du fichier courant
|
||||
@@ -81,6 +84,9 @@ export async function scanMediaPath(
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Construire le set des fichiers présents sur le disque (pour nettoyage)
|
||||
const presentFiles = new Set<string>() // "hash:filename"
|
||||
|
||||
// Parcourir récursivement mediaPath
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
@@ -94,6 +100,7 @@ export async function scanMediaPath(
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
presentFiles.add(`${hash}:${entry.name}`)
|
||||
if (organized[hash]?.[entry.name]) continue // déjà connu
|
||||
// Marquer comme organisé
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
@@ -105,9 +112,28 @@ export async function scanMediaPath(
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
if (result.added > 0) {
|
||||
// Nettoyer les entrées orphelines (fichiers supprimés)
|
||||
let removed = 0
|
||||
for (const [hash, files] of Object.entries(organized)) {
|
||||
for (const filename of Object.keys(files)) {
|
||||
if (!presentFiles.has(`${hash}:${filename}`)) {
|
||||
// Vérifier que ce hash est bien dans le filenameIndex
|
||||
// (évite de supprimer des entrées de torrents non-scannés)
|
||||
if (filenameIndex.get(filename) === hash) {
|
||||
delete organized[hash][filename]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Supprimer l'entrée du torrent si elle est vide
|
||||
if (Object.keys(organized[hash]).length === 0) {
|
||||
delete organized[hash]
|
||||
}
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés, ${removed} entrées orphelines supprimées`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
}
|
||||
@@ -139,10 +165,27 @@ export async function autoOrganizeAll(
|
||||
|
||||
if (torrents.length === 0) return
|
||||
|
||||
const isFirstRun = _prevStates.size === 0
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
|
||||
// Détecter les torrents qui viennent de passer en seeding (downloading → seeding)
|
||||
const newlySeeding = torrents.filter(t => {
|
||||
const prev = _prevStates.get(t.hash)
|
||||
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
|
||||
})
|
||||
|
||||
// Mettre à jour l'état précédent
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
// Lancer seulement si : premier run avec des seeders, ou nouveau torrent terminé
|
||||
if (!isFirstRun && newlySeeding.length === 0) return
|
||||
if (seedingCount === 0) return
|
||||
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`)
|
||||
} else {
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
}
|
||||
workerRunning = true
|
||||
|
||||
const workerPath = resolveWorkerPath()
|
||||
|
||||
Reference in new issue
Block a user