Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -1,5 +1,69 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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'
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ const BASE_DIR = _isBunBinary
|
|||||||
? path.dirname((process as any).execPath)
|
? path.dirname((process as any).execPath)
|
||||||
: process.cwd()
|
: process.cwd()
|
||||||
|
|
||||||
const TORRENTS_PATH = path.join(BASE_DIR, 'config', 'torrent_final.json')
|
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json')
|
||||||
const ORGANIZED_PATH = path.join(BASE_DIR, 'config', 'organized.json')
|
const ORGANIZED_PATH = path.join(BASE_DIR, 'data', 'organized.json')
|
||||||
|
|
||||||
// ─── Utils log ────────────────────────────────────────────────
|
// ─── Utils log ────────────────────────────────────────────────
|
||||||
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
|
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
|
||||||
@@ -39,7 +39,7 @@ function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'deb
|
|||||||
|
|
||||||
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(process.cwd(), 'data', '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 } }
|
||||||
@@ -289,6 +289,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
|||||||
return { ...result, total: 1, done: 1 }
|
return { ...result, total: 1, done: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Map filename → original_filename (depuis l'API) ────────
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Map filename → season ─────────────────────────────────
|
// ── Map filename → season ─────────────────────────────────
|
||||||
// Priorité : season_number dans torrent_files (packs multi-saisons)
|
// Priorité : season_number dans torrent_files (packs multi-saisons)
|
||||||
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
|
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
|
||||||
@@ -350,9 +358,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)
|
||||||
|
|||||||
+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