Compare commits

..
21 Commits
Author SHA1 Message Date
MasutaYunikon 2d49125e09 Merge pull request #54 from Masutayunikon/release-please--branches--main
chore(main): release 2.2.0
2026-03-23 15:38:51 +01:00
MasutaYunikon 721047c95f chore(main): release 2.2.0 2026-03-23 15:38:03 +01:00
MasutaYunikon 74662de2b6 Merge remote-tracking branch 'origin/main' 2026-03-23 15:37:23 +01:00
MasutaYunikon 84ef0333f3 feat: add organize all button
fix: organizer reuse the wrong name for gitlab path
2026-03-23 15:37:07 +01:00
MasutaYunikon c6624843b9 Merge pull request #53 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.3
2026-03-23 06:09:59 +01:00
MasutaYunikon 8bd30913cd chore(main): release 2.1.3 2026-03-23 06:09:36 +01:00
MasutaYunikon d7eb932ed3 Merge remote-tracking branch 'origin/main' 2026-03-23 06:09:04 +01:00
MasutaYunikon 4594dc5189 fix: use magnet before torrent 2026-03-23 06:08:48 +01:00
MasutaYunikon 894a621e85 Merge pull request #52 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.2
2026-03-23 05:38:16 +01:00
MasutaYunikon 23dbf86e70 chore(main): release 2.1.2 2026-03-23 05:38:02 +01:00
MasutaYunikon d86138ef1c Merge remote-tracking branch 'origin/main' 2026-03-23 05:37:32 +01:00
MasutaYunikon 4aa6f99676 fix: path now work on every device 2026-03-23 05:37:23 +01:00
MasutaYunikon 10432e0f99 Merge pull request #51 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.1
2026-03-23 05:32:19 +01:00
MasutaYunikon 4f69f8bf92 chore(main): release 2.1.1 2026-03-23 05:32:01 +01:00
MasutaYunikon 1511dd3ca3 Merge remote-tracking branch 'origin/main' 2026-03-23 05:31:29 +01:00
MasutaYunikon 23967a81d6 fix: torrent path to hash 2026-03-23 05:31:20 +01:00
MasutaYunikon 21ff025376 Merge pull request #50 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.0
2026-03-23 05:00:49 +01:00
MasutaYunikon 9ba19f9539 chore(main): release 2.1.0 2026-03-23 05:00:34 +01:00
MasutaYunikon 215d802b12 fix: remove logo
Release-As: 2.1.0
2026-03-23 05:00:05 +01:00
MasutaYunikon 21dccbe5d9 Merge remote-tracking branch 'origin/main'
Release-As: 2.1.0
2026-03-23 04:56:45 +01:00
MasutaYunikon 329150f1c4 fix(organize): migration vers paths { infohash, path } pour le matching fichiers
BREAKING CHANGE: le format paths[] dans series/{id}.json passe de string[]
à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.

fix(organize): buildFileMap matche par infohash au lieu d'index ou titre

L'ancien système utilisait soit un index positionnel fragile, soit le titre
du torrent comme clé de matching — les deux cassaient si l'utilisateur
renommait le torrent dans qBittorrent ou si un pack intégrale ne couvrait
pas tous les épisodes. Désormais chaque path est lié à un infohash précis.

fix(organize): source fichier via save_path qBittorrent + fullPath relatif

path.join(savePath, fullPath) utilise le save_path retourné par qBittorrent
au lieu de deviner le nom du dossier depuis le titre du torrent. Résiste
aux renommages de dossier côté client.

fix(organize): scanMediaPath adapté au nouveau format paths[]

L'index filename → infohash est maintenant construit directement depuis
ep.paths[].infohash sans passer par la hiérarchie torrents série/saison/épisode.
2026-03-23 04:52:33 +01:00
5 changed files with 243 additions and 107 deletions

No files matched your search

+48
View File
@@ -1,5 +1,53 @@
# Changelog # Changelog
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
### Features
* add organize all button ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
### Bug Fixes
* organizer reuse the wrong name for gitlab path ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
## [2.1.3](https://github.com/Masutayunikon/FanKarr/compare/v2.1.2...v2.1.3) (2026-03-23)
### Bug Fixes
* use magnet before torrent ([ed4665a](https://github.com/Masutayunikon/FanKarr/commit/ed4665a378d15c0b45a2be32d612e41082a43df1))
## [2.1.2](https://github.com/Masutayunikon/FanKarr/compare/v2.1.1...v2.1.2) (2026-03-23)
### Bug Fixes
* path now work on every device ([cfc3440](https://github.com/Masutayunikon/FanKarr/commit/cfc3440a95cb73802778e01d5f256f473e0cc952))
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
### Bug Fixes
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
### ⚠ BREAKING CHANGES
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
### Bug Fixes
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23) ## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
+34 -20
View File
@@ -192,22 +192,36 @@ function extractTorrentsFromSerieData(sd: any): any[] {
} }
// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx] // Construit resolved_episodes pour un torrent à partir des paths[torrentIdx]
// paths[i] = chemin du fichier dans le torrent i → on extrait le filename // Construit les épisodes résolus pour un torrent donné par son infohash
function buildResolvedEpisodes(sd: any, torrentIdx: number, seasonFilter?: number): any[] { function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = [] const resolved: any[] = []
const h = hash.toLowerCase()
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[torrentIdx] const paths: any[] = ep.paths ?? []
// Nouveau format { infohash, path } ou legacy string
let filePath: string | null = null
for (const p of paths) {
if (typeof p === 'string') {
if (!filePath) filePath = p.replace(/\\/g, '/') // legacy fallback
} else if (p?.infohash?.toLowerCase() === h) {
filePath = p.path.replace(/\\/g, '/')
break
}
}
if (!filePath) continue if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath const filename = filePath.split('/').pop() ?? filePath
resolved.push({ resolved.push({
episode_id : ep.id, episode_id : ep.id,
episode_number: ep.episode_number, episode_number : ep.episode_number,
season_number : season.season_number, season_number : season.season_number,
season_id : season.id, season_id : season.id,
filename, filename,
original_filename: filename, // sera enrichi depuis l'API si besoin original_filename: filename,
}) })
} }
} }
@@ -331,7 +345,7 @@ function computeSerieDownloadState(
if (!hash) continue if (!hash) continue
const orgFiles = organized[hash] ?? {} const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue if (Object.keys(orgFiles).length === 0) continue
const resolved = buildResolvedEpisodes(serieData, t._torrentIdx, t.season_number) const resolved = buildResolvedEpisodes(serieData, hash, t.season_number)
for (const ep of resolved) { for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
} }
@@ -433,15 +447,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
if (serieData) { if (serieData) {
// ── Torrents niveau série (pack_integrale) ────────────────── // ── Torrents niveau série (pack_integrale) ──────────────────
for (const [i, t] of (serieData.torrents ?? []).entries()) { for (const t of (serieData.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false } const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false }
integraleTorrents.push({ ...t, raw: t.title, _torrentIdx: i }) integraleTorrents.push({ ...t, raw: t.title })
// Marquer les épisodes disponibles via paths[i] const resolved = buildResolvedEpisodes(serieData, t.infohash)
const resolved = buildResolvedEpisodes(serieData, i)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
// Vérifier organisés
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) { for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id) if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
@@ -450,11 +462,11 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
// ── Torrents niveau saison (pack_saison) ──────────────────── // ── Torrents niveau saison (pack_saison) ────────────────────
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
for (const [i, t] of (season.torrents ?? []).entries()) { for (const t of (season.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false } const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
seasonTorrentMapBySn[season.season_number] = ref seasonTorrentMapBySn[season.season_number] = ref
const resolved = buildResolvedEpisodes(serieData, i, season.season_number) const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id) for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
@@ -465,13 +477,15 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
// ── Torrents niveau épisode ───────────────────────────── // ── Torrents niveau épisode ─────────────────────────────
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const [i, t] of (ep.torrents ?? []).entries()) { for (const t of (ep.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false } const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false }
episodeTorrentMap[ep.id] = ref episodeTorrentMap[ep.id] = ref
availableEpisodeIds.add(ep.id) availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {} const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const filename = (ep.paths ?? [])[i]?.split('/').pop() const paths: any[] = ep.paths ?? []
const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id) if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
} }
} }
@@ -531,7 +545,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
// ── Download ─────────────────────────────────────────────────── // ── Download ───────────────────────────────────────────────────
app.post('/api/download', requireAuth, async (req, res) => { app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.torrent_url ?? req.body.magnet const url = req.body.magnet ?? req.body.torrent_url
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return } if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const results = await dispatchDownload(url) const results = await dispatchDownload(url)
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results }) res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
@@ -562,7 +576,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
const allT = extractTorrentsFromSerieData(sd) const allT = extractTorrentsFromSerieData(sd)
const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase()) const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
if (match) { if (match) {
const resolved = buildResolvedEpisodes(sd, match._torrentIdx, match.season_number) const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1 totalFiles = resolved.length || 1
break break
} }
+97 -54
View File
@@ -67,31 +67,59 @@ function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; ser
if (t.infohash?.toLowerCase() === hash.toLowerCase()) if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd } return { torrent: { ...t, _season: season }, serieData: sd }
} }
// Niveau épisode // Niveau épisode — compter combien d'épisodes partagent ce torrent
for (const ep of season.episodes ?? []) { const matchingEpisodes = season.episodes?.filter((ep: any) =>
for (const t of ep.torrents ?? []) { ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
if (t.infohash?.toLowerCase() === hash.toLowerCase()) ) ?? []
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} if (matchingEpisodes.length === 1) {
// Torrent propre à un seul épisode → cas épisode unique
const ep = matchingEpisodes[0]
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
} else if (matchingEpisodes.length > 1) {
// Même torrent partagé entre plusieurs épisodes → traiter comme pack_saison
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd }
} }
} }
} }
return null return null
} }
// Construit la map filename → { season_number, original_filename } // Construit la map filename → { season_number, original_filename, fullPath }
// depuis les paths[] de la série en utilisant l'index du torrent // Supporte les deux formats : { infohash, path } et string (legacy)
function buildFileMap(serieData: any, torrentIdx: number, seasonFilter?: number): Map<string, { season_number: number; original_filename: string }> { function buildFileMap(
const map = new Map<string, { season_number: number; original_filename: string }>() serieData : any,
hash : string,
seasonFilter?: number
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) { for (const season of serieData.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[torrentIdx] const paths: any[] = ep.paths ?? []
if (!filePath) continue let matchedPath: string | null = null
const filename = filePath.split('/').pop() ?? filePath
// original_filename vient de l'API Fankai (passé dans ep directement si disponible) for (const p of paths) {
if (typeof p === 'string') {
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
} else if (p?.infohash?.toLowerCase() === h) {
matchedPath = p.path.replace(/\\/g, '/')
break
}
}
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
const original = ep.original_filename ?? filename const original = ep.original_filename ?? filename
map.set(filename, { season_number: season.season_number, original_filename: original }) map.set(filename, {
season_number : season.season_number,
original_filename: original,
fullPath : matchedPath,
})
} }
} }
return map return map
@@ -115,6 +143,23 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false return false
} }
// ─── Map correction titres API → titres GitLab ───────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ──────────────────────────────────── // ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository' const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack' const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -134,8 +179,9 @@ async function fetchBinary(url: string): Promise<Buffer> {
} }
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> { async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const folderPath = `pack/${serieTitle}` const gitlabTitle = getGitlabSerieTitle(serieTitle)
log(`[nfo] Récupération dossier GitLab: ${serieTitle}`) const folderPath = `pack/${gitlabTitle}`
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
let files: any[] = [] let files: any[] = []
try { try {
@@ -160,12 +206,12 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] ${fileEntries.length} fichiers à télécharger`) log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) { for (const entry of fileEntries) {
const relativePath = entry.path.replace(`pack/${serieTitle}/`, '') const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath) const destPath = path.join(destRoot, relativePath)
if (fs.existsSync(destPath)) continue if (fs.existsSync(destPath)) continue
try { try {
fs.mkdirSync(path.dirname(destPath), { recursive: true }) fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(serieTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}` const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl) const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data) fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`) log(`[nfo] ✓ ${relativePath}`)
@@ -173,7 +219,7 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`) warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
} }
} }
log(`[nfo] GitLab synchronisé pour "${serieTitle}"`) log(`[nfo] GitLab synchronisé pour "${gitlabTitle}"`)
} }
// ─── Filesystem ops ─────────────────────────────────────────── // ─── Filesystem ops ───────────────────────────────────────────
@@ -202,37 +248,25 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
} }
const { torrent, serieData } = found const { torrent, serieData } = found
const serieTitle = serieData.title ?? serieData.show_title ?? name const serieTitle = serieData.title ?? serieData.show_title ?? name
log(`[organize] ✓ Trouvé : ${torrent.title ?? name} — série: ${serieTitle}`) const torrentTitle = torrent.title ?? name
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
// Déterminer l'index du torrent dans sa hiérarchie
let torrentIdx = 0
let seasonFilter: number | undefined
if (torrent._episode) {
// Torrent niveau épisode : paths[0] est le seul fichier
torrentIdx = (torrent._episode.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
seasonFilter = torrent._season?.season_number
} else if (torrent._season) {
torrentIdx = (torrent._season.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
seasonFilter = torrent._season.season_number
} else {
torrentIdx = (serieData.torrents ?? []).findIndex((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
}
// Construire la map filename → { season_number, original_filename }
const fileMap = buildFileMap(serieData, torrentIdx, seasonFilter)
// ── NFO/images depuis GitLab ────────────────────────────── // ── NFO/images depuis GitLab ──────────────────────────────
if (nfoSupport) { if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle) await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
await downloadGitlabFolder(serieTitle, serieDestRoot)
} }
// ── Cas torrent épisode unique ──────────────────────────── // ── Cas torrent épisode unique ────────────────────────────
if (torrent._episode) { if (torrent._episode) {
const ep = torrent._episode const ep = torrent._episode
const season = torrent._season const season = torrent._season
const filePath = (ep.paths ?? [])[torrentIdx] const paths: any[] = ep.paths ?? []
let filePath: string | null = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
}
const filename = filePath?.split('/').pop() ?? name const filename = filePath?.split('/').pop() ?? name
const original = ep.original_filename ?? filename const original = ep.original_filename ?? filename
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1)) const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
@@ -240,12 +274,16 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 } if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
const candidates = [ const candidates = filePath ? [
path.join(savePath, name, filename), path.join(savePath, filePath),
path.join(completePath, filePath),
path.join(savePath, filename),
path.join(completePath, filename),
] : [
path.join(savePath, filename), path.join(savePath, filename),
path.join(completePath, name, filename),
path.join(completePath, filename), path.join(completePath, filename),
] ]
let src: string | null = null let src: string | null = null
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } } for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
@@ -268,7 +306,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
return { ...result, total: 1, done: 1 } return { ...result, total: 1, done: 1 }
} }
// ── Pack saison / intégrale : itérer sur les fichiers ───── // ── Pack saison / intégrale ───────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, seasonFilter)
if (fileMap.size === 0) { if (fileMap.size === 0) {
warn(`[organize] Aucun fichier mappé pour ${name}`) warn(`[organize] Aucun fichier mappé pour ${name}`)
return result return result
@@ -276,13 +317,15 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
result.total = fileMap.size result.total = fileMap.size
for (const [filename, { season_number, original_filename }] of fileMap) { for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue } if (isOrganized(hash, filename)) { result.skipped++; continue }
// Source : on utilise save_path + fullPath (chemin relatif exact dans le torrent)
// save_path vient de qBittorrent → résiste aux renommages de dossier
const candidates = [ const candidates = [
path.join(savePath, name, filename), path.join(savePath, fullPath),
path.join(completePath, fullPath),
path.join(savePath, filename), path.join(savePath, filename),
path.join(completePath, name, filename),
path.join(completePath, filename), path.join(completePath, filename),
] ]
let src: string | null = null let src: string | null = null
@@ -339,9 +382,9 @@ parentPort?.on('message', async (msg: any) => {
} }
// Vérifier si déjà tout organisé // Vérifier si déjà tout organisé
const { serieData } = found const { torrent, serieData } = found
const torrentIdx = (serieData.torrents ?? []).findIndex((st: any) => st.infohash?.toLowerCase() === t.hash.toLowerCase()) const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, torrentIdx) const fileMap = buildFileMap(serieData, t.hash, seasonFilter)
const allDone = fileMap.size > 0 const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f]) ? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name] : !!organized[t.hash]?.[t.name]
+21 -32
View File
@@ -51,39 +51,16 @@ export async function scanMediaPath(
// Construire index filename → infohash depuis les seriesData // Construire index filename → infohash depuis les seriesData
const filenameIndex = new Map<string, string>() // filename → infohash const filenameIndex = new Map<string, string>() // filename → infohash
for (const sd of seriesData) { for (const sd of seriesData) {
// Torrents niveau série
for (const [i, t] of (sd.torrents ?? []).entries()) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[i]
if (!filePath) continue
const filename = filePath.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
}
// Torrents niveau saison
for (const season of sd.seasons ?? []) { for (const season of sd.seasons ?? []) {
for (const [i, t] of (season.torrents ?? []).entries()) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const ep of season.episodes ?? []) {
const filePath = (ep.paths ?? [])[i]
if (!filePath) continue
const filename = filePath.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
// Torrents niveau épisode
for (const ep of season.episodes ?? []) { for (const ep of season.episodes ?? []) {
for (const [i, t] of (ep.torrents ?? []).entries()) { for (const p of ep.paths ?? []) {
const hash = t.infohash?.toLowerCase() if (typeof p === 'string') {
if (!hash) continue // Legacy : pas d'infohash disponible, on skip pour le scan
const filePath = (ep.paths ?? [])[i] continue
if (!filePath) continue }
const filename = filePath.split('/').pop() const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue
const filename = p.path.replace(/\\/g, '/').split('/').pop()
if (filename) filenameIndex.set(filename, hash) if (filename) filenameIndex.set(filename, hash)
} }
} }
@@ -177,7 +154,19 @@ export async function autoOrganizeAll(
for (const t of torrents) _prevStates.set(t.hash, t.state) for (const t of torrents) _prevStates.set(t.hash, t.state)
if (!isFirstRun && newlySeeding.length === 0) return // Charger organized.json pour vérifier s'il reste des torrents non organisés
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
const hasUnorganized = torrents.some(t =>
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
)
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
if (seedingCount === 0) return if (seedingCount === 0) return
if (newlySeeding.length > 0 && !isFirstRun) { if (newlySeeding.length > 0 && !isFirstRun) {
+43 -1
View File
@@ -10,6 +10,12 @@
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1> <h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button
@click="organizeAll"
:disabled="organizingAll"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
{{ organizingAll ? '...' : 'ORGANISER TOUT' }}
</button>
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest"> <div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/> <div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
{{ polling ? 'EN DIRECT' : 'PAUSE' }} {{ polling ? 'EN DIRECT' : 'PAUSE' }}
@@ -190,6 +196,7 @@ const loading = ref(true)
const polling = ref(true) const polling = ref(true)
const noClients = ref(false) const noClients = ref(false)
const organizing = ref<Record<string, boolean>>({}) const organizing = ref<Record<string, boolean>>({})
const organizingAll = ref(false)
const activeTab = ref<'active' | 'done'>('active') const activeTab = ref<'active' | 'done'>('active')
const hideOrganized = ref(false) const hideOrganized = ref(false)
@@ -251,7 +258,7 @@ function togglePolling() {
onMounted(startPolling) onMounted(startPolling)
onUnmounted(stopPolling) onUnmounted(stopPolling)
// ── Organiser ────────────────────────────────────────────────── // ── Organiser un torrent ───────────────────────────────────────
async function organize(torrent: any) { async function organize(torrent: any) {
organizing.value[torrent.hash] = true organizing.value[torrent.hash] = true
try { try {
@@ -263,6 +270,7 @@ async function organize(torrent: any) {
}) })
if (res.ok) { if (res.ok) {
toast(`${torrent.name} organisé ✓`, 'success') toast(`${torrent.name} organisé ✓`, 'success')
await fetchTorrents()
} else { } else {
const { error } = await res.json() const { error } = await res.json()
toast(error ?? 'Erreur lors de l\'organisation', 'error') toast(error ?? 'Erreur lors de l\'organisation', 'error')
@@ -274,6 +282,40 @@ async function organize(torrent: any) {
} }
} }
// ── Organiser tout ─────────────────────────────────────────────
async function organizeAll() {
const toOrganize = doneTorrents.value.filter(t => t.organizeState !== 'done')
if (toOrganize.length === 0) {
toast('Tous les torrents sont déjà organisés', 'success')
return
}
organizingAll.value = true
let done = 0
let errors = 0
for (const t of toOrganize) {
try {
const res = await fetch('/api/organize', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
})
if (res.ok) done++
else errors++
} catch {
errors++
}
}
organizingAll.value = false
await fetchTorrents()
if (errors === 0) toast(`${done} torrent(s) organisé(s) ✓`, 'success')
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
}
// ── Helpers affichage ────────────────────────────────────────── // ── Helpers affichage ──────────────────────────────────────────
function stateBadge(state: string) { function stateBadge(state: string) {
const map: Record<string, { label: string; class: string }> = { const map: Record<string, { label: string; class: string }> = {