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 | ||
|
|
3372d4cb40 | ||
|
|
eb22bb669f | ||
|
|
0d64a66977 | ||
|
|
b0e81166e1 | ||
|
|
79d7aa7485 | ||
|
|
af995a84cb | ||
|
|
5a71bdca3c | ||
|
|
77d918fbe0 | ||
|
|
a0b255338d | ||
|
|
e0b09c144f | ||
|
|
418d09b07f | ||
|
|
4c7936b019 | ||
|
|
77f42b5b3e | ||
|
|
bd69f7a730 | ||
|
|
3ce6523248 | ||
|
|
2efed9dcfc | ||
|
|
88e2a0bc38 | ||
|
|
338e3f8547 | ||
|
|
d1ccc902da | ||
|
|
5e54e24b60 |
No files matched your search
+141
@@ -1,5 +1,146 @@
|
|||||||
# Changelog
|
# 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)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||||
|
|
||||||
|
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||||
|
|
||||||
|
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||||
|
|
||||||
|
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||||
|
|
||||||
|
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||||
|
|
||||||
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
|
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
|
|||||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||||
|
|
||||||
# Corriger les permissions du dossier data
|
# Corriger les permissions du dossier data
|
||||||
chown -R "$PUID:$PGID" /app/data
|
chown -R "$PUID:$PGID" /config
|
||||||
|
|
||||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||||
+2
-1
@@ -4,8 +4,9 @@ import bcrypt from 'bcryptjs'
|
|||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import type { Request, Response, NextFunction } from 'express'
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
import { JWT_SECRET } from './secret.js'
|
import { JWT_SECRET } from './secret.js'
|
||||||
|
import { DATA_DIR } from './config.js'
|
||||||
|
|
||||||
const DATA_PATH = path.join(process.cwd(), 'config', 'auth.json')
|
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||||
const SALT_ROUNDS = 10
|
const SALT_ROUNDS = 10
|
||||||
|
|
||||||
interface AuthData {
|
interface AuthData {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||||
|
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||||
|
|
||||||
|
export const BASE_DIR = _isBunBinary
|
||||||
|
? path.dirname((process as any).execPath)
|
||||||
|
: process.cwd()
|
||||||
|
|
||||||
|
export const DATA_DIR = fs.existsSync('/.dockerenv')
|
||||||
|
? '/config'
|
||||||
|
: path.join(BASE_DIR, 'config')
|
||||||
+11
-39
@@ -12,23 +12,10 @@ import {
|
|||||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||||
|
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||||
|
|
||||||
registerDriver(qbittorrentDriver)
|
registerDriver(qbittorrentDriver)
|
||||||
|
|
||||||
// ── Base directory ─────────────────────────────────────────────
|
|
||||||
// Binaire Bun compilé : data/ et public/ sont à côté de l'exécutable
|
|
||||||
// Docker / dev : relatif au cwd (comportement inchangé)
|
|
||||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
|
||||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
|
||||||
export const BASE_DIR = _isBunBinary
|
|
||||||
? path.dirname((process as any).execPath)
|
|
||||||
: process.cwd()
|
|
||||||
|
|
||||||
// DATA_DIR : /config
|
|
||||||
export const DATA_DIR = fs.existsSync('/.dockerenv')
|
|
||||||
? '/config'
|
|
||||||
: path.join(BASE_DIR, 'config')
|
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT) || 9898
|
const PORT = Number(process.env.PORT) || 9898
|
||||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||||
@@ -148,21 +135,18 @@ function indexByNormalizedTitle(torrents: any[]): Map<string, any[]> {
|
|||||||
function computeSerieDownloadState(
|
function computeSerieDownloadState(
|
||||||
serieTorrents : any[],
|
serieTorrents : any[],
|
||||||
organized : Record<string, Record<string, string>>,
|
organized : Record<string, Record<string, string>>,
|
||||||
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
|
activeTorrents: Set<string>
|
||||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||||
if (serieTorrents.length === 0) return 'none'
|
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()))
|
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||||
if (hasActive) return 'downloading'
|
if (hasActive) return 'downloading'
|
||||||
|
|
||||||
// Compter les fichiers attendus vs organisés
|
|
||||||
let totalFiles = 0
|
let totalFiles = 0
|
||||||
let doneFiles = 0
|
let doneFiles = 0
|
||||||
for (const t of serieTorrents) {
|
for (const t of serieTorrents) {
|
||||||
const files: any[] = t.torrent_files ?? []
|
const files: any[] = t.torrent_files ?? []
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
// Fichier unique
|
|
||||||
totalFiles++
|
totalFiles++
|
||||||
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
||||||
} else {
|
} else {
|
||||||
@@ -204,7 +188,7 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
|||||||
|
|
||||||
const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
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>()
|
const seenTitles = new Map<string, any>()
|
||||||
for (const serie of seriesRaw) {
|
for (const serie of seriesRaw) {
|
||||||
const key = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
const key = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||||
@@ -214,7 +198,6 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
|||||||
const existing = seenTitles.get(key)!
|
const existing = seenTitles.get(key)!
|
||||||
const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0
|
const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0
|
||||||
const newHasTorrents = (byId.get(serie.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)
|
if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie)
|
||||||
else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie)
|
else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie)
|
||||||
}
|
}
|
||||||
@@ -249,7 +232,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const allTorrents = readTorrents()
|
const allTorrents = readTorrents()
|
||||||
const serieMeta = allTorrents.find(t => t.serie_id === id)
|
|
||||||
const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||||
const torrents = allTorrents.filter(t =>
|
const torrents = allTorrents.filter(t =>
|
||||||
t.serie_id === id ||
|
t.serie_id === id ||
|
||||||
@@ -257,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 allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||||
const availableEpisodeIds = new Set<number>()
|
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 availableBySnEn = new Set<string>() // "sn:en"
|
||||||
const episodeTorrentMap: Record<number, any> = {}
|
const episodeTorrentMap: Record<number, any> = {}
|
||||||
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
||||||
@@ -332,8 +313,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||||
} catch {}
|
} 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 organizedEpisodeIds = new Set<number>()
|
||||||
const organizedBySnEn = new Set<string>()
|
const organizedBySnEn = new Set<string>()
|
||||||
for (const t of torrents) {
|
for (const t of torrents) {
|
||||||
@@ -341,9 +320,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
const files = t.torrent_files ?? []
|
const files = t.torrent_files ?? []
|
||||||
const orgFiles = organized[hash] ?? {}
|
const orgFiles = organized[hash] ?? {}
|
||||||
if (Object.keys(orgFiles).length === 0) continue
|
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 ?? []) {
|
for (const ep of t.resolved_episodes ?? []) {
|
||||||
// Vérifier si le fichier de cet épisode est organisé
|
|
||||||
const epFile = files.find((f: any) =>
|
const epFile = files.find((f: any) =>
|
||||||
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
||||||
)
|
)
|
||||||
@@ -365,7 +342,6 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
|||||||
organized : organizedEpisodeIds.has(ep.id)
|
organized : organizedEpisodeIds.has(ep.id)
|
||||||
|| organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`),
|
|| organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`),
|
||||||
}))
|
}))
|
||||||
// State saison : none / partial / complete
|
|
||||||
const total = eps.filter((e: any) => e.available).length
|
const total = eps.filter((e: any) => e.available).length
|
||||||
const orgCount = eps.filter((e: any) => e.organized).length
|
const orgCount = eps.filter((e: any) => e.organized).length
|
||||||
const seasonOrganizedState =
|
const seasonOrganizedState =
|
||||||
@@ -406,7 +382,6 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
|||||||
const { category } = readSettings()
|
const { category } = readSettings()
|
||||||
const torrents = await dispatchList(category ?? 'fankai')
|
const torrents = await dispatchList(category ?? 'fankai')
|
||||||
|
|
||||||
// Enrichir avec l'état d'organisation
|
|
||||||
let organized: Record<string, Record<string, string>> = {}
|
let organized: Record<string, Record<string, string>> = {}
|
||||||
try {
|
try {
|
||||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||||
@@ -416,7 +391,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
|||||||
|
|
||||||
let torrentFinal: any[] = []
|
let torrentFinal: any[] = []
|
||||||
try {
|
try {
|
||||||
const tfPath = path.join(process.cwd(), 'config', 'torrent_final.json')
|
const tfPath = path.join(DATA_DIR, 'torrent_final.json')
|
||||||
if (fs.existsSync(tfPath))
|
if (fs.existsSync(tfPath))
|
||||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -432,7 +407,6 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
|||||||
if (doneFiles >= totalFiles) organizeState = 'done'
|
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||||
else if (doneFiles > 0) organizeState = 'partial'
|
else if (doneFiles > 0) organizeState = 'partial'
|
||||||
|
|
||||||
// Récupérer les erreurs depuis recentOrganized
|
|
||||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||||
const errorFiles = notif?.errorFiles ?? []
|
const errorFiles = notif?.errorFiles ?? []
|
||||||
|
|
||||||
@@ -445,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 {
|
interface OrganizeNotif {
|
||||||
hash : string
|
hash : string
|
||||||
name : string
|
name : string
|
||||||
@@ -467,13 +441,11 @@ app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
|||||||
res.json(recentOrganized)
|
res.json(recentOrganized)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Vider les notifs lues
|
|
||||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||||
recentOrganized.length = 0
|
recentOrganized.length = 0
|
||||||
res.json({ ok: true })
|
res.json({ ok: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||||
const { hash, name, save_path } = req.body
|
const { hash, name, save_path } = req.body
|
||||||
if (!hash || !name || !save_path) {
|
if (!hash || !name || !save_path) {
|
||||||
@@ -488,7 +460,6 @@ app.post('/api/organize', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Torrents status ────────────────────────────────────────────
|
|
||||||
// ── Logs ──────────────────────────────────────────────────────
|
// ── Logs ──────────────────────────────────────────────────────
|
||||||
app.get('/api/logs', requireAuth, (req, res) => {
|
app.get('/api/logs', requireAuth, (req, res) => {
|
||||||
const limit = Number(req.query.limit) || 100
|
const limit = Number(req.query.limit) || 100
|
||||||
@@ -564,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)) {
|
if (fs.existsSync(PUBLIC_PATH)) {
|
||||||
app.get('*path', (_req, res) => {
|
app.get('*path', (_req, res) => {
|
||||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||||
@@ -604,7 +575,8 @@ app.listen(PORT, async () => {
|
|||||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
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 () => {
|
const autoOrganize = async () => {
|
||||||
try {
|
try {
|
||||||
const { category } = readSettings()
|
const { category } = readSettings()
|
||||||
@@ -620,9 +592,9 @@ app.listen(PORT, async () => {
|
|||||||
console.error('[organize] Erreur auto-organise:', err)
|
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(() => {
|
setTimeout(() => {
|
||||||
autoOrganize()
|
autoOrganize()
|
||||||
setInterval(autoOrganize, 30_000)
|
setInterval(autoOrganize, 5 * 60_000)
|
||||||
}, 30_000)
|
}, 10_000)
|
||||||
})
|
})
|
||||||
+2
-1
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { DATA_DIR } from './config.js'
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export interface LogEntry {
|
|||||||
meta? : Record<string, any>
|
meta? : Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOGS_PATH = path.join(process.cwd(), 'config', 'logs.jsonl')
|
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||||
const MAX_LINES = 2000 // rotation auto au-delà
|
const MAX_LINES = 2000 // rotation auto au-delà
|
||||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
|||||||
+34
-45
@@ -15,31 +15,22 @@ import fs from 'fs'
|
|||||||
import fsp from 'fs/promises'
|
import fsp from 'fs/promises'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
|
import { DATA_DIR } from './config.js'
|
||||||
|
|
||||||
// Base directory : binaire Bun ou cwd (Docker/dev)
|
const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json')
|
||||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
|
||||||
const BASE_DIR = _isBunBinary
|
|
||||||
? path.dirname((process as any).execPath)
|
|
||||||
: process.cwd()
|
|
||||||
|
|
||||||
const TORRENTS_PATH = path.join(BASE_DIR, 'config', 'torrent_final.json')
|
|
||||||
const ORGANIZED_PATH = path.join(BASE_DIR, 'config', 'organized.json')
|
|
||||||
|
|
||||||
// ─── Utils log ────────────────────────────────────────────────
|
// ─── 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 log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||||
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
||||||
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', 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 } {
|
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||||
try {
|
try {
|
||||||
const p = path.join(process.cwd(), 'config', 'settings.json')
|
const p = path.join(DATA_DIR, 'settings.json')
|
||||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||||
@@ -112,8 +103,6 @@ function getGitlabSerieTitle(serieTitle: string): string {
|
|||||||
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
|
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'
|
||||||
@@ -139,7 +128,6 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
|||||||
|
|
||||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||||
|
|
||||||
// Lister récursivement tous les fichiers du dossier (pagination GitLab)
|
|
||||||
let files: any[] = []
|
let files: any[] = []
|
||||||
try {
|
try {
|
||||||
let page = 1
|
let page = 1
|
||||||
@@ -166,13 +154,10 @@ 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) {
|
||||||
// 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 relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||||
const destPath = path.join(destRoot, relativePath)
|
const destPath = path.join(destRoot, relativePath)
|
||||||
const destDir = path.dirname(destPath)
|
const destDir = path.dirname(destPath)
|
||||||
|
|
||||||
// Skip si déjà présent
|
|
||||||
if (fs.existsSync(destPath)) continue
|
if (fs.existsSync(destPath)) continue
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -225,15 +210,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
|
|
||||||
// ── Téléchargement NFO/images depuis GitLab ───────────────
|
// ── Téléchargement NFO/images depuis GitLab ───────────────
|
||||||
if (nfoSupport) {
|
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>()
|
const serieTitlesMap = new Map<number, string>()
|
||||||
|
|
||||||
// Titre principal du torrent
|
|
||||||
serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle)
|
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[] = (() => {
|
const allTorrents: any[] = (() => {
|
||||||
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
|
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
|
||||||
})()
|
})()
|
||||||
@@ -249,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()) {
|
for (const title of serieTitlesMap.values()) {
|
||||||
const serieDestRoot = path.join(mediaPath, title)
|
const serieDestRoot = path.join(mediaPath, title)
|
||||||
await downloadGitlabFolder(title, serieDestRoot)
|
await downloadGitlabFolder(title, serieDestRoot)
|
||||||
@@ -290,29 +268,38 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Map filename → season ─────────────────────────────────
|
// ── 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>()
|
const filenameSeasonMap = new Map<string, number>()
|
||||||
|
|
||||||
// Index resolved_episodes par episode_number pour lookup rapide
|
// Index par filename depuis resolved_episodes (évite les collisions episode_number)
|
||||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
const filenameToSeason = new Map<string, number>()
|
||||||
for (const ep of torrent.resolved_episodes ?? []) {
|
for (const ep of torrent.resolved_episodes ?? []) {
|
||||||
if (ep.episode_number !== undefined && ep.season_number !== undefined)
|
if (ep.filename && ep.season_number !== undefined)
|
||||||
epByNum.set(Number(ep.episode_number), ep.season_number)
|
filenameToSeason.set(ep.filename, ep.season_number)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tf of torrentFiles) {
|
for (const tf of torrentFiles) {
|
||||||
const sn: number | null = tf.season_number != null ? Number(tf.season_number) : null
|
// Priorité 1 : resolved_episodes (saison API Fankai)
|
||||||
const tfNum = tf.num !== undefined ? Number(tf.num) : undefined
|
const fromResolved = filenameToSeason.get(tf.filename)
|
||||||
if (sn !== null) {
|
if (fromResolved !== undefined) {
|
||||||
filenameSeasonMap.set(tf.filename, sn)
|
filenameSeasonMap.set(tf.filename, fromResolved)
|
||||||
} else if (tfNum !== undefined && epByNum.has(tfNum)) {
|
// Priorité 2 : season_number dans torrent_files (dossier Saison/Partie)
|
||||||
filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
|
} 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) {
|
} else if (torrent.season_number !== undefined) {
|
||||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
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
|
result.total = torrentFiles.length
|
||||||
|
|
||||||
for (const tf of torrentFiles) {
|
for (const tf of torrentFiles) {
|
||||||
@@ -320,7 +307,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
const filePath: string[] = tf.path
|
const filePath: string[] = tf.path
|
||||||
|
|
||||||
// Skip les fichiers dans des dossiers bonus/musique/images
|
// 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}`)
|
log(`[organize] skip bonus: ${filename}`)
|
||||||
result.skipped++
|
result.skipped++
|
||||||
continue
|
continue
|
||||||
@@ -350,9 +339,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
||||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
const destFilename = filenameToOriginal.get(filename) ?? filename
|
||||||
const dest = path.join(destDir, filename)
|
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||||
|
const dest = path.join(destDir, destFilename)
|
||||||
|
|
||||||
if (fs.existsSync(dest)) {
|
if (fs.existsSync(dest)) {
|
||||||
markOrganized(hash, filename)
|
markOrganized(hash, filename)
|
||||||
@@ -370,7 +360,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
}
|
}
|
||||||
markOrganized(hash, filename)
|
markOrganized(hash, filename)
|
||||||
result.done++
|
result.done++
|
||||||
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
|
log(`[organize] ✓ ${destFilename} → Saison ${String(seasonNum).padStart(3,'0')}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||||
error(`[organize] ✗ ${filename}: ${msg}`)
|
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||||
@@ -400,7 +390,6 @@ parentPort?.on('message', async (msg: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const files: any[] = torrent.torrent_files ?? []
|
const files: any[] = torrent.torrent_files ?? []
|
||||||
// Pour fichier unique : vérifier via le name directement
|
|
||||||
const allDone = files.length > 0
|
const allDone = files.length > 0
|
||||||
? files.every((f: any) => organized[t.hash]?.[f.filename])
|
? files.every((f: any) => organized[t.hash]?.[f.filename])
|
||||||
: !!organized[t.hash]?.[t.name]
|
: !!organized[t.hash]?.[t.name]
|
||||||
|
|||||||
+46
-3
@@ -13,6 +13,9 @@ import { logger } from './logger.js'
|
|||||||
// Un seul worker à la fois
|
// Un seul worker à la fois
|
||||||
let workerRunning = false
|
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
|
// Résolution du chemin du worker
|
||||||
// En binaire Bun compilé : le worker est à côté de l'exécutable
|
// En binaire Bun compilé : le worker est à côté de l'exécutable
|
||||||
// En Docker/dev : à côté du fichier courant
|
// En Docker/dev : à côté du fichier courant
|
||||||
@@ -81,6 +84,9 @@ export async function scanMediaPath(
|
|||||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// Construire le set des fichiers présents sur le disque (pour nettoyage)
|
||||||
|
const presentFiles = new Set<string>() // "hash:filename"
|
||||||
|
|
||||||
// Parcourir récursivement mediaPath
|
// Parcourir récursivement mediaPath
|
||||||
function walk(dir: string) {
|
function walk(dir: string) {
|
||||||
let entries: fs.Dirent[]
|
let entries: fs.Dirent[]
|
||||||
@@ -94,6 +100,7 @@ export async function scanMediaPath(
|
|||||||
result.found++
|
result.found++
|
||||||
const hash = filenameIndex.get(entry.name)
|
const hash = filenameIndex.get(entry.name)
|
||||||
if (!hash) continue
|
if (!hash) continue
|
||||||
|
presentFiles.add(`${hash}:${entry.name}`)
|
||||||
if (organized[hash]?.[entry.name]) continue // déjà connu
|
if (organized[hash]?.[entry.name]) continue // déjà connu
|
||||||
// Marquer comme organisé
|
// Marquer comme organisé
|
||||||
if (!organized[hash]) organized[hash] = {}
|
if (!organized[hash]) organized[hash] = {}
|
||||||
@@ -105,9 +112,28 @@ export async function scanMediaPath(
|
|||||||
|
|
||||||
walk(mediaPath)
|
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')
|
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 {
|
} else {
|
||||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
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
|
if (torrents.length === 0) return
|
||||||
|
|
||||||
|
const isFirstRun = _prevStates.size === 0
|
||||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
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
|
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
|
workerRunning = true
|
||||||
|
|
||||||
const workerPath = resolveWorkerPath()
|
const workerPath = resolveWorkerPath()
|
||||||
|
|||||||
+2
-1
@@ -9,8 +9,9 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
|
import { DATA_DIR } from './config.js'
|
||||||
|
|
||||||
const SECRET_PATH = path.join(process.cwd(), 'config', 'secret.key')
|
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
|
||||||
|
|
||||||
function generateSecret(): string {
|
function generateSecret(): string {
|
||||||
return crypto.randomBytes(48).toString('hex')
|
return crypto.randomBytes(48).toString('hex')
|
||||||
|
|||||||
+2
-6
@@ -1,12 +1,8 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { DATA_DIR } from './config.js'
|
||||||
|
|
||||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
|
||||||
const BASE_DIR = _isBunBinary
|
|
||||||
? path.dirname((process as any).execPath)
|
|
||||||
: process.cwd()
|
|
||||||
const DATA_PATH = path.join(BASE_DIR, 'config', 'settings.json')
|
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
mediaPath : string
|
mediaPath : string
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { DATA_DIR } from '../config.js'
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
|||||||
|
|
||||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||||
|
|
||||||
const CLIENTS_PATH = path.join(process.cwd(), 'config', 'torrent_clients.json')
|
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||||
|
|
||||||
function loadClients(): SavedClient[] {
|
function loadClients(): SavedClient[] {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user