Compare commits

...
21 Commits
Author SHA1 Message Date
MasutaYunikon f871644330 Merge pull request #6 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.5
2026-03-10 23:52:29 +01:00
MasutaYunikon a5e97088c4 chore(main): release 1.0.5 2026-03-10 23:52:14 +01:00
MasutaYunikon ca4542d71b Merge remote-tracking branch 'origin/main' 2026-03-10 23:51:58 +01:00
MasutaYunikon 61418c6f77 fix: add user permissions when the scripts is launched 2026-03-10 23:51:50 +01:00
MasutaYunikon 1d89a8f61e Merge pull request #5 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.4
2026-03-10 23:37:40 +01:00
MasutaYunikon db5454393f chore(main): release 1.0.4 2026-03-10 23:37:16 +01:00
MasutaYunikon e18c6a1c54 fix: add log for debug 2026-03-10 23:37:06 +01:00
MasutaYunikon 49d60aeabd Merge remote-tracking branch 'origin/main' 2026-03-10 23:36:57 +01:00
MasutaYunikon c78537c9d8 fix: add log for debug 2026-03-10 23:36:46 +01:00
MasutaYunikon def689a1ca Merge pull request #4 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.3
2026-03-10 23:27:12 +01:00
MasutaYunikon 8bddc61d41 chore(main): release 1.0.3 2026-03-10 23:25:18 +01:00
MasutaYunikon b6587acaf4 Merge remote-tracking branch 'origin/main' 2026-03-10 23:25:01 +01:00
MasutaYunikon dd1ead4568 fix: organize file 2026-03-10 23:24:51 +01:00
MasutaYunikon d83c060ab9 Merge pull request #3 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.2
2026-03-10 23:22:54 +01:00
MasutaYunikon f214f36cb2 chore(main): release 1.0.2 2026-03-10 23:22:21 +01:00
MasutaYunikon 6e654844aa Merge remote-tracking branch 'origin/main' 2026-03-10 23:22:03 +01:00
MasutaYunikon 38282fd854 fix: organize use torrent hash and not name 2026-03-10 23:21:56 +01:00
MasutaYunikon 754a7296f6 Merge pull request #2 from Masutayunikon/release-please--branches--main
chore(main): release 1.0.1
2026-03-10 22:41:00 +01:00
MasutaYunikon 784e2d80a2 chore(main): release 1.0.1 2026-03-10 22:40:40 +01:00
MasutaYunikon 4ed8dc3a54 Merge remote-tracking branch 'origin/main' 2026-03-10 22:40:21 +01:00
MasutaYunikon 27027642eb fix: docker errors 2026-03-10 22:40:13 +01:00
5 changed files with 143 additions and 68 deletions

No files matched your search

+36
View File
@@ -1,5 +1,41 @@
# Changelog
## [1.0.5](https://github.com/Masutayunikon/FanKarr/compare/v1.0.4...v1.0.5) (2026-03-10)
### Bug Fixes
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
## [1.0.4](https://github.com/Masutayunikon/FanKarr/compare/v1.0.3...v1.0.4) (2026-03-10)
### Bug Fixes
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
## [1.0.3](https://github.com/Masutayunikon/FanKarr/compare/v1.0.2...v1.0.3) (2026-03-10)
### Bug Fixes
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
## [1.0.2](https://github.com/Masutayunikon/FanKarr/compare/v1.0.1...v1.0.2) (2026-03-10)
### Bug Fixes
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
## [1.0.1](https://github.com/Masutayunikon/FanKarr/compare/v1.0.0...v1.0.1) (2026-03-10)
### Bug Fixes
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
## 1.0.0 (2026-03-10)
+7 -3
View File
@@ -15,13 +15,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# ── Production ─────────────────────────────────────────────────
FROM base
FROM node:20-slim
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist/server /app/dist/server
COPY --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data
EXPOSE 3001
ENV NODE_ENV=production
CMD ["node", "dist/server/index.js"]
ENTRYPOINT ["/entrypoint.sh"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data
exec gosu fankarr node dist/server/index.js
+4 -4
View File
@@ -8,7 +8,7 @@ import {
registerDriver, getDriver, getAvailableClients,
listClients, addClient, removeClient, getClient,
sanitizeClient, dispatchDownload, dispatchList
} from './torrent-clients'
} from './torrent-clients/index.js'
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll } from './organize.js'
@@ -19,7 +19,7 @@ const PORT = 3001
const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/CHANGE_ME/fankarr-scraper/main/data/torrent_final.json'
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
app.use(express.json())
app.use(cookieParser())
@@ -50,7 +50,7 @@ app.get('/api/torrent-clients/available', requireAuth, (_req, res) => {
res.json(getAvailableClients())
})
app.get('/api/torrent-clients', requireAuth, (_req, res) => {
res.json(listClients().map(c => sanitizeClient(c)))
res.json(listClients().map((c: any) => sanitizeClient(c)))
})
app.post('/api/torrent-clients', requireAuth, (req, res) => {
const { name, type, config } = req.body
@@ -222,7 +222,7 @@ app.post('/api/download', requireAuth, async (req, res) => {
const url = req.body.torrent_url ?? req.body.magnet
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
const results = await dispatchDownload(url)
res.status(results.every(r => r.ok) ? 200 : 207).json({ results })
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
})
// ── Downloads — liste torrents Fankai ──────────────────────────
+84 -61
View File
@@ -49,9 +49,9 @@ function readTorrents(): any[] {
} catch { return [] }
}
function findTorrentByName(name: string): any | null {
function findTorrentByHash(hash: string): any | null {
const torrents = readTorrents()
return torrents.find(t => t.raw === name) ?? null
return torrents.find(t => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
}
// ─── Filesystem ops ───────────────────────────────────────────
@@ -68,7 +68,8 @@ function tryHardlink(src: string, dest: string): boolean {
try {
fs.linkSync(src, dest)
return true
} catch {
} catch (err) {
console.warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
return false
}
}
@@ -90,35 +91,40 @@ export interface OrganizeResult {
export async function organizeTorrent(
hash : string,
name : string, // nom du dossier torrent dans completePath
savePath: string // save_path retourné par qBittorrent
name : string, // nom du dossier torrent (retourné par qB)
savePath: string // save_path retourné par qBittorrent
): Promise<OrganizeResult> {
const settings = readSettings()
const { mediaPath, completePath, organizeMode } = settings
console.log(`[organize] ── Début organisation ──────────────────────`)
console.log(`[organize] hash : ${hash}`)
console.log(`[organize] name : ${name}`)
console.log(`[organize] save_path : ${savePath}`)
console.log(`[organize] mediaPath : ${mediaPath}`)
console.log(`[organize] completePath: ${completePath}`)
console.log(`[organize] mode : ${organizeMode}`)
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
// Trouver le torrent dans torrent_final.json
const torrent = findTorrentByName(name)
// Trouver le torrent dans torrent_final.json via infohash
const torrent = findTorrentByHash(hash)
if (!torrent) {
throw new Error(`Torrent introuvable dans torrent_final.json : "${name}"`)
console.error(`[organize] ✗ Aucun torrent avec infohash="${hash}" dans torrent_final.json`)
throw new Error(`Torrent introuvable pour le hash : "${hash}"`)
}
console.log(`[organize] ✓ Torrent trouvé : ${torrent.raw}`)
const serieTitle = torrent.serie_title || torrent.show_title || name
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
console.log(`[organize] serie_title : ${serieTitle}`)
console.log(`[organize] fichiers : ${torrentFiles.length}`)
if (torrentFiles.length === 0) {
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
}
// Résoudre season_number par episode_id
const epSeasonMap = new Map<number, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename) {
epSeasonMap.set(ep.episode_number, ep.season_number)
}
}
// Map filename → season_number
// Map filename → season_number depuis resolved_episodes
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined) {
@@ -130,51 +136,59 @@ export async function organizeTorrent(
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
const filePath: string[] = tf.path
console.log(`[organize] ── Fichier : ${filename}`)
console.log(`[organize] path[] : ${JSON.stringify(filePath)}`)
// Skip si déjà organisé
if (isOrganized(hash, filename)) {
console.log(`[organize] → déjà organisé, skip`)
result.skipped++
continue
}
// Construire le chemin source
// save_path peut déjà contenir le nom du dossier ou pas selon le client
const torrentDir = path.join(savePath, name)
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
// Essayer plusieurs chemins sources possibles
const candidates = [
path.join(savePath, name, ...filePath.slice(0, -1), filename),
path.join(savePath, ...filePath.slice(0, -1), filename),
path.join(savePath, name, filename),
path.join(savePath, filename),
path.join(completePath, name, ...filePath.slice(0, -1), filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
// Chemin source = dossier torrent + sous-dossiers + fichier
const subPath = filePath.length > 1
? path.join(...filePath.slice(0, -1), filename)
: filename
let src: string | null = null
for (const candidate of candidates) {
console.log(`[organize] candidate: ${candidate} → ${fs.existsSync(candidate) ? '✓ EXISTS' : '✗ absent'}`)
if (fs.existsSync(candidate)) {
src = candidate
break
}
}
let src = path.join(torrentDir, subPath)
if (!fs.existsSync(src)) {
// Fallback: save_path sans le nom du torrent
src = path.join(torrentDirAlt, subPath)
}
if (!fs.existsSync(src)) {
// Fallback: fichier directement dans save_path/name
src = path.join(torrentDir, filename)
}
if (!fs.existsSync(src)) {
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
if (!src) {
const err = `Source introuvable (essayé ${candidates.length} chemins)`
console.error(`[organize] ✗ ${err}`)
result.errors.push({ file: filename, error: err })
continue
}
// Déterminer la saison
let seasonNum = filenameSeasonMap.get(filename)
if (seasonNum === undefined) {
// Fallback : season_number = 1
seasonNum = 1
}
console.log(`[organize] ✓ source : ${src}`)
// Construire le chemin destination
// Déterminer la saison
const seasonNum = filenameSeasonMap.get(filename) ?? 1
console.log(`[organize] saison : ${seasonNum}`)
// Chemin destination
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
console.log(`[organize] dest : ${dest}`)
// Skip si dest existe déjà
if (fs.existsSync(dest)) {
console.log(`[organize] → dest existe déjà, skip`)
markOrganized(hash, filename)
result.skipped++
continue
@@ -186,33 +200,29 @@ export async function organizeTorrent(
if (organizeMode === 'hardlink') {
const ok = tryHardlink(src, dest)
if (!ok) {
// Hardlink impossible (filesystems différents) → copy
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
console.warn(`[organize] hardlink impossible → fallback copy`)
copyFile(src, dest)
}
} else {
// move = copy + delete source
copyFile(src, dest)
fs.unlinkSync(src)
}
markOrganized(hash, filename)
result.done++
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
console.log(`[organize] ✓ ${organizeMode} OK`)
} catch (err) {
result.errors.push({
file : filename,
error: err instanceof Error ? err.message : 'Erreur inconnue'
})
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
console.error(`[organize] ✗ ${msg}`)
result.errors.push({ file: filename, error: msg })
}
}
console.log(`[organize] ── Résultat : ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Auto-organise polling ────────────────────────────────────
// Appelé depuis index.ts au démarrage, toutes les 30s
// Détecte les torrents "seeding" non encore organisés
export async function autoOrganizeAll(
listFn: () => Promise<any[]>
@@ -220,22 +230,35 @@ export async function autoOrganizeAll(
let torrents: any[]
try {
torrents = await listFn()
} catch { return }
} catch (err) {
console.error('[organize] autoOrganizeAll: impossible de récupérer la liste des torrents:', err)
return
}
console.log(`[organize] autoOrganizeAll: ${torrents.length} torrents récupérés`)
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
if (t.state !== 'seeding') {
console.log(`[organize] skip ${t.name} (state=${t.state})`)
continue
}
// Vérifier si tous les fichiers sont déjà organisés
const torrent = findTorrentByName(t.name)
if (!torrent) continue
const torrent = findTorrentByHash(t.hash)
if (!torrent) {
console.warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans torrent_final.json`)
continue
}
const files: any[] = torrent.torrent_files ?? []
if (files.length === 0) continue
const allDone = files.every(f => organized[t.hash]?.[f.filename])
if (allDone) continue
const allDone = files.every((f: any) => organized[t.hash]?.[f.filename])
if (allDone) {
console.log(`[organize] ${t.name} → déjà entièrement organisé`)
continue
}
console.log(`[organize] Auto-organisation : ${t.name}`)
try {