Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0d40c02b5 | ||
|
|
0e541a19ba | ||
|
|
1d71c40a2b | ||
|
|
adc3e91122 | ||
|
|
b260208701 | ||
|
|
3dd517c37f | ||
|
|
93397bee77 | ||
|
|
3f72c01911 | ||
|
|
77e6a2e38c | ||
|
|
bef99183aa | ||
|
|
7b84229510 | ||
|
|
23c46a621e | ||
|
|
4dc7255bb0 | ||
|
|
180513c98a | ||
|
|
00894bc069 | ||
|
|
a1b128640d | ||
|
|
8bec4c62fa | ||
|
|
a7ba6a54a3 | ||
|
|
33d0b9a52d | ||
|
|
46546d0178 | ||
|
|
756ced0001 | ||
|
|
2addb39ba3 | ||
|
|
232ede4292 | ||
|
|
f161e84a1d | ||
|
|
4d4a8029f6 | ||
|
|
2a1dcddd90 | ||
|
|
4d319f97bc |
No files matched your search
+106
@@ -1,5 +1,111 @@
|
||||
# Changelog
|
||||
|
||||
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
|
||||
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import type { Request, Response, NextFunction } from 'express'
|
||||
import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'config', 'auth.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
|
||||
+10
-25
@@ -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')
|
||||
@@ -403,7 +391,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
|
||||
let torrentFinal: any[] = []
|
||||
try {
|
||||
const tfPath = path.join(DATA_DIR, 'config', 'torrent_final.json')
|
||||
const tfPath = path.join(DATA_DIR, 'torrent_final.json')
|
||||
if (fs.existsSync(tfPath))
|
||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -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)
|
||||
})
|
||||
+1
-1
@@ -20,7 +20,7 @@ export interface LogEntry {
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'config', 'logs.jsonl')
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
+30
-35
@@ -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,29 +268,38 @@ 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
|
||||
if (sn !== null) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tfNum !== undefined && epByNum.has(tfNum)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
|
||||
// Priorité 1 : resolved_episodes (saison API Fankai)
|
||||
const fromResolved = filenameToSeason.get(tf.filename)
|
||||
if (fromResolved !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, fromResolved)
|
||||
// Priorité 2 : season_number dans torrent_files (dossier Saison/Partie)
|
||||
} else if (tf.season_number != null) {
|
||||
filenameSeasonMap.set(tf.filename, Number(tf.season_number))
|
||||
// Priorité 3 : season_number du torrent
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
@@ -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
@@ -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()
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const SECRET_PATH = path.join(DATA_DIR, 'config', 'secret.key')
|
||||
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const DATA_PATH = path.join(DATA_DIR, 'config', 'settings.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
|
||||
@@ -78,7 +78,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'config', 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user