Compare commits

...
68 Commits
Author SHA1 Message Date
Quentin Robert 16bca596b6 Merge pull request #118 from Masutayunikon/release-please--branches--main
chore(main): release 3.15.0
2026-05-10 02:58:56 +02:00
Quentin Robert ebc902c9e7 chore(main): release 3.15.0 2026-05-10 02:57:38 +02:00
MasutaYunikon f81969cdd0 Merge remote-tracking branch 'origin/main' 2026-05-10 02:57:13 +02:00
MasutaYunikon 950a2abc49 feat: ajout build docker pour arm32 2026-05-10 02:56:48 +02:00
github-actions[bot] 04a900e120 chore: update version.txt to v3.14.6 2026-05-08 00:15:05 +00:00
Quentin Robert 1c11eb1e89 Merge pull request #117 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.6
2026-05-08 02:14:38 +02:00
Quentin Robert 4dd9ba2ff4 chore(main): release 3.14.6 2026-05-08 02:14:15 +02:00
MasutaYunikon 27a23d76dd Merge remote-tracking branch 'origin/main' 2026-05-08 02:13:44 +02:00
MasutaYunikon fcb176d519 fix: pnpm build 2026-05-08 02:13:32 +02:00
github-actions[bot] 359be7fd9f chore: update version.txt to v3.14.5 2026-05-08 00:11:32 +00:00
Quentin Robert ece35824d3 Merge pull request #116 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.5
2026-05-08 02:10:59 +02:00
Quentin Robert fe807bded4 chore(main): release 3.14.5 2026-05-08 02:07:29 +02:00
MasutaYunikon 7cdfe13151 Merge remote-tracking branch 'origin/main' 2026-05-08 02:07:04 +02:00
MasutaYunikon d04ca79e00 fix: pnpm build 2026-05-08 02:06:48 +02:00
github-actions[bot] a37b527895 chore: update version.txt to v3.14.4 2026-05-08 00:05:03 +00:00
Quentin Robert 1e1a04a465 Merge pull request #115 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.4
2026-05-08 02:04:34 +02:00
Quentin Robert c6555ae47d chore(main): release 3.14.4 2026-05-08 02:03:41 +02:00
MasutaYunikon a76328cc16 Merge remote-tracking branch 'origin/main' 2026-05-08 02:03:18 +02:00
MasutaYunikon ef527c2072 fix: lock file for build 2026-05-08 02:03:09 +02:00
github-actions[bot] 45079dce25 chore: update version.txt to v3.14.3 2026-05-08 00:01:05 +00:00
Quentin Robert 5466028ae2 Merge pull request #114 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.3
2026-05-08 02:00:44 +02:00
Quentin Robert ea265cfd9e chore(main): release 3.14.3 2026-05-08 02:00:12 +02:00
MasutaYunikon 0e3e5fe912 Merge remote-tracking branch 'origin/main' 2026-05-08 01:59:48 +02:00
MasutaYunikon 6d8ea36260 fix: build binaries node version 2026-05-08 01:59:26 +02:00
github-actions[bot] ea1fb49a37 chore: update version.txt to v3.14.2 2026-05-07 23:55:36 +00:00
Quentin Robert 244fa4ae43 Merge pull request #113 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.2
2026-05-08 01:55:06 +02:00
Quentin Robert 5cfb449c3c chore(main): release 3.14.2 2026-05-08 01:54:21 +02:00
MasutaYunikon 3c9b9f5344 Merge remote-tracking branch 'origin/main' 2026-05-08 01:53:57 +02:00
MasutaYunikon 9ebaca92f1 fix: build binaries node version 2026-05-08 01:53:46 +02:00
github-actions[bot] 494266953c chore: update version.txt to v3.14.1 2026-05-07 23:46:45 +00:00
Quentin Robert 583097cd16 Merge pull request #112 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.1
2026-05-08 01:46:20 +02:00
Quentin Robert cb1d1759e3 chore(main): release 3.14.1 2026-05-08 01:45:59 +02:00
MasutaYunikon 563ffb2bd3 Merge remote-tracking branch 'origin/main' 2026-05-08 01:45:26 +02:00
MasutaYunikon 25ab373539 fix: eslint build 2026-05-08 01:45:16 +02:00
github-actions[bot] ed0acdda22 chore: update version.txt to v3.14.0 2026-05-07 17:24:23 +00:00
Quentin Robert a84337c928 Merge pull request #111 from Masutayunikon/release-please--branches--main
chore(main): release 3.14.0
2026-05-07 19:23:50 +02:00
Quentin Robert cface2944e chore(main): release 3.14.0 2026-05-07 15:30:06 +02:00
MasutaYunikon 1e92934f53 fix: renaming dans le managements des series 2026-05-07 15:29:29 +02:00
MasutaYunikon bf3f30356e fix: renaming dans le managements des series 2026-05-07 15:21:27 +02:00
MasutaYunikon bfabf9b793 fix: rtorrent priority default to 1 2026-05-07 15:08:30 +02:00
MasutaYunikon 1f50f9ae72 fix: on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) 2026-05-07 15:07:17 +02:00
MasutaYunikon 7da5a8d4e8 fix: cookie 2026-05-07 14:57:30 +02:00
MasutaYunikon 2b3fd79a92 fix: ajout de log pour le cookie 2026-05-07 14:55:08 +02:00
MasutaYunikon aa8b500f57 feat(qbittorrent): support cookie name (≥5.2.0) 2026-05-07 14:51:55 +02:00
MasutaYunikon 702847a5c2 feat(qbittorrent): support API key authentication (≥5.2.0) 2026-05-07 13:50:18 +02:00
MasutaYunikon 5cc1aa29fe Merge remote-tracking branch 'origin/main' 2026-05-07 01:19:00 +02:00
MasutaYunikon 105cc05bdf fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 01:18:50 +02:00
github-actions[bot] 50b8b2992b chore: update version.txt to v3.13.2 2026-05-06 22:43:17 +00:00
Quentin Robert f4c9713815 Merge pull request #110 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.2
2026-05-07 00:42:54 +02:00
Quentin Robert 7154dc87d2 chore(main): release 3.13.2 2026-05-07 00:42:35 +02:00
MasutaYunikon 2c9bef8883 Merge remote-tracking branch 'origin/main' 2026-05-07 00:41:59 +02:00
MasutaYunikon 0d5d44a484 fix: le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode 2026-05-07 00:41:50 +02:00
github-actions[bot] d5953a948c chore: update version.txt to v3.13.1 2026-05-06 21:37:40 +00:00
Quentin Robert 132198a3a9 Merge pull request #109 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.1
2026-05-06 23:37:13 +02:00
Quentin Robert add5ec5a3e chore(main): release 3.13.1 2026-05-06 23:35:45 +02:00
MasutaYunikon 260fd10bf0 Merge branch 'main' of github.com:Masutayunikon/FanKarr 2026-05-06 23:34:53 +02:00
MasutaYunikon 95d2b270dc fix: ajout du champ wiki dans l'api 2026-05-06 23:33:35 +02:00
github-actions[bot] 1aa6c57e58 chore: update version.txt to v3.13.0 2026-05-06 21:29:35 +00:00
Quentin Robert bde377dd37 Merge pull request #108 from Masutayunikon/release-please--branches--main
chore(main): release 3.13.0
2026-05-06 23:29:06 +02:00
Quentin Robert 834dc12b05 chore(main): release 3.13.0 2026-05-06 23:13:38 +02:00
MasutaYunikon 35655d7784 fix: progression 2026-05-06 23:13:03 +02:00
MasutaYunikon 434da54ede fix(qbittorrent): FanKarr télécharge le .torrent et l'uploade directement à qBit
qBittorrent (Docker/runtipi) n'a pas forcément accès à nyaa.si depuis son réseau.
FanKarr télécharge lui-même le fichier .torrent et l'envoie en upload direct à qBit,
qui n'a donc plus besoin d'accès internet pour les métadonnées.
2026-05-06 23:00:12 +02:00
MasutaYunikon d299b76bd3 fix: ajout de log 2026-05-06 22:57:10 +02:00
MasutaYunikon 9f90b6ab6c fix(qbittorrent): pause uniquement pour les .torrent, pas pour les magnets
Les fichiers .torrent contiennent les métadonnées → pause/stopped OK, fichiers disponibles immédiatement.
Les magnets récupèrent les métadonnées via les pairs → sans pause pour permettre la connexion.
2026-05-06 22:47:26 +02:00
MasutaYunikon 2a77a38655 feat(torrent-clients): ajout du driver Real-Debrid
feat(organize): vérification de complétion par fichier avant d'importer
- Récupération de la progression par fichier (index + name + progress 0–1) dans qBittorrent, Transmission et rTorrent
- Ajout de getFiles() sur le driver qBittorrent et interface TorrentClientDriver
- Le worker filtre les fichiers incomplets (< 100%) avant move/copy/hardlink

fix(organize): retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows)

fix(organize): suppression des entrées orphelines lors de la migration d'IDs
- Quand l'ancien ID est orphelin et le nouvel ID déjà présent, l'orphelin est supprimé au lieu d'être conservé

fix(qbittorrent): suppression de paused/stopped à l'ajout du torrent
- Le torrent est ajouté normalement pour permettre la récupération immédiate des métadonnées
- Premier poll de qbApplyFilePriority réduit à 100 ms

feat(serie): badges épisodes et chapitres extraits du synopsis
- Regex sur ep.plot pour extraire les plages "Ép. 628–634" et "Ch. 700–706"
- La phrase de couverture est retirée du synopsis affiché
- Badges violet (épisodes) et teal (chapitres) à côté des badges MULTI/VOSTFR

feat(serie): lien wiki dans le header de la série si serie.wiki est défini

fix(serie): header mobile — items-start + aspect-[2/3] sur le poster
2026-05-06 22:20:22 +02:00
MasutaYunikon 39463bfbcb fix: utilisation de formated_name depuis les paths
feat: ajout de la mise a jour des id's
2026-05-06 16:41:53 +02:00
MasutaYunikon 83437def32 feat: ajout de la prise en charges des codex 2026-05-06 00:22:07 +02:00
github-actions[bot] 7f6bbd6323 chore: update version.txt to v3.12.1 2026-04-29 23:18:28 +00:00
23 changed files with 951 additions and 138 deletions

No files matched your search

+4 -4
View File
@@ -14,11 +14,11 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: latest
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
@@ -78,11 +78,11 @@ jobs:
- uses: pnpm/action-setup@v3
with:
version: latest
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
- name: Install dependencies
+1 -1
View File
@@ -35,6 +35,6 @@ jobs:
uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
View File
Whitespace-only changes.
+107
View File
@@ -1,5 +1,112 @@
# Changelog
## [3.15.0](https://github.com/Masutayunikon/FanKarr/compare/v3.14.6...v3.15.0) (2026-05-10)
### Features
* ajout build docker pour arm32 ([950a2ab](https://github.com/Masutayunikon/FanKarr/commit/950a2abc49fe0b7b5bcc0f47d781a45a01838ec3))
## [3.14.6](https://github.com/Masutayunikon/FanKarr/compare/v3.14.5...v3.14.6) (2026-05-08)
### Bug Fixes
* pnpm build ([fcb176d](https://github.com/Masutayunikon/FanKarr/commit/fcb176d519b1bff49c40b79192e71b6062671998))
## [3.14.5](https://github.com/Masutayunikon/FanKarr/compare/v3.14.4...v3.14.5) (2026-05-08)
### Bug Fixes
* pnpm build ([d04ca79](https://github.com/Masutayunikon/FanKarr/commit/d04ca79e0057e3d6cf92e49822d812266f629dba))
## [3.14.4](https://github.com/Masutayunikon/FanKarr/compare/v3.14.3...v3.14.4) (2026-05-08)
### Bug Fixes
* lock file for build ([ef527c2](https://github.com/Masutayunikon/FanKarr/commit/ef527c2072a93650f9159de12ae875a671fb845c))
## [3.14.3](https://github.com/Masutayunikon/FanKarr/compare/v3.14.2...v3.14.3) (2026-05-08)
### Bug Fixes
* build binaries node version ([6d8ea36](https://github.com/Masutayunikon/FanKarr/commit/6d8ea36260cb76e0592bbee3ce5450d2424b9209))
## [3.14.2](https://github.com/Masutayunikon/FanKarr/compare/v3.14.1...v3.14.2) (2026-05-07)
### Bug Fixes
* build binaries node version ([9ebaca9](https://github.com/Masutayunikon/FanKarr/commit/9ebaca92f18213fd6a79bf55f22cfb8358c4f7e0))
## [3.14.1](https://github.com/Masutayunikon/FanKarr/compare/v3.14.0...v3.14.1) (2026-05-07)
### Bug Fixes
* eslint build ([25ab373](https://github.com/Masutayunikon/FanKarr/commit/25ab373539c20942202e82cf678e5b7577ff0027))
## [3.14.0](https://github.com/Masutayunikon/FanKarr/compare/v3.13.2...v3.14.0) (2026-05-07)
### Features
* **qbittorrent:** support API key authentication (≥5.2.0) ([702847a](https://github.com/Masutayunikon/FanKarr/commit/702847a5c2eb5956f54a8475fefc80a08fca70f1))
* **qbittorrent:** support cookie name (≥5.2.0) ([aa8b500](https://github.com/Masutayunikon/FanKarr/commit/aa8b500f57efb97aae10eb0bd8e254388a8aff42))
### Bug Fixes
* ajout de log pour le cookie ([2b3fd79](https://github.com/Masutayunikon/FanKarr/commit/2b3fd79a92bbc7d7ed2142b7a23e03799efe0a28))
* cookie ([7da5a8d](https://github.com/Masutayunikon/FanKarr/commit/7da5a8d4e853f7e92770f20352478a76ee91642d))
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([105cc05](https://github.com/Masutayunikon/FanKarr/commit/105cc05bdf38127089869ea201165ac1d3479275))
* on ne vois plus le telechargement de fichier qui ont une priorité basse (ne pas télécharger) ([1f50f9a](https://github.com/Masutayunikon/FanKarr/commit/1f50f9ae72ec9958bfb92b2e6936c699e19272f3))
* renaming dans le managements des series ([1e92934](https://github.com/Masutayunikon/FanKarr/commit/1e92934f531c82b9da90c8597ec08cd162b5a9ee))
* renaming dans le managements des series ([bf3f303](https://github.com/Masutayunikon/FanKarr/commit/bf3f30356e086d310b7213b267356bc20976589c))
* rtorrent priority default to 1 ([bfabf9b](https://github.com/Masutayunikon/FanKarr/commit/bfabf9b7937f055579add331cd918a13f05084bf))
## [3.13.2](https://github.com/Masutayunikon/FanKarr/compare/v3.13.1...v3.13.2) (2026-05-06)
### Bug Fixes
* le bouton rename n'apparait plus quand on utilise differents torrents entre les épisode ([0d5d44a](https://github.com/Masutayunikon/FanKarr/commit/0d5d44a4840df27d8b23bde9935d12c664d5ad6c))
## [3.13.1](https://github.com/Masutayunikon/FanKarr/compare/v3.13.0...v3.13.1) (2026-05-06)
### Bug Fixes
* ajout du champ wiki dans l'api ([95d2b27](https://github.com/Masutayunikon/FanKarr/commit/95d2b270dcb3052b65f5918ec770799b43316cd6))
## [3.13.0](https://github.com/Masutayunikon/FanKarr/compare/v3.12.1...v3.13.0) (2026-05-06)
### Features
* ajout de la mise a jour des id's ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
* ajout de la prise en charges des codex ([83437de](https://github.com/Masutayunikon/FanKarr/commit/83437def32618c8a5b9ca4364db992762b9359f8))
* **organize:** vérification de complétion par fichier avant d'importer ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** badges épisodes et chapitres extraits du synopsis ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** lien wiki dans le header de la série si serie.wiki est défini ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **torrent-clients:** ajout du driver Real-Debrid ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
### Bug Fixes
* ajout de log ([d299b76](https://github.com/Masutayunikon/FanKarr/commit/d299b76bd3fc8a8411e71f9f05318901df8372ef))
* **organize:** retry EBUSY sur unlink — 5 tentatives × 500 ms (Windows) ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **organize:** suppression des entrées orphelines lors de la migration d'IDs ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* progression ([35655d7](https://github.com/Masutayunikon/FanKarr/commit/35655d7784025c9b2d7f992b24181d62b8488130))
* **qbittorrent:** FanKarr télécharge le .torrent et l'uploade directement à qBit ([434da54](https://github.com/Masutayunikon/FanKarr/commit/434da54ede8ece06c1fbcf4d66fe6ecdf87557a5))
* **qbittorrent:** pause uniquement pour les .torrent, pas pour les magnets ([9f90b6a](https://github.com/Masutayunikon/FanKarr/commit/9f90b6ab6c0aa97e33244d827370492abcfe9a13))
* **qbittorrent:** suppression de paused/stopped à l'ajout du torrent ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* **serie:** header mobile — items-start + aspect-[2/3] sur le poster ([2a77a38](https://github.com/Masutayunikon/FanKarr/commit/2a77a38655dcc307e36371422f8698e33fd73135))
* utilisation de formated_name depuis les paths ([39463bf](https://github.com/Masutayunikon/FanKarr/commit/39463bfbcb74c7241420504ba51bd91108df20e4))
## [3.12.1](https://github.com/Masutayunikon/FanKarr/compare/v3.12.0...v3.12.1) (2026-04-29)
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:22-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack enable && corepack prepare pnpm@10 --activate
COPY . /app
WORKDIR /app
+7
View File
@@ -139,6 +139,13 @@ fankarr-windows-x64/
**Linux / macOS** — rendre le binaire exécutable avant le premier lancement :
```bash
wget https://github.com/Masutayunikon/FanKarr/releases/download/v3.14.6/fankarr-linux-arm64.zip
unzip fankarr-linux-arm64.zip -d fankarr
cd fankarr
################
# if the "fankarr" files in inside the binaries directory use this command
# mv ./binaries/fankarr .
##################
chmod +x fankarr
./fankarr
```
+13 -4
View File
@@ -11,7 +11,8 @@ import transmissionDriver from './torrent-clients/transmission.js'
import synologyDsDriver from './torrent-clients/synology-ds.js'
import utorrentDriver from './torrent-clients/utorrent.js'
import rtorrentDriver from './torrent-clients/rtorrent.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges } from './organize.js'
import realDebridDriver from './torrent-clients/real-debrid.js'
import { autoOrganizeAll, scanMediaPath, syncFilenameChanges, migrateOrganizedEpisodeIds } from './organize.js'
import { logger } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
import { readAvailable, readInfohashMap, loadEnrichedSeriesData } from './lib/github-cache.js'
@@ -33,6 +34,7 @@ registerDriver(transmissionDriver)
registerDriver(synologyDsDriver)
registerDriver(utorrentDriver)
registerDriver(rtorrentDriver)
registerDriver(realDebridDriver)
const app = express()
const PORT = Number(process.env.PORT) || 9898
@@ -97,7 +99,10 @@ server.listen(PORT, async () => {
} catch {}
loadEnrichedSeriesData()
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
.then(async seriesData => {
await migrateOrganizedEpisodeIds(ORGANIZED_PATH, seriesData)
return scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
})
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
const autoOrganize = async () => {
@@ -144,8 +149,12 @@ server.listen(PORT, async () => {
// Le TTL du cache GitHub est de 1h — ce setInterval tire toujours sur des données fraîches.
setInterval(async () => {
try {
const seriesData = await loadEnrichedSeriesData()
const { renamed } = await syncFilenameChanges(seriesData, path.join(DATA_DIR, 'organized.json'))
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const { updated } = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
if (updated > 0)
logger.info('api', `Migration IDs auto — ${updated} ID(s) mis à jour`)
const { renamed } = await syncFilenameChanges(seriesData, organizedPath)
if (renamed > 0)
logger.info('api', `Sync noms auto — ${renamed} fichier(s) renommé(s)`)
} catch (err) {
+91 -7
View File
@@ -1,3 +1,5 @@
import path from 'path'
const FANKAI_API = 'https://metadata.fankai.fr'
export async function fankaiGet(endpoint: string): Promise<any> {
@@ -43,6 +45,77 @@ export function extractTorrentsFromSerieData(sd: any): any[] {
return result
}
/**
* Résout les noms d'un épisode en préférant les valeurs du path entry
* correspondant au hash donné (spécifique au torrent/encoding),
* avec fallback sur les champs de niveau épisode.
*
* Utilisation :
* const { formatted_name, nfo_filename, original_filename } = resolveEpNaming(ep, hash)
*/
export function resolveEpNaming(ep: any, hash?: string | null): {
formatted_name : string | null
nfo_filename : string | null
original_filename: string | null
} {
const h = hash?.toLowerCase() ?? null
const pathEntry = h
? (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === h)
: null
return {
formatted_name : pathEntry?.formatted_name ?? ep.formatted_name ?? null,
nfo_filename : pathEntry?.nfo_filename ?? ep.nfo_filename ?? null,
original_filename: pathEntry?.original_filename ?? ep.original_filename ?? null,
}
}
/**
* Calcule le nom attendu d'un fichier épisode selon le mode (NFO ou formaté).
* Source unique de vérité partagée entre organized-summary, organized/:id, rename-episode et rename-all.
*/
export function computeExpectedName(
ep : any,
orgEntry: any,
hash : string | null,
nfoSupport: boolean,
): { currentName: string; expectedName: string; needsRename: boolean } {
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
const { formatted_name: rawFmt, nfo_filename: rawNfo } = resolveEpNaming(ep, hash)
const expectedName = nfoSupport
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
return { currentName, expectedName, needsRename: expectedName !== currentName }
}
/**
* Dédoublonne une liste d'épisodes par episode_number.
* Quand plusieurs variantes existent (ex. x264 + x265), on garde la première
* comme épisode principal et on fusionne les torrents des autres dans sa liste.
*/
export function deduplicateEpisodes(episodes: any[]): any[] {
const map = new Map<number, any>()
for (const ep of episodes) {
const key = ep.episode_number
if (!map.has(key)) {
map.set(key, { ...ep, torrents: [...(ep.torrents ?? [])] })
} else {
const primary = map.get(key)!
const seenHashes = new Set(primary.torrents.map((t: any) => t.infohash).filter(Boolean))
for (const t of ep.torrents ?? []) {
if (!t.infohash || !seenHashes.has(t.infohash)) {
primary.torrents.push(t)
if (t.infohash) seenHashes.add(t.infohash)
}
}
primary.torrent = primary.torrents[0] ?? null
primary.available = primary.available || ep.available
primary.organized = primary.organized || ep.organized
}
}
return Array.from(map.values())
}
export function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = []
const h = hash.toLowerCase()
@@ -69,14 +142,19 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
const allEpisodeIds = new Set<number>()
// Dédoublonnage : épisodes uniques par (season_number, episode_number)
// pour éviter de compter x264 + x265 comme deux épisodes distincts
const uniqueEpKeys = new Set<string>()
const epIdToKey = new Map<number, string>()
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
allEpisodeIds.add(ep.id)
const key = `${season.season_number}:${ep.episode_number}`
uniqueEpKeys.add(key)
epIdToKey.set(ep.id, key)
}
}
const organizedEpisodeIds = new Set<number>()
const organizedEpKeys = new Set<string>()
for (const t of allTorrents) {
const hash = t.infohash?.toLowerCase()
@@ -85,18 +163,24 @@ export function computeSerieDownloadState(serieData: any | null, organized: Reco
if (Object.keys(orgFiles).length === 0) continue
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
const isOrganized = orgFiles[String(ep.episode_id)] !== undefined || orgFiles[ep.filename] !== undefined
if (isOrganized) organizedEpisodeIds.add(ep.episode_id)
if (isOrganized) {
const key = epIdToKey.get(ep.episode_id)
if (key) organizedEpKeys.add(key)
}
}
}
const manualOrg = organized['manual'] ?? {}
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if (manualOrg[String(ep.id)]) organizedEpisodeIds.add(ep.id)
if (manualOrg[String(ep.id)]) {
const key = epIdToKey.get(ep.id)
if (key) organizedEpKeys.add(key)
}
}
}
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
if (uniqueEpKeys.size === 0 || organizedEpKeys.size === 0) return 'none'
if (organizedEpKeys.size >= uniqueEpKeys.size) return 'complete'
return 'partial'
}
+93 -16
View File
@@ -113,13 +113,15 @@ function buildFileMap(
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
let matchedPath: string | null = null
let matchedPath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') {
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
} else if (p?.infohash?.toLowerCase() === h) {
matchedPath = p.path.replace(/\\/g, '/')
matchedPath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
}
@@ -127,8 +129,12 @@ function buildFileMap(
if (!matchedPath) continue
const filename = matchedPath.split('/').pop() ?? matchedPath
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
if (!nfoSupport && !fmtName) {
@@ -137,8 +143,8 @@ function buildFileMap(
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
map.set(filename, {
@@ -247,6 +253,26 @@ function seasonFolder(n: number): string {
return n === 0 ? 'Specials' : `Saison ${n}`
}
/**
* Supprime un fichier avec retry en cas d'EBUSY (Windows : fichier encore ouvert par qBit/autre).
* 5 tentatives × 500 ms = 2,5 s max avant de remonter l'erreur.
*/
async function unlinkWithRetry(filePath: string, maxAttempts = 5): Promise<void> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fsp.unlink(filePath)
return
} catch (err: any) {
if (err?.code === 'EBUSY' && attempt < maxAttempts - 1) {
warn(`EBUSY sur "${path.basename(filePath)}" — retry ${attempt + 1}/${maxAttempts - 1}`)
await new Promise(r => setTimeout(r, 500))
} else {
throw err
}
}
}
}
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
catch (err) {
@@ -264,7 +290,13 @@ function sanitizeDirName(name: string): string {
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
async function organizeTorrent(
hash : string,
name : string,
savePath : string,
seriesData : any[],
completedFileNames: Set<string> | null = null,
) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
@@ -289,20 +321,29 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
const ep = torrent._episode
const season = torrent._season
const paths: any[] = ep.paths ?? []
let filePath: string | null = null
let filePath : string | null = null
let matchedPathObj: any = null
for (const p of paths) {
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) {
filePath = p.path.replace(/\\/g, '/')
matchedPathObj = p
break
}
}
const filename = filePath?.split('/').pop() ?? name
const fmtName = ep.formatted_name?.trim()
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
// Nommage : priorité aux valeurs du path entry (spécifique au torrent/encoding)
const rawFmt = matchedPathObj?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathObj?.nfo_filename ?? ep.nfo_filename
const rawOriginal = matchedPathObj?.original_filename ?? ep.original_filename
const fmtName = rawFmt?.trim()
? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
: null
const srcExt = path.extname(filename)
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? filename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? filename)
? (rawNfo ?? rawOriginal ?? filename)
: (fmtName ?? rawNfo ?? rawOriginal ?? filename)
const destName = swapExtension(resolvedName, srcExt)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
@@ -310,6 +351,12 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
if (isOrganized(hash, ep.id)) return { ...result, total: 1, skipped: 1 }
// Vérification de complétion pour épisode unique
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
return { ...result, total: 1, skipped: 1 }
}
const candidates = filePath ? [
path.join(savePath, filePath),
path.join(completePath, filePath),
@@ -341,7 +388,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
episode: ep.episode_number, episode_id: ep.id,
src_filename: filename, dest_filename: destName, dest_path: dest,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
log(`${destName} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
@@ -370,6 +417,14 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
for (const [filename, { season_number, episode_number, episode_id, nfo_filename, fullPath }] of fileMap) {
if (isOrganized(hash, episode_id)) { result.skipped++; continue }
// Vérification de complétion : si on a les infos de progression par fichier,
// on skip les fichiers pas encore à 100% (ex: 1% téléchargé avant priorité appliquée).
if (completedFileNames !== null && !completedFileNames.has(filename.toLowerCase())) {
debug(`Fichier incomplet (skip) : ${filename}`)
result.skipped++
continue
}
const candidates = [
path.join(savePath, fullPath),
path.join(completePath, fullPath),
@@ -418,7 +473,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string, ser
episode: episode_number, episode_id,
src_filename: filename, dest_filename: nfo_filename, dest_path: dest,
})
await fsp.unlink(src)
await unlinkWithRetry(src)
}
result.done++
debug(`${nfo_filename} → Saison ${season_number}`)
@@ -464,7 +519,29 @@ parentPort?.on('message', async (msg: any) => {
}
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
// Construire l'ensemble des fichiers 100% complets à partir des infos de progression.
// On ne filtre QUE si le client fournit des noms de fichiers (qBittorrent, Transmission).
// Si aucun fichier n'a de nom (rtorrent…), on passe null → pas de filtrage.
let completedFileNames: Set<string> | null = null
if (Array.isArray(t.files) && t.files.length > 0) {
// Vérifier qu'au moins un fichier a un nom non vide
const hasNames = t.files.some((f: any) => f.name && String(f.name).trim())
if (hasNames) {
completedFileNames = new Set(
t.files
.filter((f: any) => (f.progress ?? 0) >= 1 && f.name && String(f.name).trim())
.map((f: any) => {
// f.name est un chemin relatif ex: "Pack S1/Episode1.mkv"
// on ne garde que le basename pour comparer au filename du fileMap
const parts = String(f.name).replace(/\\/g, '/').split('/')
return (parts[parts.length - 1] ?? '').toLowerCase()
})
.filter(Boolean)
)
}
}
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData, completedFileNames)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
+112 -4
View File
@@ -100,10 +100,17 @@ export async function scanMediaPath(
const srcFilename = p.path.replace(/\\/g, '/').split('/').pop()
if (!srcFilename) continue
idx(srcFilename, hash)
if (ep.nfo_filename) idx(ep.nfo_filename, hash, ep.nfo_filename)
// nfo_filename et formatted_name du path en priorité sur ceux de l'épisode
const pathNfo = p.nfo_filename ?? ep.nfo_filename
if (pathNfo) idx(pathNfo, hash, pathNfo)
const pathFmt = p.formatted_name ?? ep.formatted_name
if (pathFmt?.trim()) {
const fmtBase = pathFmt.replace(/[<>:"/\\|?*]/g, '').trim()
idx(fmtBase, hash, fmtBase)
}
}
// 2. original_filename, nfo_filename et formatted_name — indexés même si paths est vide
// 2. original_filename, nfo_filename et formatted_name épisode — fallback
const fallbackHash = ep.paths?.[0]?.infohash?.toLowerCase() ?? 'manual'
if (ep.original_filename) idx(ep.original_filename, fallbackHash, ep.original_filename)
if (ep.nfo_filename) idx(ep.nfo_filename, fallbackHash, ep.nfo_filename)
@@ -327,6 +334,101 @@ export async function autoOrganizeAll(
worker.postMessage({ type: 'run', torrents, seriesData })
}
// ── Migration des IDs épisodes ────────────────────────────────
//
// Les IDs d'épisodes, saisons et séries peuvent changer entre deux passages
// du scraper. On utilise (infohash, season_number, episode_number) comme clé
// stable pour retrouver le nouvel ID et mettre à jour organized.json.
export async function migrateOrganizedEpisodeIds(
organizedPath: string,
seriesData : any[]
): Promise<{ updated: number; orphaned: number }> {
// 1. Construire les tables de lookup depuis le catalogue actuel
// hashEpMap : infohash → Map<"S:E", new_episode_id>
const hashEpMap = new Map<string, Map<string, number>>()
const validIds = new Set<number>()
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
validIds.add(ep.id)
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const key = `${season.season_number}:${ep.episode_number}`
if (!hashEpMap.has(h)) hashEpMap.set(h, new Map())
hashEpMap.get(h)!.set(key, ep.id)
}
}
}
}
// 2. Charger organized.json
let organized: Organized = {}
try {
if (fs.existsSync(organizedPath))
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch { return { updated: 0, orphaned: 0 } }
let updated = 0
let orphaned = 0
let changed = false
for (const [hash, episodes] of Object.entries(organized)) {
if (hash === 'manual') continue
const epMap = hashEpMap.get(hash.toLowerCase())
const newEpisodes: Record<string, any> = {}
for (const [oldIdStr, entry] of Object.entries(episodes as Record<string, any>)) {
const oldId = Number(oldIdStr)
if (validIds.has(oldId)) {
// ID encore valide, aucun changement
newEpisodes[oldIdStr] = entry
continue
}
// ID orphelin — chercher le nouvel ID via (hash, saison, épisode)
const key = `${entry.season}:${entry.episode}`
const newId = epMap?.get(key)
if (newId && newId !== oldId) {
// Vérifier qu'on n'écrase pas une entrée déjà présente avec le nouvel ID
if (newEpisodes[String(newId)] || (episodes as any)[String(newId)]) {
// Le nouvel ID est déjà présent → l'ancien est un orphelin inutile, on le supprime simplement
changed = true
logger.info('organize', `Migration ID : ep ${oldId} orphelin supprimé (${newId} déjà présent) (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
} else {
newEpisodes[String(newId)] = { ...entry, episode_id: newId }
updated++
changed = true
logger.info('organize', `Migration ID : ep ${oldId} → ${newId} (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
} else {
// Introuvable — on conserve pour ne pas perdre l'entrée
orphaned++
newEpisodes[oldIdStr] = entry
logger.warn('organize', `Migration ID : ep ${oldId} introuvable dans le catalogue (${hash.slice(0, 8)}… S${entry.season}E${entry.episode})`)
}
}
organized[hash] = newEpisodes
// Nettoyer les hash vides
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (changed) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `Migration IDs terminée — ${updated} mis à jour${orphaned > 0 ? `, ${orphaned} non résolus` : ''}`)
} else {
logger.debug('organize', `Migration IDs — aucun changement nécessaire${orphaned > 0 ? ` (${orphaned} IDs inconnus conservés)` : ''}`)
}
return { updated, orphaned }
}
// ── Sync des noms de fichiers après mise à jour du scraper ────
export async function syncFilenameChanges(
@@ -362,9 +464,15 @@ export async function syncFilenameChanges(
? path.basename(orgEntry.dest_path)
: orgEntry.dest_filename
const srcExt = path.extname(currentName)
// Priorité aux valeurs du path entry correspondant au hash organisé
const matchedPathEntry = (ep.paths ?? []).find((p: any) =>
typeof p === 'object' && p.infohash?.toLowerCase() === orgHash
)
const rawFmt = matchedPathEntry?.formatted_name ?? ep.formatted_name
const rawNfo = matchedPathEntry?.nfo_filename ?? ep.nfo_filename
const expectedName: string = nfoSupport
? (ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
? (rawNfo ? rawNfo.replace(/\.[^.]+$/, '') + srcExt : currentName)
: (rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName)
if (expectedName === currentName) continue
+34 -39
View File
@@ -7,6 +7,7 @@ import { DATA_DIR } from '../config.js'
import { readSettings } from '../settings.js'
import { getGitlabTitle } from '../gitlab-map.js'
import { readSerieData, loadEnrichedSeriesData } from '../lib/github-cache.js'
import { resolveEpNaming, computeExpectedName } from '../lib/serie-helpers.js'
import { GITLAB_API_NFO, GITLAB_RAW_NFO } from '../lib/nfo.js'
import { dispatchRemove } from '../torrent-clients/index.js'
@@ -51,10 +52,11 @@ router.post('/manual-import', requireAuth, async (req, res) => {
const { ep, season } = found
const srcFilename = path.basename(file_path)
const srcExt = path.extname(srcFilename)
const fmtName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const { formatted_name: rawFmt, nfo_filename: rawNfo, original_filename: rawOrig } = resolveEpNaming(ep, hash)
const fmtName = rawFmt?.trim() ? rawFmt.replace(/[<>:"/\\|?*]/g, '') + '.mkv' : null
const resolvedName = nfoSupport
? (ep.nfo_filename ?? ep.original_filename ?? srcFilename)
: (fmtName ?? ep.nfo_filename ?? ep.original_filename ?? srcFilename)
? (rawNfo ?? rawOrig ?? srcFilename)
: (fmtName ?? rawNfo ?? rawOrig ?? srcFilename)
const resolvedExt = path.extname(resolvedName)
const destFilename = resolvedExt && resolvedExt !== srcExt
? resolvedName.slice(0, -resolvedExt.length) + srcExt
@@ -137,19 +139,26 @@ router.get('/organized/:serieId', requireAuth, async (req, res) => {
try {
const sd = await readSerieData(serieId)
if (!sd) { res.status(404).json({ error: 'Série introuvable' }); return }
const organized = loadOrganizedJson()
const organized = loadOrganizedJson()
const { nfoSupport } = readSettings()
const result: Record<string, any> = {}
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
let entry: any = null
let entryHash = 'manual'
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const hash = p.infohash.toLowerCase()
const entry = (organized[hash] ?? {})[String(ep.id)]
if (entry) { result[String(ep.id)] = entry; break }
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { entry = e; entryHash = h; break }
}
if (!result[String(ep.id)] && organized['manual']?.[String(ep.id)]) {
result[String(ep.id)] = organized['manual'][String(ep.id)]
if (!entry && organized['manual']?.[String(ep.id)]) {
entry = organized['manual'][String(ep.id)]
}
if (!entry) continue
const { needsRename } = computeExpectedName(ep, entry, entryHash, nfoSupport)
result[String(ep.id)] = { ...entry, hash: entryHash, needs_rename: needsRename }
}
}
res.json(result)
@@ -184,15 +193,8 @@ router.post('/rename-episode', requireAuth, async (req, res) => {
}
}
if (!orgEntry) { res.status(404).json({ error: 'Épisode non importé' }); return }
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
let newName: string
if (nfoSupport) {
newName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
newName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (newName === currentName) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const { currentName, expectedName: newName, needsRename } = computeExpectedName(ep, orgEntry, entryHash, nfoSupport)
if (!needsRename) { res.json({ ok: true, renamed: false, message: 'Nom déjà correct' }); return }
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), newName)
try {
@@ -332,20 +334,20 @@ router.get('/organized-summary', requireAuth, async (_req, res) => {
for (const ep of season.episodes ?? []) {
let orgEntry: any = null
let orgHash: string | null = null
for (const [hash, eps] of Object.entries(organized)) {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
// Même stratégie que organized/:serieId : on cherche via ep.paths d'abord
// pour garantir le bon hash → bon formatted_name dans resolveEpNaming
for (const p of ep.paths ?? []) {
if (typeof p !== 'object' || !p.infohash) continue
const h = p.infohash.toLowerCase()
const e = (organized[h] ?? {})[String(ep.id)]
if (e) { orgEntry = e; orgHash = h; break }
}
if (!orgEntry && organized['manual']?.[String(ep.id)]) {
orgEntry = organized['manual'][String(ep.id)]; orgHash = 'manual'
}
if (!orgEntry) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
const needsRename = expectedName !== currentName
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
const fileExists = orgEntry.dest_path ? fs.existsSync(orgEntry.dest_path) : false
episodes.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, title: ep.title, current_name: currentName, expected_name: expectedName, dest_path: orgEntry.dest_path, torrent_hash: orgHash, needs_rename: needsRename, file_exists: fileExists })
}
}
@@ -378,15 +380,8 @@ router.post('/rename-all', requireAuth, async (req, res) => {
if (eps[String(ep.id)]) { orgEntry = eps[String(ep.id)]; orgHash = hash; break }
}
if (!orgEntry || !orgHash) continue
const currentName = orgEntry.dest_path ? path.basename(orgEntry.dest_path) : orgEntry.dest_filename
const srcExt = path.extname(currentName)
let expectedName: string
if (nfoSupport) {
expectedName = ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : currentName
} else {
expectedName = ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : currentName
}
if (expectedName === currentName) continue
const { currentName, expectedName, needsRename } = computeExpectedName(ep, orgEntry, orgHash, nfoSupport)
if (!needsRename) continue
const oldPath = orgEntry.dest_path
const newPath = path.join(path.dirname(oldPath), expectedName)
try {
+15 -1
View File
@@ -1,12 +1,26 @@
import { Router } from 'express'
import { requireAuth } from '../auth.js'
import { logger } from '../logger.js'
import { organizeTorrent } from '../organize.js'
import { organizeTorrent, migrateOrganizedEpisodeIds } from '../organize.js'
import { loadEnrichedSeriesData } from '../lib/github-cache.js'
import { recentOrganized, pushNotif } from '../lib/notifs.js'
import { DATA_DIR } from '../config.js'
import path from 'path'
const router = Router()
router.post('/organize/migrate-ids', requireAuth, async (_req, res) => {
try {
const seriesData = await loadEnrichedSeriesData()
const organizedPath = path.join(DATA_DIR, 'organized.json')
const result = await migrateOrganizedEpisodeIds(organizedPath, seriesData)
res.json({ ok: true, ...result })
} catch (err) {
logger.error('api', `Migration IDs échouée : ${err instanceof Error ? err.message : err}`)
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
router.get('/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
+8 -4
View File
@@ -10,6 +10,7 @@ import { readAvailable, readInfohashMap, readSerieData } from '../lib/github-cac
import {
fankaiGet, normalizeSerie, normalizeSeason, normalizeEpisode,
extractTorrentsFromSerieData, buildResolvedEpisodes, computeSerieDownloadState,
deduplicateEpisodes,
} from '../lib/serie-helpers.js'
const router = Router()
@@ -40,7 +41,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serieRaw, seasonsData, serieData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`), readSerieData(id)])
const serie = normalizeSerie(serieRaw)
const serie = { ...normalizeSerie(serieRaw), wiki: serieData?.wiki ?? null }
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
@@ -99,7 +100,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
for (const t of (ep.torrents ?? [])) {
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
const pathEntry = (ep.paths ?? []).find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null })
episodeTorrentMap[ep.id].push({ torrent_url: t.torrent_url, magnet: t.magnet, infohash: t.infohash?.toLowerCase() ?? null, type: 'episode', raw: t.title, torrent_name: t.torrent_name ?? null, manual: t.manual ?? false, fankai: t.fankai ?? true, file_index: pathEntry?.file_index ?? null, file_path: pathEntry?.path ?? null, formatted_name: pathEntry?.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const isOrg = orgFiles[String(ep.id)] !== undefined
@@ -124,7 +125,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
if (!pack) continue
seenPackHashes.add(hashKey)
if (!episodeTorrentMap[ep.id]) episodeTorrentMap[ep.id] = []
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null })
episodeTorrentMap[ep.id].push({ torrent_url: pack.torrent_url, magnet: pack.magnet, infohash: hashKey, type: pack.type, raw: pack.raw, torrent_name: pack.torrent_name ?? null, manual: false, fankai: pack.fankai, file_index: pathEntry.file_index ?? null, file_path: pathEntry.path ?? null, formatted_name: pathEntry.formatted_name ?? ep.formatted_name ?? null })
availableEpisodeIds.add(ep.id)
}
}
@@ -133,7 +134,7 @@ router.get('/series/:id', requireAuth, async (req, res) => {
}
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => {
const rawEps = season.episodes.map((ep: any) => {
const epTorrents = episodeTorrentMap[ep.id] ?? []
const epTorrent = epTorrents[0] ?? null
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
@@ -160,6 +161,9 @@ router.get('/series/:id', requireAuth, async (req, res) => {
organized: organizedEpisodeIds.has(ep.id),
}
})
// Dédoublonnage : plusieurs variantes (x264/x265…) du même episode_number
// sont fusionnées en un seul épisode avec la liste de torrents combinée
const eps = deduplicateEpisodes(rawEps)
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const epTotal = eps.length
+27 -1
View File
@@ -36,7 +36,9 @@ export interface SavedClient {
export interface TorrentFileProgress {
index : number
progress: number // 0–100
name ?: string // chemin relatif dans le torrent (ex: "Pack S1/Episode1.mkv") — optionnel selon le client
progress: number // 0–1
priority: number // 0 = do not download, 1 = normal, 6 = high, 7 = max
}
export interface TorrentInfo {
@@ -70,6 +72,7 @@ export interface TorrentClientDriver {
add : (config: Record<string, string | number>, url: string, options?: DownloadOptions) => Promise<void>
list : (config: Record<string, string | number>, category?: string) => Promise<TorrentInfo[]>
remove : (config: Record<string, string | number>, hash: string, deleteFiles?: boolean) => Promise<void>
getFiles ?: (config: Record<string, string | number>, hash: string) => Promise<TorrentFileProgress[]>
}
// ─── Registry ─────────────────────────────────────────────────────────────────
@@ -341,4 +344,27 @@ export async function dispatchList(
}
return results
}
// ─── Dispatch file progress ────────────────────────────────────────────────────
// Retourne la liste des fichiers d'un torrent avec leur progression (0–1).
// clientUuid optionnel : si fourni, interroge uniquement ce client.
export async function dispatchGetFiles(
hash : string,
clientUuid?: string,
): Promise<TorrentFileProgress[]> {
const clients = clientUuid
? loadClients().filter(c => c.uuid === clientUuid)
: loadClients()
for (const client of clients) {
const driver = getDriver(client.type)
if (!driver?.getFiles) continue
try {
const files = await driver.getFiles(client.config, hash)
if (files.length > 0) return files
} catch {}
}
return []
}
+86 -40
View File
@@ -1,5 +1,6 @@
/**
* qBittorrent Driver
* Supports qBittorrent 4.x and 5.x (including 5.2.0+ API key authentication)
*/
import type { TorrentClientDriver, TorrentInfo, DownloadOptions } from './index.js'
@@ -14,6 +15,15 @@ function mapState(state: string): TorrentInfo['state'] {
return 'unknown'
}
// Retourne les headers d'authentification : Bearer (≥5.2.0) ou cookie SID
async function qbAuth(config: Record<string, string | number>): Promise<Record<string, string>> {
if (config.apiKey) {
return { Authorization: `Bearer ${config.apiKey}` }
}
const cookie = await qbLogin(config)
return { Cookie: cookie }
}
async function qbLogin(config: Record<string, string | number>): Promise<string> {
const form = new URLSearchParams()
form.append('username', String(config.username ?? ''))
@@ -26,18 +36,17 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
if (!res.ok) throw new Error(`Login échoué : ${text.trim() || res.status}`)
const cookie = res.headers.get('set-cookie') ?? ''
const sid = cookie.match(/SID=([^;]+)/)?.[1]
if (!sid) throw new Error('Cookie SID introuvable')
return sid
const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0].trim()
if (!cookie) throw new Error('Cookie de session introuvable')
return cookie
}
async function qbTorrentExists(config: Record<string, string | number>, sid: string, hash: string): Promise<boolean> {
async function qbTorrentExists(config: Record<string, string | number>, authHeaders: Record<string, string>, hash: string): Promise<boolean> {
try {
const res = await fetch(`${config.url}/api/v2/torrents/info?hashes=${hash}`, {
headers: { Cookie: `SID=${sid}` },
headers: authHeaders,
})
if (!res.ok) return false
const list = await res.json()
@@ -57,13 +66,13 @@ async function qbApplyFilePriority(
fileIndex: number,
resume : boolean,
): Promise<void> {
// 60 tentatives × 500 ms = 30 s max
// Première tentative rapide (100 ms), puis 500 ms — 60 tentatives max ≈ 30 s
for (let attempt = 0; attempt < 60; attempt++) {
await new Promise(r => setTimeout(r, 500))
await new Promise(r => setTimeout(r, attempt === 0 ? 100 : 500))
try {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) continue
const files: any[] = await res.json()
@@ -79,7 +88,7 @@ async function qbApplyFilePriority(
// on se contente d'activer la nouvelle cible sans toucher aux autres.
const isInitialState = files.every((f: any) => (f.priority ?? 1) > 0)
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
if (isInitialState) {
const unwanted = files.map((_, i) => i).filter(i => i !== fileIndex)
@@ -99,7 +108,7 @@ async function qbApplyFilePriority(
if (!r2.ok) { logger.warn('qbittorrent', `filePrio activation échouée (${r2.status}) — retry`); continue }
// Vérification : le fichier cible est bien à priorité > 0
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: { Cookie: `SID=${sid}` } })
const verif = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, { headers: authH })
if (verif.ok) {
const verifiedFiles: any[] = await verif.json()
const target = verifiedFiles[fileIndex]
@@ -129,8 +138,8 @@ async function qbApplyFilePriority(
// Timeout : reprendre quand même pour ne pas laisser le torrent bloqué
if (resume) {
try {
const sid = await qbLogin(config)
const headers = { Cookie: `SID=${sid}`, 'Content-Type': 'application/x-www-form-urlencoded' }
const authH = await qbAuth(config)
const headers = { ...authH, 'Content-Type': 'application/x-www-form-urlencoded' }
const body = new URLSearchParams({ hashes: hash })
await Promise.allSettled([
fetch(`${config.url}/api/v2/torrents/resume`, { method: 'POST', body, headers }),
@@ -148,8 +157,9 @@ const QB: TorrentClientDriver = {
label : 'qBittorrent',
fields: [
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
{ key: 'apiKey', label: 'Clé API (≥5.2.0)', type: 'password', placeholder: 'qbt_xxxx…', required: false },
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: false },
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
{ key: 'savePath', label: 'Dossier cible', type: 'text', placeholder: '/downloads/fankai', required: false },
{ key: 'remotePath', label: 'Chemin distant (client)', type: 'text', placeholder: '/downloads', required: false },
@@ -159,7 +169,9 @@ const QB: TorrentClientDriver = {
async test(config) {
try {
await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, { headers: authH })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
return { ok: true, message: 'Connexion réussie' }
} catch (err) {
@@ -171,9 +183,9 @@ const QB: TorrentClientDriver = {
async healthcheck(config) {
try {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/app/version`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) return { online: false }
const version = (await res.text()).trim()
@@ -186,11 +198,11 @@ const QB: TorrentClientDriver = {
},
async list(config, category) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const params = new URLSearchParams()
if (category) params.set('category', category)
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
const data: any[] = await res.json()
@@ -200,7 +212,7 @@ const QB: TorrentClientDriver = {
await Promise.all(data.map(async (t: any) => {
try {
const r = await fetch(`${config.url}/api/v2/torrents/files?hash=${t.hash}`, {
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (r.ok) fileMap.set(t.hash, await r.json())
} catch {}
@@ -223,14 +235,34 @@ const QB: TorrentClientDriver = {
save_path : t.save_path,
category : t.category ?? '',
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({ index: i, progress: Math.round((f.progress ?? 0) * 100) }))
? fileInfo.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0, // 0–1
priority: f.priority ?? 1,
}))
: undefined,
} satisfies TorrentInfo
})
},
async getFiles(config, hash) {
const authH = await qbAuth(config)
const res = await fetch(`${config.url}/api/v2/torrents/files?hash=${hash}`, {
headers: authH,
})
if (!res.ok) return []
const data: any[] = await res.json()
return data.map((f: any, i: number) => ({
index : i,
name : String(f.name ?? ''),
progress: f.progress ?? 0,
priority: f.priority ?? 1,
}))
},
async add(config, url, options?: DownloadOptions) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
// Priorité : infohash direct (depuis les données API) > parsing du magnet/URL
const hash = (options?.infohash?.toLowerCase() ?? null)
@@ -240,7 +272,7 @@ const QB: TorrentClientDriver = {
if (options?.file_index != null) {
if (hash) {
const exists = await qbTorrentExists(config, sid, hash)
const exists = await qbTorrentExists(config, authH, hash)
if (exists) {
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… déjà présent, mise à jour priorité fichier ${options.file_index}`)
qbApplyFilePriority(config, hash, options.file_index, false).catch(err =>
@@ -254,29 +286,43 @@ const QB: TorrentClientDriver = {
let knownHashes: Set<string> = new Set()
if (!hash) {
try {
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${sid}` } })
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: authH })
if (lr.ok) knownHashes = new Set((await lr.json()).map((t: any) => String(t.hash).toLowerCase()))
} catch {}
}
// Ajout du torrent en pause immédiate (toutes versions qBit)
// paused=true → qBit < 5.0
// stopped=true → qBit ≥ 5.0
// Stratégie d'ajout selon le type de lien :
// .torrent URL → FanKarr télécharge le fichier et l'uploade directement à qBit
// (qBit n'a pas forcément accès à internet / nyaa.si depuis son réseau Docker)
// magnet link → passage de l'URL brute, ajout normal sans pause
// (les métadonnées arrivent via les pairs, la pause bloquerait tout)
const isMagnet = url.startsWith('magnet:')
const form = new FormData()
form.append('urls', url)
if (config.category) form.append('category', String(config.category))
if (config.savePath) form.append('savepath', String(config.savePath))
form.append('paused', 'true')
form.append('stopped', 'true')
if (isMagnet) {
form.append('urls', url)
} else {
// FanKarr télécharge le .torrent et l'envoie directement à qBit
logger.debug('qbittorrent', `Téléchargement .torrent via FanKarr : ${url.slice(0, 120)}`)
const torrentRes = await fetch(url, { headers: { 'User-Agent': 'FanKarr/1.0' } })
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le .torrent (${torrentRes.status}) : ${url}`)
const torrentBytes = await torrentRes.arrayBuffer()
form.append('torrents', new Blob([torrentBytes], { type: 'application/x-bittorrent' }), 'torrent.torrent')
// Pause : métadonnées déjà dans le fichier → liste de fichiers disponible immédiatement
form.append('paused', 'true') // qBit < 5.0
form.append('stopped', 'true') // qBit ≥ 5.0
}
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
method: 'POST', body: form, headers: authH,
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
if (hash) {
logger.info('qbittorrent', `Torrent ajouté (en pause, sélection fichier ${options.file_index} en attente)`)
logger.info('qbittorrent', `Torrent ajouté (${isMagnet ? 'magnet, actif' : 'en pause'}) — sélection fichier ${options.file_index} en attente des métadonnées`)
qbApplyFilePriority(config, hash, options.file_index, true).catch(err =>
logger.warn('qbittorrent', `Priorité fichier non appliquée : ${err instanceof Error ? err.message : err}`)
)
@@ -288,8 +334,8 @@ const QB: TorrentClientDriver = {
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const s = await qbLogin(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: { Cookie: `SID=${s}` } })
const ah = await qbAuth(config)
const lr = await fetch(`${config.url}/api/v2/torrents/info`, { headers: ah })
if (!lr.ok) continue
const newT = (await lr.json()).find((t: any) => !knownHashes.has(String(t.hash).toLowerCase()))
if (!newT) continue
@@ -312,7 +358,7 @@ const QB: TorrentClientDriver = {
if (config.savePath) form.append('savepath', String(config.savePath))
const res = await fetch(`${config.url}/api/v2/torrents/add`, {
method: 'POST', body: form, headers: { Cookie: `SID=${sid}` },
method: 'POST', body: form, headers: authH,
})
const text = await res.text()
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
@@ -320,14 +366,14 @@ const QB: TorrentClientDriver = {
},
async remove(config, hash, deleteFiles = false) {
const sid = await qbLogin(config)
const authH = await qbAuth(config)
const form = new FormData()
form.append('hashes', hash)
form.append('deleteFiles', deleteFiles ? 'true' : 'false')
const res = await fetch(`${config.url}/api/v2/torrents/delete`, {
method : 'POST',
body : form,
headers: { Cookie: `SID=${sid}` },
headers: authH,
})
if (!res.ok) throw new Error(`Suppression échouée : ${res.status}`)
logger.info('qbittorrent', `Torrent ${hash.slice(0, 8)}… supprimé${deleteFiles ? ' (avec fichiers)' : ''}`)
+254
View File
@@ -0,0 +1,254 @@
/**
* Real-Debrid Driver
*
* Real-Debrid est un service debrid cloud : les torrents téléchargent sur leurs serveurs,
* puis les fichiers sont accessibles via des liens directs (HTTPS).
*
* Workflow FanKarr :
* 1. add() → POST /torrents/addMagnet puis selectFiles si file_index fourni
* 2. list() → GET /torrents (filtre par status=downloaded pour state='seeding')
* 3. remove() → DELETE /torrents/delete/{id}
*
* Le save_path de chaque torrent est construit à partir du localPath configuré
* (dossier où l'utilisateur télécharge ses fichiers RD via FUSE, WebDAV ou autre).
*/
import type { TorrentClientDriver, TorrentInfo, TorrentFileProgress, DownloadOptions } from './index.js'
import { logger } from '../logger.js'
const RD_BASE = 'https://api.real-debrid.com/rest/1.0'
function rdHeaders(config: Record<string, string | number>) {
return {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
}
}
async function rdFetch(
config : Record<string, string | number>,
method : 'GET' | 'POST' | 'DELETE',
path : string,
body ?: URLSearchParams,
): Promise<any> {
const res = await fetch(`${RD_BASE}${path}`, {
method,
headers: rdHeaders(config),
body : body,
})
if (res.status === 204) return null
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`Real-Debrid HTTP ${res.status} : ${text}`)
}
return res.json()
}
function mapStatus(status: string): TorrentInfo['state'] {
switch (status) {
case 'downloaded': return 'seeding'
case 'downloading':
case 'queued':
case 'compressing':
case 'uploading':
case 'magnet_conversion': return 'downloading'
case 'waiting_files_selection': return 'paused'
case 'magnet_error':
case 'error':
case 'virus':
case 'dead': return 'error'
default: return 'unknown'
}
}
const rdDriver: TorrentClientDriver = {
definition: {
id : 'real-debrid',
label : 'Real-Debrid',
fields: [
{ key: 'apiKey', label: 'Clé API', type: 'password', required: true },
{ key: 'localPath', label: 'Dossier local (FUSE/WebDAV/…)', type: 'text', placeholder: '/mnt/real-debrid', required: false },
],
},
async test(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
logger.info('real-debrid', `Connexion réussie — utilisateur : ${user.username}`)
return { ok: true, message: `Connecté en tant que ${user.username}` }
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
logger.warn('real-debrid', `Test de connexion échoué : ${msg}`)
return { ok: false, message: msg }
}
},
async healthcheck(config) {
try {
const user = await rdFetch(config, 'GET', '/user')
return { online: true, version: `RD — ${user.username}` }
} catch {
return { online: false }
}
},
async add(config, url, options?: DownloadOptions) {
const isMagnet = url.startsWith('magnet:')
const hash = (options?.infohash?.toLowerCase() ?? null)
|| url.match(/xt=urn:btih:([a-fA-F0-9]{40,})/i)?.[1]?.toLowerCase()
|| null
// Vérifier si le torrent est déjà présent sur RD
if (hash) {
try {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const existing = list.find((t: any) => t.hash?.toLowerCase() === hash)
if (existing) {
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… déjà présent sur RD`)
if (options?.file_index != null && existing.status === 'waiting_files_selection') {
const files: any[] = await rdFetch(config, 'GET', `/torrents/info/${existing.id}`)
.then((info: any) => info.files ?? [])
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${existing.id}`,
new URLSearchParams({ files: String(fileId) }))
}
}
return
}
} catch {}
}
let torrentId: string
if (isMagnet) {
const data = await rdFetch(config, 'POST', '/torrents/addMagnet',
new URLSearchParams({ magnet: url }))
torrentId = data.id
logger.info('real-debrid', `Magnet ajouté → ID ${torrentId}`)
} else {
// URL de fichier .torrent : télécharger puis uploader
const torrentRes = await fetch(url)
if (!torrentRes.ok) throw new Error(`Impossible de télécharger le fichier .torrent : ${torrentRes.status}`)
const torrentBuf = await torrentRes.arrayBuffer()
const formData = new FormData()
formData.append('torrent', new Blob([torrentBuf], { type: 'application/x-bittorrent' }), 'torrent.torrent')
const uploadRes = await fetch(`${RD_BASE}/torrents/addTorrent`, {
method : 'PUT',
headers: { Authorization: `Bearer ${config.apiKey}` },
body : formData,
})
if (!uploadRes.ok) throw new Error(`Ajout .torrent échoué : ${uploadRes.status}`)
const data = await uploadRes.json()
torrentId = data.id
logger.info('real-debrid', `.torrent uploadé → ID ${torrentId}`)
}
// Sélection des fichiers
if (options?.file_index != null) {
// Attendre que la liste des fichiers soit disponible (état waiting_files_selection)
let files: any[] = []
for (let attempt = 0; attempt < 20; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection' && Array.isArray(info.files) && info.files.length > 0) {
files = info.files
break
}
if (info.status === 'downloading' || info.status === 'downloaded') break
} catch {}
}
if (files.length > 0 && options.file_index < files.length) {
const fileId = files[options.file_index]?.id
if (fileId) {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: String(fileId) }))
logger.info('real-debrid', `Fichier ${options.file_index} sélectionné sur torrent RD ${torrentId}`)
}
} else if (files.length > 0) {
// Sélectionner tous les fichiers si index hors limites
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
}
} else {
// Pas de sélection spécifique : tous les fichiers
// Attendre waiting_files_selection pour envoyer "all"
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise(r => setTimeout(r, 500))
try {
const info = await rdFetch(config, 'GET', `/torrents/info/${torrentId}`)
if (info.status === 'waiting_files_selection') {
await rdFetch(config, 'POST', `/torrents/selectFiles/${torrentId}`,
new URLSearchParams({ files: 'all' }))
break
}
if (info.status !== 'magnet_conversion') break
} catch {}
}
}
},
async list(config, _category?) {
const torrents: any[] = await rdFetch(config, 'GET', '/torrents')
const localBase = String(config.localPath ?? '').replace(/\/+$/, '')
return torrents.map((t: any): TorrentInfo => {
const progress = typeof t.progress === 'number' ? t.progress / 100 : 0
// Construire un save_path "virtuel" à partir du localPath configuré + nom du torrent
const save_path = localBase ? `${localBase}/${t.filename ?? t.id}` : ''
// Les liens sont les fichiers téléchargés (disponibles si status=downloaded)
const files: TorrentFileProgress[] | undefined =
Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? progress : 0,
}))
: undefined
return {
hash : String(t.hash ?? t.id ?? '').toLowerCase(),
name : String(t.filename ?? t.id ?? ''),
state : mapStatus(t.status ?? ''),
progress : Math.round(progress * 100),
size : typeof t.bytes === 'number' ? t.bytes : 0,
downloaded: typeof t.bytes === 'number' ? Math.round(t.bytes * progress) : 0,
uploaded : 0,
ratio : 0,
speed : typeof t.speed === 'number' ? t.speed : 0,
upspeed : 0,
eta : -1,
save_path,
category : '',
files,
}
})
},
async remove(config, hash, _deleteFiles?) {
// Retrouver l'ID RD depuis le hash
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) throw new Error(`Torrent ${hash.slice(0, 8)}… introuvable sur Real-Debrid`)
await rdFetch(config, 'DELETE', `/torrents/delete/${target.id}`)
logger.info('real-debrid', `Torrent ${hash.slice(0, 8)}… supprimé de Real-Debrid`)
},
async getFiles(config, hash) {
const list: any[] = await rdFetch(config, 'GET', '/torrents')
const target = list.find((t: any) => String(t.hash ?? t.id ?? '').toLowerCase() === hash.toLowerCase())
if (!target) return []
const info = await rdFetch(config, 'GET', `/torrents/info/${target.id}`)
const overallProgress = typeof info.progress === 'number' ? info.progress / 100 : 0
if (!Array.isArray(info.files)) return []
return info.files.map((f: any, i: number) => ({
index : i,
name : String(f.path ?? f.name ?? ''),
progress: f.selected ? overallProgress : 0,
}))
},
}
export default rdDriver
+4 -2
View File
@@ -244,7 +244,7 @@ const RT: TorrentClientDriver = {
await Promise.all(rows.map(async (r: any) => {
const hash = String(r[0]).toUpperCase()
try {
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes='])
const files = await rpcCall(config, 'f.multicall', [hash, '', 'f.completed_length=', 'f.size_bytes=', 'f.path='])
if (Array.isArray(files) && files.length > 0) fileMap.set(hash.toLowerCase(), files)
} catch {}
}))
@@ -277,7 +277,9 @@ const RT: TorrentClientDriver = {
files : fileInfo.length > 0
? fileInfo.map((f: any, i: number) => ({
index : i,
progress: Number(f[1]) > 0 ? Math.round((Number(f[0]) / Number(f[1])) * 100) : 0,
name : String(f[2] ?? ''),
progress: Number(f[1]) > 0 ? Number(f[0]) / Number(f[1]) : 0,
priority: 1,
}))
: undefined,
} satisfies TorrentInfo
+2 -1
View File
@@ -161,7 +161,8 @@ const TR: TorrentClientDriver = {
files : Array.isArray(t.files) && t.files.length > 0
? t.files.map((f: any, i: number) => ({
index : i,
progress: f.length > 0 ? Math.round((f.bytesCompleted / f.length) * 100) : 0,
name : String(f.name ?? ''),
progress: f.length > 0 ? f.bytesCompleted / f.length : 0,
}))
: undefined,
} satisfies TorrentInfo))
+15 -3
View File
@@ -9,9 +9,9 @@
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-main" />
</div>
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
<div class="relative px-4 md:px-8 pt-6 pb-10 flex items-start gap-6 min-h-[200px]">
<!-- Poster -->
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<div class="shrink-0 w-32 aspect-[2/3] rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
<img v-if="serie.poster_image" :src="serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
<Tv :size="28" />
@@ -47,6 +47,18 @@
>
{{ serie.plot }}
</p>
<!-- Lien wiki -->
<a
v-if="serie.wiki"
:href="serie.wiki"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors w-fit"
>
<ExternalLink :size="12" />
Wiki
</a>
</div>
</div>
</div>
@@ -55,7 +67,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ArrowLeft, Tv } from 'lucide-vue-next'
import { ArrowLeft, Tv, ExternalLink } from 'lucide-vue-next'
defineProps<{ serie: any }>()
const expanded = ref(false)
+74 -7
View File
@@ -149,7 +149,7 @@
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
>Hors Fankai</span>
</div>
<div class="flex items-center gap-2 mt-0.5">
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
<!-- Badge langue -->
@@ -175,6 +175,18 @@
<svg width="14" height="10" viewBox="0 0 14 10" class="rounded-[1px] shrink-0"><rect width="14" height="10" fill="#ED2939"/><rect width="9.33" height="10" fill="#fff"/><rect width="4.67" height="10" fill="#002395"/></svg>
VOSTFR
</span>
<!-- Badge épisodes -->
<span
v-if="parseEpChap(ep.plot).episodes"
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-violet-500/10 border-violet-500/20 text-violet-300 select-none"
:title="`Plage d'épisodes couverts`"
>{{ parseEpChap(ep.plot).episodes }}</span>
<!-- Badge chapitres -->
<span
v-if="parseEpChap(ep.plot).chapters"
class="shrink-0 text-[10px] font-medium leading-none px-1.5 py-0.5 rounded border bg-teal-500/10 border-teal-500/20 text-teal-300 select-none"
:title="`Plage de chapitres manga couverts`"
>{{ parseEpChap(ep.plot).chapters }}</span>
</div>
</div>
@@ -247,7 +259,7 @@
<!-- Synopsis -->
<div v-if="plotOpen === ep.id && ep.plot" class="mt-2 sm:pl-[92px]">
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ ep.plot }}</p>
<p class="text-xs text-muted leading-relaxed" style="white-space: pre-line">{{ parseEpChap(ep.plot).cleanPlot }}</p>
</div>
</div>
</div>
@@ -429,8 +441,8 @@ function epProgress(ep: any): ActiveTorrent | null {
// Avec données par fichier on se fie à la progression réelle du fichier précis.
// Si le fichier est à 100% sans session active → déjà téléchargé hors session
// (ex : désimporté puis re-consulté) → ne pas afficher le spinner.
if (file != null && (sessionDl || (file.progress > 0 && file.progress < 100))) {
return { ...active, progress: file.progress }
if (file != null && file.priority !== 0 && (sessionDl || (file.progress > 0 && file.progress < 1))) {
return { ...active, progress: Math.round(file.progress * 100) }
}
if (!sessionDl) continue
} else {
@@ -491,14 +503,69 @@ function epExpectedName(ep: any): string {
if (props.nfoSupport) {
return ep.nfo_filename ? ep.nfo_filename.replace(/\.[^.]+$/, '') + srcExt : entry.dest_filename
} else {
return ep.formatted_name?.trim() ? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
// Préférer le formatted_name du torrent qui correspond au fichier importé.
// On essaie de matcher par hash (si l'entrée organized l'expose), sinon on
// cherche le premier torrent qui a un formatted_name.
const usedHash = entry.hash?.toLowerCase() ?? null
const matchedName = usedHash
? (ep.torrents ?? []).find((t: any) => t.infohash === usedHash)?.formatted_name
: null
const name = matchedName
?? (ep.torrents ?? []).find((t: any) => t.formatted_name)?.formatted_name
?? ep.formatted_name
return name?.trim() ? name.replace(/[<>:"/\\|?*]/g, '').trim() + srcExt : entry.dest_filename
}
}
function epNeedsRename(ep: any): boolean {
const entry = props.organizedByEpisode[String(ep.id)]
if (!entry) return false
const expected = epExpectedName(ep)
return !!expected && expected !== entry.dest_filename
return entry.needs_rename === true
}
// ── Extraction épisodes / chapitres depuis le synopsis ─────────
// Cache léger pour éviter de recalculer pour chaque accès template (badges + synopsis)
const _epChapCache = new Map<string | null | undefined, ReturnType<typeof _parseEpChap>>()
/**
* Extrait les plages d'épisodes et chapitres depuis un synopsis.
* Ex: "...épisodes 628 à 634 soit les chapitres 700 à 706."
* → { episodes: "628–634", chapters: "700–706", cleanPlot: "..." }
* La phrase (ou ligne) contenant ces infos est retirée du synopsis affiché.
*/
function _parseEpChap(plot: string | null | undefined): {
episodes: string | null
chapters: string | null
cleanPlot: string
} {
const text = plot ?? ''
const epMatch = text.match(/[éeÉE]pisodes?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const chapMatch = text.match(/chapitres?\s+(\d+)(?:\s+[àa]\s+(\d+))?/i)
const episodes = epMatch
? (epMatch[2] ? `Ép. ${epMatch[1]}–${epMatch[2]}` : `Ép. ${epMatch[1]}`)
: null
const chapters = chapMatch
? (chapMatch[2] ? `Ch. ${chapMatch[1]}–${chapMatch[2]}` : `Ch. ${chapMatch[1]}`)
: null
// Retirer les lignes contenant les références d'épisodes ou chapitres
let cleanPlot = text
if (episodes || chapters) {
cleanPlot = text
.split(/\r?\n/)
.filter(line => !/[éeÉE]pisodes?\s+\d+|chapitres?\s+\d+/i.test(line))
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
return { episodes, chapters, cleanPlot }
}
function parseEpChap(plot: string | null | undefined) {
if (_epChapCache.has(plot)) return _epChapCache.get(plot)!
const result = _parseEpChap(plot)
_epChapCache.set(plot, result)
return result
}
// ── Détection langue ───────────────────────────────────────────
+1 -1
View File
@@ -16,7 +16,7 @@
</Transition>
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0 relative z-10">
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
<Menu :size="20" />
</button>
+1 -1
View File
@@ -285,7 +285,7 @@ async function fetchActiveDownloads() {
const key = `${t.hash}:${f.index}`
const prevProg = _prevFileProgresses.get(key)
_prevFileProgresses.set(key, f.progress)
if (!isFirstPoll && prevProg !== undefined && prevProg < 100 && f.progress === 100 && !_recentlyOrganized.has(key)) {
if (!isFirstPoll && prevProg !== undefined && prevProg < 1 && f.progress >= 1 && !_recentlyOrganized.has(key)) {
_recentlyOrganized.add(key)
triggerOrganize(t)
}
+1 -1
View File
@@ -1 +1 @@
3.12.1
3.15.0