Compare commits

...
17 Commits
Author SHA1 Message Date
MasutaYunikon 93397bee77 Merge pull request #44 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.1
2026-03-21 04:00:35 +01:00
MasutaYunikon 3f72c01911 chore(main): release 1.12.1 2026-03-21 03:59:32 +01:00
MasutaYunikon 77e6a2e38c Merge remote-tracking branch 'origin/main' 2026-03-21 03:59:09 +01:00
MasutaYunikon bef99183aa fix: path 2026-03-21 03:58:55 +01:00
MasutaYunikon 7b84229510 Merge pull request #43 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.0
2026-03-21 03:45:31 +01:00
MasutaYunikon 23c46a621e chore(main): release 1.12.0 2026-03-21 03:45:10 +01:00
MasutaYunikon 4dc7255bb0 Merge remote-tracking branch 'origin/main' 2026-03-21 03:44:46 +01:00
MasutaYunikon 180513c98a feat: now rename episodes with original_filename from api 2026-03-21 03:44:37 +01:00
MasutaYunikon 00894bc069 Merge pull request #42 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.10
2026-03-21 03:18:52 +01:00
MasutaYunikon a1b128640d chore(main): release 1.11.10 2026-03-21 03:18:32 +01:00
MasutaYunikon 8bec4c62fa fix: improve torrent organization and cleanup logic 2026-03-21 03:18:09 +01:00
MasutaYunikon a7ba6a54a3 Merge remote-tracking branch 'origin/main' 2026-03-21 02:14:03 +01:00
MasutaYunikon 33d0b9a52d fix: season 0 not clonficting anymore 2026-03-21 02:13:52 +01:00
MasutaYunikon 46546d0178 Merge pull request #41 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.9
2026-03-21 00:09:13 +01:00
MasutaYunikon 756ced0001 chore(main): release 1.11.9 2026-03-20 22:03:02 +01:00
MasutaYunikon 2addb39ba3 Merge remote-tracking branch 'origin/main' 2026-03-20 22:02:40 +01:00
MasutaYunikon 232ede4292 fix: season 0 resolved is no longer skipped in the worker 2026-03-20 22:02:17 +01:00
4 changed files with 114 additions and 62 deletions

No files matched your search

+29
View File
@@ -1,5 +1,34 @@
# Changelog
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
### Bug Fixes
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
### Features
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
## [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
View File
@@ -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)
})
+30 -35
View File
@@ -21,15 +21,12 @@ const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json')
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
// qui les écrit dans le fichier via logger.ts
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
@@ -106,8 +103,6 @@ function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
@@ -133,7 +128,6 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier (pagination GitLab)
let files: any[] = []
try {
let page = 1
@@ -160,13 +154,10 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) {
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
const destDir = path.dirname(destPath)
// Skip si déjà présent
if (fs.existsSync(destPath)) continue
try {
@@ -219,15 +210,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
// ── Téléchargement NFO/images depuis GitLab ───────────────
if (nfoSupport) {
// Collecter tous les titres de séries distincts depuis resolved_episodes
// (un pack_integrale peut mélanger plusieurs séries, ex: One Piece Yabai + Kaï)
const serieTitlesMap = new Map<number, string>()
// Titre principal du torrent
serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle)
// Titres alternatifs depuis resolved_episodes
// resolved_episodes n'a pas toujours serie_title → on cherche dans torrent_final.json
const allTorrents: any[] = (() => {
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
})()
@@ -243,7 +228,6 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
}
}
// Télécharger NFO pour chaque série distincte
for (const title of serieTitlesMap.values()) {
const serieDestRoot = path.join(mediaPath, title)
await downloadGitlabFolder(title, serieDestRoot)
@@ -284,26 +268,35 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
}
// ── Map filename → season ─────────────────────────────────
// Priorité : season_number dans torrent_files (packs multi-saisons)
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
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
// Index par filename depuis resolved_episodes (évite les collisions episode_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)
}
}
}
// ── Map filename → original_filename (depuis l'API) ────────
// Permet de renommer le fichier selon le nom standardisé Fankai
const filenameToOriginal = new Map<string, string>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.original_filename && ep.filename !== ep.original_filename) {
filenameToOriginal.set(ep.filename, ep.original_filename)
}
}
@@ -314,7 +307,9 @@ 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)) {
// Exception : fichiers avec season_number explicite (saison 0)
const tfMeta = torrentFiles.find((tf: any) => tf.filename === filename)
if (isInExcludedFolder(filePath) && tfMeta?.season_number == null) {
log(`[organize] skip bonus: ${filename}`)
result.skipped++
continue
@@ -344,9 +339,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
continue
}
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destFilename = filenameToOriginal.get(filename) ?? filename
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, destFilename)
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
@@ -364,7 +360,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
log(`[organize] ✓ ${destFilename} → Saison ${String(seasonNum).padStart(3,'0')}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
@@ -394,7 +390,6 @@ parentPort?.on('message', async (msg: any) => {
}
const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename])
: !!organized[t.hash]?.[t.name]
+46 -3
View File
@@ -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()