Compare commits

...
136 Commits
Author SHA1 Message Date
MasutaYunikon 10432e0f99 Merge pull request #51 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.1
2026-03-23 05:32:19 +01:00
MasutaYunikon 4f69f8bf92 chore(main): release 2.1.1 2026-03-23 05:32:01 +01:00
MasutaYunikon 1511dd3ca3 Merge remote-tracking branch 'origin/main' 2026-03-23 05:31:29 +01:00
MasutaYunikon 23967a81d6 fix: torrent path to hash 2026-03-23 05:31:20 +01:00
MasutaYunikon 21ff025376 Merge pull request #50 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.0
2026-03-23 05:00:49 +01:00
MasutaYunikon 9ba19f9539 chore(main): release 2.1.0 2026-03-23 05:00:34 +01:00
MasutaYunikon 215d802b12 fix: remove logo
Release-As: 2.1.0
2026-03-23 05:00:05 +01:00
MasutaYunikon 21dccbe5d9 Merge remote-tracking branch 'origin/main'
Release-As: 2.1.0
2026-03-23 04:56:45 +01:00
MasutaYunikon 329150f1c4 fix(organize): migration vers paths { infohash, path } pour le matching fichiers
BREAKING CHANGE: le format paths[] dans series/{id}.json passe de string[]
à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.

fix(organize): buildFileMap matche par infohash au lieu d'index ou titre

L'ancien système utilisait soit un index positionnel fragile, soit le titre
du torrent comme clé de matching — les deux cassaient si l'utilisateur
renommait le torrent dans qBittorrent ou si un pack intégrale ne couvrait
pas tous les épisodes. Désormais chaque path est lié à un infohash précis.

fix(organize): source fichier via save_path qBittorrent + fullPath relatif

path.join(savePath, fullPath) utilise le save_path retourné par qBittorrent
au lieu de deviner le nom du dossier depuis le titre du torrent. Résiste
aux renommages de dossier côté client.

fix(organize): scanMediaPath adapté au nouveau format paths[]

L'index filename → infohash est maintenant construit directement depuis
ep.paths[].infohash sans passer par la hiérarchie torrents série/saison/épisode.
2026-03-23 04:52:33 +01:00
MasutaYunikon 17f7234b49 Merge pull request #48 from Masutayunikon/release-please--branches--main
chore(main): release 2.0.0
2026-03-23 04:05:16 +01:00
MasutaYunikon 8e529d3845 chore(main): release 2.0.0 2026-03-23 04:04:52 +01:00
MasutaYunikon a3f3539a27 Merge remote-tracking branch 'origin/main' 2026-03-23 04:04:23 +01:00
MasutaYunikon d5c37e8f2c feat!: migration vers nouvelle architecture API et scraper par série
BREAKING CHANGE: suppression de torrent_final.json au profit d'un système
de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h.
Version majeure — nécessite un nouveau scraper et une mise à jour complète.

feat(api): cache mémoire GitHub TTL 6h avec invalidation via /api/update

Plus de fichiers locaux dans /config pour les torrents. Les données sont
fetchées depuis GitHub (available.json + series/{id}.json) et mises en
cache 6h. /api/update vide le cache pour forcer le rechargement.

feat(api): adaptation complète à la nouvelle structure API Fankai

- normalizeSerie/Season/Episode pour aplatir images.poster → poster_image
- Proxy wsrv.nl pour redimensionner les images (poster 300px, fanart 1280px,
  thumbnail 400px) et réduire le temps de chargement
- duration en secondes → affichage en minutes dans SerieView

feat(organize): refonte complète sans torrent_final.json

- organize-worker.ts reçoit seriesData via message au lieu de lire un fichier
- buildFileMap construit filename→{season_number,original_filename} depuis paths[]
- enrichSeriesDataWithOriginalFilenames fetche /seasons/{id}/episodes avant
  de passer les données au worker pour le renommage correct des fichiers
- Suppression de SERIE_TITLE_GITLAB_MAP (Kaï→Kai résolu côté API)
- scanMediaPath adapté : plus de torrentsPath, prend seriesData en paramètre

feat(api): matching torrents par hiérarchie série/saison/épisode

- Torrents niveau série → pack_integrale
- Torrents niveau saison → pack_saison (indexé par season_number)
- Torrents niveau épisode → episode
- Matching via paths[torrentIdx] pour résoudre episode↔fichier

feat(series-view): badge "Hors Fankai" sur épisodes de torrents manuels
feat(series-view): tooltip raw name sur boutons intégrale via :title
feat(scraper): extraction nom bencode dans raw pour torrents locaux
fix(series): boutons saison Yu-Gi-Oh via fallback season_number
fix(catalogue): état download basé sur épisodes organisés vs torrents bruts

Release-As: 2.0.0
2026-03-23 04:04:12 +01:00
MasutaYunikon a5f5dfb0da Merge pull request #47 from Masutayunikon/release-please--branches--main
chore(main): release 1.14.0
2026-03-21 08:01:19 +01:00
MasutaYunikon 08531c80f3 chore(main): release 1.14.0 2026-03-21 07:59:16 +01:00
MasutaYunikon 96ce207027 Merge remote-tracking branch 'origin/main' 2026-03-21 07:58:54 +01:00
MasutaYunikon 13f9a27af8 fix(series): résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires
Les boutons "Saison entière" n'apparaissaient pas pour les séries dont les
season_id du scraper ne correspondent pas aux season_id de l'API Fankai.

- Ajouter seasonTorrentMapBySn indexé par season_number en fallback
- Utiliser season_number comme clé de secours dans enrichedSeasons

fix(catalogue): état "partiel" incorrect quand l'intégrale est organisée

Le badge partial s'affichait même quand tous les épisodes étaient organisés
via un pack_integrale, car les torrents d'épisodes séparés faisaient baisser
le ratio. La logique est maintenant basée sur les episode_id uniques.

feat(series-view): badge "Hors Fankai" sur les épisodes de torrents manuels

Les épisodes issus de MANUAL_TORRENTS (ex: MHA Film 4) sont maintenant
marqués d'un badge violet "Hors Fankai" avec tooltip explicatif.

- Propager manual: true du torrent dans le TorrentRef de l'épisode
- Afficher le badge dans SerieView.vue si ep.torrent?.manual

feat(series-view): tooltip avec le nom du torrent sur les boutons intégrale

Quand plusieurs intégrales existent pour une série, survoler le bouton
affiche le raw name du torrent via :title="t.raw".

feat(scraper): extraction du nom bencode pour les torrents locaux

Le champ raw des LOCAL_TORRENTS est maintenant renseigné depuis info.name
extrait directement du fichier .torrent via bencode, au lieu d'utiliser
la clé du dictionnaire LOCAL_TORRENTS.

- fetch_torrent_files_from_local retourne maintenant (files, infohash, name)
- torrent["raw"] est mis à jour avec le vrai nom bencode
2026-03-21 07:58:45 +01:00
MasutaYunikon d0d40c02b5 Merge pull request #46 from Masutayunikon/release-please--branches--main
chore(main): release 1.13.0
2026-03-21 04:21:36 +01:00
MasutaYunikon 0e541a19ba chore(main): release 1.13.0 2026-03-21 04:21:09 +01:00
MasutaYunikon 1d71c40a2b Merge pull request #45 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.2
2026-03-21 04:20:11 +01:00
MasutaYunikon adc3e91122 chore(main): release 1.12.2 2026-03-21 04:18:31 +01:00
MasutaYunikon b260208701 Merge remote-tracking branch 'origin/main' 2026-03-21 04:18:09 +01:00
MasutaYunikon 3dd517c37f fix: use season number from resolved episodes in priority 2026-03-21 04:17:59 +01:00
MasutaYunikon 93397bee77 Merge pull request #44 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.1
2026-03-21 04:00:35 +01:00
MasutaYunikon 3f72c01911 chore(main): release 1.12.1 2026-03-21 03:59:32 +01:00
MasutaYunikon 77e6a2e38c Merge remote-tracking branch 'origin/main' 2026-03-21 03:59:09 +01:00
MasutaYunikon bef99183aa fix: path 2026-03-21 03:58:55 +01:00
MasutaYunikon 7b84229510 Merge pull request #43 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.0
2026-03-21 03:45:31 +01:00
MasutaYunikon 23c46a621e chore(main): release 1.12.0 2026-03-21 03:45:10 +01:00
MasutaYunikon 4dc7255bb0 Merge remote-tracking branch 'origin/main' 2026-03-21 03:44:46 +01:00
MasutaYunikon 180513c98a feat: now rename episodes with original_filename from api 2026-03-21 03:44:37 +01:00
MasutaYunikon 00894bc069 Merge pull request #42 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.10
2026-03-21 03:18:52 +01:00
MasutaYunikon a1b128640d chore(main): release 1.11.10 2026-03-21 03:18:32 +01:00
MasutaYunikon 8bec4c62fa fix: improve torrent organization and cleanup logic 2026-03-21 03:18:09 +01:00
MasutaYunikon a7ba6a54a3 Merge remote-tracking branch 'origin/main' 2026-03-21 02:14:03 +01:00
MasutaYunikon 33d0b9a52d fix: season 0 not clonficting anymore 2026-03-21 02:13:52 +01:00
MasutaYunikon 46546d0178 Merge pull request #41 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.9
2026-03-21 00:09:13 +01:00
MasutaYunikon 756ced0001 chore(main): release 1.11.9 2026-03-20 22:03:02 +01:00
MasutaYunikon 2addb39ba3 Merge remote-tracking branch 'origin/main' 2026-03-20 22:02:40 +01:00
MasutaYunikon 232ede4292 fix: season 0 resolved is no longer skipped in the worker 2026-03-20 22:02:17 +01:00
MasutaYunikon f161e84a1d Merge pull request #40 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.8
2026-03-20 20:31:54 +01:00
MasutaYunikon 4d4a8029f6 chore(main): release 1.11.8 2026-03-20 20:31:00 +01:00
MasutaYunikon 2a1dcddd90 Merge remote-tracking branch 'origin/main' 2026-03-20 20:30:33 +01:00
MasutaYunikon 4d319f97bc fix: config path /config/config to /config 2026-03-20 20:30:23 +01:00
MasutaYunikon 3372d4cb40 Merge pull request #39 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.7
2026-03-20 20:18:44 +01:00
MasutaYunikon eb22bb669f chore(main): release 1.11.7 2026-03-20 20:18:19 +01:00
MasutaYunikon 0d64a66977 Merge remote-tracking branch 'origin/main' 2026-03-20 20:17:54 +01:00
MasutaYunikon b0e81166e1 fix: worker path 2026-03-20 20:17:46 +01:00
MasutaYunikon 79d7aa7485 Merge pull request #38 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.6
2026-03-20 17:30:18 +01:00
MasutaYunikon af995a84cb chore(main): release 1.11.6 2026-03-20 17:29:09 +01:00
MasutaYunikon 5a71bdca3c Merge remote-tracking branch 'origin/main' 2026-03-20 17:28:46 +01:00
MasutaYunikon 77d918fbe0 fix: path 2026-03-20 17:28:09 +01:00
MasutaYunikon a0b255338d Merge pull request #37 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.5
2026-03-20 17:01:44 +01:00
MasutaYunikon e0b09c144f chore(main): release 1.11.5 2026-03-20 17:00:37 +01:00
MasutaYunikon 418d09b07f Merge remote-tracking branch 'origin/main' 2026-03-20 17:00:07 +01:00
MasutaYunikon 4c7936b019 fix: export _isBunBinary 2026-03-20 16:59:58 +01:00
MasutaYunikon 77f42b5b3e Merge pull request #36 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.4
2026-03-20 16:57:17 +01:00
MasutaYunikon bd69f7a730 chore(main): release 1.11.4 2026-03-20 16:55:47 +01:00
MasutaYunikon 3ce6523248 Merge remote-tracking branch 'origin/main' 2026-03-20 16:55:16 +01:00
MasutaYunikon 2efed9dcfc fix: add config.ts with config variable (BASE_DIR...) 2026-03-20 16:55:03 +01:00
MasutaYunikon 88e2a0bc38 Merge pull request #35 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.3
2026-03-20 16:08:46 +01:00
MasutaYunikon 338e3f8547 chore(main): release 1.11.3 2026-03-20 15:41:14 +01:00
MasutaYunikon d1ccc902da Merge remote-tracking branch 'origin/main' 2026-03-20 15:40:40 +01:00
MasutaYunikon 5e54e24b60 fix: entrypoint.sh config directory 2026-03-20 15:40:28 +01:00
MasutaYunikon aae2f592b5 Merge pull request #34 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.2
2026-03-20 02:11:37 +01:00
MasutaYunikon bd94e6a460 chore(main): release 1.11.2 2026-03-20 02:11:01 +01:00
MasutaYunikon cbe3e1a786 Merge remote-tracking branch 'origin/main' 2026-03-20 02:10:38 +01:00
MasutaYunikon 7a0917e053 fix: port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve
- Port par défaut 3001 → 9898
- Volume /app/data → /config (entrypoint, docker-compose, README)
- Normalisation titres Kaï/Kai dans index.ts (catalogue + déduplication + serie detail)
- Fix pagination GitLab NFO (per_page=100 → loop multi-pages)
- Fix _is_bonus_filename avec word boundaries (\b) — évite faux positifs "national"→"ona"
- Fix dossier Bonus/Specials exclus : récupère flottants (7,5/19,5) et S00Exx → saison 0
- _EP_FLOAT_PATTERN supporte virgules (7,5) en plus des points (7.5)
- Ajout strict_season dans find_episode/resolve_ep — évite collision ep1 S1 vs ep1 S2 (JoJo Parties)
- Ajout SEASON_EP_OFFSET pour Hokuto No Ken (saison 02 torrent → +16 offset API)
- Fallback non-strict si strict_season échoue (Hokuto ep 5-16 S1)
- Fix collect_covered_episodes : fallback torrent.serie_id si absent dans resolved_episodes
- Fix s6_verify : get_all_specials + compteur S00 séparé, déduplication Kaï/Kai, format OK/TOTAL
- Ajout MANUAL_TORRENTS dans s4 (MHA Film 4 You're Next URL Nyaa)
- Injection torrents locaux manquants dans pipeline (Reborn! Kaï via fichier local)
- Fix s5 : résolution directe torrents manuels season_number=0
- Fix s2 : pack_saga_slash et pack_partie_slash → pack_integrale, lookahead (?<!/) sur episode_num_tiret_apres
- Fix s5 skip condition : re-résoudre si resolved_count < files_count (Fairy Tail)
- README : note hardlinks volumes multiples, port, chemins config
2026-03-20 02:10:27 +01:00
MasutaYunikon 8ac4f7a65b Merge pull request #33 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.1
2026-03-14 00:59:14 +01:00
MasutaYunikon 453cd47e85 chore(main): release 1.11.1 2026-03-14 00:58:57 +01:00
MasutaYunikon d5679ec529 Merge remote-tracking branch 'origin/main' 2026-03-14 00:58:37 +01:00
MasutaYunikon 2f0be502c3 fix: worker now work on multiple github files pages 2026-03-14 00:58:29 +01:00
MasutaYunikon ec971915b5 Merge pull request #32 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.0
2026-03-13 19:03:34 +01:00
MasutaYunikon bb71b241d1 chore(main): release 1.11.0 2026-03-13 19:03:16 +01:00
MasutaYunikon 7aa01755ec Merge remote-tracking branch 'origin/main' 2026-03-13 19:02:46 +01:00
MasutaYunikon 8ce0ca1f24 feat: fix worker for multiple series in integral (one piece fix ?) 2026-03-13 19:02:38 +01:00
MasutaYunikon 620aac6b38 Merge pull request #31 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.3
2026-03-13 04:35:00 +01:00
MasutaYunikon e99e413e4e chore(main): release 1.10.3 2026-03-13 04:34:21 +01:00
MasutaYunikon fb578cf9f9 Merge remote-tracking branch 'origin/main' 2026-03-13 04:34:00 +01:00
MasutaYunikon 78e3055344 fix: worker path 2026-03-13 04:33:51 +01:00
MasutaYunikon 15f5847c97 Merge pull request #30 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.2
2026-03-13 04:25:54 +01:00
MasutaYunikon 8736b58d8c chore(main): release 1.10.2 2026-03-13 04:25:39 +01:00
MasutaYunikon 12e8e2f93a Merge remote-tracking branch 'origin/main' 2026-03-13 04:25:17 +01:00
MasutaYunikon ed2306bb70 fix: add target node 2026-03-13 04:25:09 +01:00
MasutaYunikon b3fd57c2c8 Merge pull request #29 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.1
2026-03-13 04:22:24 +01:00
MasutaYunikon c2da12409a chore(main): release 1.10.1 2026-03-13 04:22:06 +01:00
MasutaYunikon 178a57c444 Merge remote-tracking branch 'origin/main' 2026-03-13 04:21:42 +01:00
MasutaYunikon 1afe13c555 fix: add target node 2026-03-13 04:21:34 +01:00
MasutaYunikon c8963cc4cf Merge pull request #28 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.0
2026-03-13 04:19:01 +01:00
MasutaYunikon 391b793e39 chore(main): release 1.10.0 2026-03-13 04:18:23 +01:00
MasutaYunikon 9a1519e6cd Merge pull request #27 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.5
2026-03-13 04:17:27 +01:00
MasutaYunikon a12237af68 chore(main): release 1.9.5 2026-03-13 04:16:50 +01:00
MasutaYunikon d304aa6a26 Merge remote-tracking branch 'origin/main' 2026-03-13 04:16:29 +01:00
MasutaYunikon 27092b3968 fix: worker outside the exe 2026-03-13 04:16:07 +01:00
MasutaYunikon c1c36018b1 Merge pull request #26 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.4
2026-03-13 04:12:38 +01:00
MasutaYunikon b462ec717b chore(main): release 1.9.4 2026-03-13 04:11:56 +01:00
MasutaYunikon 65518861ac Merge remote-tracking branch 'origin/main' 2026-03-13 04:11:36 +01:00
MasutaYunikon ed75280e70 fix: worker outside the exe 2026-03-13 04:11:27 +01:00
MasutaYunikon 9f53a9fef5 Merge pull request #25 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.3
2026-03-13 04:02:39 +01:00
MasutaYunikon 3e5dc8d1d4 chore(main): release 1.9.3 2026-03-13 04:02:06 +01:00
MasutaYunikon bcb7f656a1 Merge remote-tracking branch 'origin/main' 2026-03-13 04:01:43 +01:00
MasutaYunikon 372359d040 fix: use bcryptjs for compilation 2026-03-13 04:01:34 +01:00
MasutaYunikon 63847c583a Merge pull request #24 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.2
2026-03-13 03:52:42 +01:00
MasutaYunikon e90f85af70 chore(main): release 1.9.2 2026-03-13 03:52:30 +01:00
MasutaYunikon 0c683d4e37 Merge remote-tracking branch 'origin/main' 2026-03-13 03:52:10 +01:00
MasutaYunikon 16b28004cf fix: workflow directory 2026-03-13 03:52:02 +01:00
MasutaYunikon 558f4e2a1b Merge pull request #23 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.1
2026-03-13 03:47:53 +01:00
MasutaYunikon 352c18029f chore(main): release 1.9.1 2026-03-13 03:47:33 +01:00
MasutaYunikon 465d3dba8b fix: workflow directory 2026-03-13 03:47:09 +01:00
MasutaYunikon 7b82d60c69 Merge remote-tracking branch 'origin/main' 2026-03-13 03:43:01 +01:00
MasutaYunikon af03aecf0d fix: workflow permmisions 2026-03-13 03:42:50 +01:00
MasutaYunikon f878298cef Merge pull request #22 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.0
2026-03-13 03:39:58 +01:00
MasutaYunikon fc5dd1d36d chore(main): release 1.9.0 2026-03-13 03:39:04 +01:00
MasutaYunikon 1b73e7d0a3 Merge remote-tracking branch 'origin/main' 2026-03-13 03:38:43 +01:00
MasutaYunikon 8030bf14f9 feat: add windows / linux / macos binaries on build 2026-03-13 03:38:34 +01:00
MasutaYunikon 908a8b6076 Merge pull request #21 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.2
2026-03-13 02:52:27 +01:00
MasutaYunikon 0d2708ca09 chore(main): release 1.8.2 2026-03-13 02:52:12 +01:00
MasutaYunikon fb7e443c23 Merge remote-tracking branch 'origin/main' 2026-03-13 02:51:50 +01:00
MasutaYunikon 8fab68a0e4 fix: season fallback 2026-03-13 02:51:41 +01:00
MasutaYunikon 0c06941452 Merge pull request #20 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.1
2026-03-13 02:34:56 +01:00
MasutaYunikon 8b8c5cc930 chore(main): release 1.8.1 2026-03-13 02:20:57 +01:00
MasutaYunikon 1dc01a101f Merge remote-tracking branch 'origin/main' 2026-03-13 02:20:36 +01:00
MasutaYunikon 054dcb915a fix: nfo settings and add json file when not existing 2026-03-13 02:20:26 +01:00
MasutaYunikon d0c876b885 Merge pull request #19 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.0
2026-03-13 00:26:18 +01:00
MasutaYunikon ce0bf1b8ab chore(main): release 1.8.0 2026-03-13 00:25:54 +01:00
MasutaYunikon 014c7bbbc3 Merge remote-tracking branch 'origin/main' 2026-03-13 00:25:34 +01:00
MasutaYunikon f3bcc68104 feat: cache images 2026-03-13 00:25:24 +01:00
MasutaYunikon a8978c1a43 Merge pull request #18 from Masutayunikon/release-please--branches--main
chore(main): release 1.7.3
2026-03-13 00:13:58 +01:00
MasutaYunikon 40632c8b06 chore(main): release 1.7.3 2026-03-13 00:12:12 +01:00
MasutaYunikon 71d3c557e9 fix: CatalogView.vue badge deleted 2026-03-13 00:11:45 +01:00
MasutaYunikon 0825cb2f44 Merge remote-tracking branch 'origin/main' 2026-03-12 23:56:24 +01:00
MasutaYunikon cec376ade6 fix: empty docker-compose.yml 2026-03-12 23:56:16 +01:00
MasutaYunikon f01a37dfd8 Merge pull request #17 from Masutayunikon/release-please--branches--main
chore(main): release 1.7.2
2026-03-12 23:52:47 +01:00
MasutaYunikon c9adcb5abc chore(main): release 1.7.2 2026-03-12 23:51:10 +01:00
MasutaYunikon 3dbb0757c4 Merge remote-tracking branch 'origin/main' 2026-03-12 23:50:46 +01:00
MasutaYunikon 8417aa98ca fix: docker is able to change directory 2026-03-12 23:50:17 +01:00
25 changed files with 1695 additions and 983 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ node_modules
.git
.gitignore
*.md
data
config
.env
.env.*
!.env.example
+119
View File
@@ -0,0 +1,119 @@
name: Build Binaries
on:
release:
types: [created]
workflow_dispatch:
jobs:
build-frontend:
name: Build Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Vue frontend
run: pnpm build
- name: Upload frontend dist
uses: actions/upload-artifact@v4
with:
name: frontend-dist
path: dist/client/
retention-days: 1
build-binaries:
name: Build ${{ matrix.target }}
needs: build-frontend
runs-on: ${{ matrix.os }}
permissions:
contents: write
strategy:
matrix:
include:
- target: windows-x64
os: windows-latest
bun-target: bun-windows-x64
binary: binaries/fankarr.exe
archive: fankarr-windows-x64.zip
- target: linux-x64
os: ubuntu-latest
bun-target: bun-linux-x64
binary: binaries/fankarr
archive: fankarr-linux-x64.zip
- target: macos
os: macos-latest
bun-target: bun-darwin-arm64
binary: binaries/fankarr
archive: fankarr-macos.zip
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: pnpm/action-setup@v3
with:
version: latest
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download frontend dist
uses: actions/download-artifact@v4
with:
name: frontend-dist
path: public/
- name: Build binary (Unix)
if: matrix.target != 'windows-x64'
run: |
mkdir -p binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
- name: Build binary (Windows)
if: matrix.target == 'windows-x64'
run: |
New-Item -ItemType Directory -Force -Path binaries
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
shell: pwsh
- name: Build worker
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
- name: Package archive (Windows)
if: matrix.target == 'windows-x64'
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
shell: pwsh
- name: Package archive (Unix)
if: matrix.target != 'windows-x64'
run: |
chmod +x ${{ matrix.binary }}
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
- name: Upload archive to Release
uses: softprops/action-gh-release@v2
with:
files: ${{ matrix.archive }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
binaries
# Editor directories and files
.vscode/*
+368
View File
@@ -1,5 +1,373 @@
# Changelog
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
### Bug Fixes
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
### ⚠ BREAKING CHANGES
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
### Bug Fixes
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
### ⚠ BREAKING CHANGES
* suppression de torrent_final.json au profit d'un système de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h. Version majeure — nécessite un nouveau scraper et une mise à jour complète.
### Features
* **api:** adaptation complète à la nouvelle structure API Fankai ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **api:** cache mémoire GitHub TTL 6h avec invalidation via /api/update ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **api:** matching torrents par hiérarchie série/saison/épisode ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* migration vers nouvelle architecture API et scraper par série ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **organize:** refonte complète sans torrent_final.json ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **scraper:** extraction nom bencode dans raw pour torrents locaux ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series-view:** badge "Hors Fankai" sur épisodes de torrents manuels ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series-view:** tooltip raw name sur boutons intégrale via :title ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
### Bug Fixes
* **catalogue:** état download basé sur épisodes organisés vs torrents bruts ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
* **series:** boutons saison Yu-Gi-Oh via fallback season_number ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
### Features
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
### Bug Fixes
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
### Bug Fixes
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
### Bug Fixes
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
### Bug Fixes
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
### Features
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
### Bug Fixes
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
### Bug Fixes
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
### Bug Fixes
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
### Bug Fixes
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
### Bug Fixes
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
### Bug Fixes
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
### Bug Fixes
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
### Bug Fixes
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
### Bug Fixes
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
### Bug Fixes
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
### Features
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
### Bug Fixes
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
### Bug Fixes
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
## [1.10.1](https://github.com/Masutayunikon/FanKarr/compare/v1.10.0...v1.10.1) (2026-03-13)
### Bug Fixes
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
## [1.10.0](https://github.com/Masutayunikon/FanKarr/compare/v1.9.5...v1.10.0) (2026-03-13)
### Features
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
### Bug Fixes
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.9.5](https://github.com/Masutayunikon/FanKarr/compare/v1.9.4...v1.9.5) (2026-03-13)
### Bug Fixes
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
## [1.9.4](https://github.com/Masutayunikon/FanKarr/compare/v1.9.3...v1.9.4) (2026-03-13)
### Bug Fixes
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
## [1.9.3](https://github.com/Masutayunikon/FanKarr/compare/v1.9.2...v1.9.3) (2026-03-13)
### Bug Fixes
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
### Bug Fixes
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
## [1.9.1](https://github.com/Masutayunikon/FanKarr/compare/v1.9.0...v1.9.1) (2026-03-13)
### Bug Fixes
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
## [1.9.0](https://github.com/Masutayunikon/FanKarr/compare/v1.8.2...v1.9.0) (2026-03-13)
### Features
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
## [1.8.2](https://github.com/Masutayunikon/FanKarr/compare/v1.8.1...v1.8.2) (2026-03-13)
### Bug Fixes
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
## [1.8.1](https://github.com/Masutayunikon/FanKarr/compare/v1.8.0...v1.8.1) (2026-03-13)
### Bug Fixes
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
## [1.8.0](https://github.com/Masutayunikon/FanKarr/compare/v1.7.3...v1.8.0) (2026-03-12)
### Features
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
## [1.7.3](https://github.com/Masutayunikon/FanKarr/compare/v1.7.2...v1.7.3) (2026-03-12)
### Bug Fixes
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
## [1.7.2](https://github.com/Masutayunikon/FanKarr/compare/v1.7.1...v1.7.2) (2026-03-12)
### Bug Fixes
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
## [1.7.1](https://github.com/Masutayunikon/FanKarr/compare/v1.7.0...v1.7.1) (2026-03-12)
+1 -1
View File
@@ -24,7 +24,7 @@ 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
RUN mkdir -p /config
EXPOSE 3001
ENV NODE_ENV=production
+53 -30
View File
@@ -2,7 +2,6 @@
# Fankarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version)
![Docker Image Size](https://img.shields.io/docker/image-size/masutayunikon/fankarr/latest?style=flat-square&color=1e2d3d&label=image)
![Docker Pulls](https://img.shields.io/docker/pulls/masutayunikon/fankarr?style=flat-square&color=1e2d3d)
@@ -11,7 +10,7 @@
![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?style=flat-square)
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
</div>
@@ -69,14 +68,9 @@ Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour**
## Installation
### Prérequis
### Docker Compose (recommandé)
- Docker + Docker Compose
- qBittorrent accessible en réseau
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
### Docker Compose
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
```yaml
services:
@@ -88,11 +82,10 @@ services:
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./data:/app/data # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
- ./config:/config # Config, logs, base de données
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
ports:
- 3001:3001
- 9898:9898
restart: unless-stopped
```
@@ -100,21 +93,51 @@ services:
docker compose up -d
```
Fankarr sera accessible sur `http://localhost:3001`.
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme le font Radarr et Sonarr.
>
> Vous pouvez tout à fait monter plusieurs volumes si vous le souhaitez, mais dans ce cas les hardlinks ne seront pas possibles entre deux volumes différents et Fankarr basculera automatiquement en copie.
### Binaire autonome (Windows / Linux / macOS)
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive |
| --------------------- | ------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` |
| Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
```
fankarr-windows-x64/
├── fankarr.exe ← lancer ce fichier
└── public/ ← ne pas déplacer séparément
```
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
```bash
chmod +x fankarr
./fankarr
```
Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
### Variables d'environnement
| Variable | Défaut | Description |
|---|---|---|
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
| Variable | Défaut | Description |
| ---------------- | ------------ | ----------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage
1. Ouvrir `http://localhost:3001`
1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath`
@@ -130,7 +153,7 @@ Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes
```
/media/Kai/
└── Black Lagoon Henshū/
└── Saison 001/
└── Saison 1/
├── Black Lagoon Henshū S01E01.mkv
├── Black Lagoon Henshū S01E02.mkv
└── ...
@@ -152,13 +175,13 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
## Stack technique
| Couche | Technologie |
|---|---|
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Packaging | Docker (`node:20-slim`) + pnpm |
| Permissions | gosu (PUID/PGID) |
| Couche | Technologie |
| ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) |
---
+14
View File
@@ -0,0 +1,14 @@
fankarr:
image: masutayunikon/fankarr:latest
container_name: fankarr
environment:
- PUID=1000 # UID de votre utilisateur (id -u)
- PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone
volumes:
- ./config:/config # Config, logs, base de données
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
ports:
- 9898:9898
restart: unless-stopped
+1 -1
View File
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data
chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js
+10 -5
View File
@@ -7,12 +7,16 @@
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
"dev:server": "tsx watch server/index.ts",
"dev:client": "vite",
"build": "vite build --outDir dist/client && tsc -p tsconfig.server.json",
"build": "vite build && tsc -p tsconfig.server.json",
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
"build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
"start": "node dist/server/index.js",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
@@ -20,10 +24,10 @@
"pinia": "^3.0.4",
"tailwindcss": "^4.2.1",
"vue": "^3.5.25",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"bcryptjs": "^3.0.3"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9",
@@ -34,6 +38,7 @@
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
"vue-tsc": "^3.1.5",
"@types/bcrypt": "^5.0.2"
}
}
+7 -22
View File
@@ -11,9 +11,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.2.1
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
bcrypt:
specifier: ^6.0.0
version: 6.0.0
bcryptjs:
specifier: ^3.0.3
version: 3.0.3
cookie-parser:
specifier: ^1.4.7
version: 1.4.7
@@ -650,9 +650,9 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -1087,10 +1087,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-addon-api@8.6.0:
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
engines: {node: ^18 || ^20 || >= 21}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -1100,10 +1096,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -1919,10 +1911,7 @@ snapshots:
'@babel/parser': 7.29.0
ast-kit: 2.2.0
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
bcryptjs@3.0.3: {}
birpc@2.9.0: {}
@@ -2351,8 +2340,6 @@ snapshots:
negotiator@1.0.0: {}
node-addon-api@8.6.0: {}
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -2361,8 +2348,6 @@ snapshots:
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-gyp-build@4.8.4: {}
object-inspect@1.13.4: {}
on-finished@2.4.1:
+3 -2
View File
@@ -1,11 +1,12 @@
import fs from 'fs'
import path from 'path'
import bcrypt from 'bcrypt'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10
interface AuthData {
+13
View File
@@ -0,0 +1,13 @@
import fs from 'fs'
import path from 'path'
export const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
export const DATA_DIR = fs.existsSync('/.dockerenv')
? '/config'
: path.join(BASE_DIR, 'config')
+436 -204
View File
@@ -12,21 +12,21 @@ import {
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
registerDriver(qbittorrentDriver)
const app = express()
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/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
const PORT = Number(process.env.PORT) || 9898
const FANKAI_API = 'https://metadata.fankai.fr'
const GITHUB_BASE = process.env.GITHUB_BASE
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
app.use(express.json())
app.use(cookieParser())
// ── Static frontend (prod) ─────────────────────────────────────
const PUBLIC_PATH = path.join(process.cwd(), 'public')
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
if (fs.existsSync(PUBLIC_PATH)) {
app.use(express.static(PUBLIC_PATH))
}
@@ -42,8 +42,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
res.json(readSettings())
})
app.post('/api/settings', requireAuth, (req, res) => {
const { mediaPath, completePath, organizeMode, category } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
const { mediaPath, completePath, organizeMode, category, nfoSupport } = req.body
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
})
// ── Torrent clients ────────────────────────────────────────────
@@ -86,71 +86,289 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
res.json(await driver.healthcheck(client.config))
})
// ── Helpers ────────────────────────────────────────────────────
function readTorrents(): any[] {
// ── Cache GitHub ───────────────────────────────────────────────
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures
interface CacheEntry<T> { data: T; expiresAt: number }
const _cache = new Map<string, CacheEntry<any>>()
function cacheGet<T>(key: string): T | null {
const entry = _cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
return entry.data as T
}
function cacheSet<T>(key: string, data: T): void {
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
}
function cacheClear(): void { _cache.clear() }
async function githubGet(urlPath: string): Promise<any> {
const key = urlPath
const cached = cacheGet(key)
if (cached) return cached
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
const data = await res.json()
cacheSet(key, data)
return data
}
async function readAvailable(): Promise<number[]> {
try {
if (!fs.existsSync(TORRENTS_PATH)) return []
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
return await githubGet('available.json') as number[]
} catch { return [] }
}
async function readSerieData(serieId: number): Promise<any | null> {
try {
return await githubGet(`series/${serieId}.json`)
} catch { return null }
}
// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique
// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... }
function extractTorrentsFromSerieData(sd: any): any[] {
const result: any[] = []
if (!sd) return result
// Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison)
for (const t of sd.torrents ?? []) {
result.push({
...t,
raw : t.title,
type : 'pack_integrale',
serie_id : sd.id,
serie_title: sd.title,
// episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i]
_serieLevel: true,
_serieData : sd,
_torrentIdx: (sd.torrents ?? []).indexOf(t),
})
}
// Torrents au niveau saison → pack_saison
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
result.push({
...t,
raw : t.title,
type : 'pack_saison',
serie_id : sd.id,
serie_title : sd.title,
season_id : season.id,
season_number: season.season_number,
_seasonLevel : true,
_season : season,
_torrentIdx : (season.torrents ?? []).indexOf(t),
})
}
// Torrents au niveau épisode → episode
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
result.push({
...t,
raw : t.title,
type : 'episode',
serie_id : sd.id,
serie_title : sd.title,
season_id : season.id,
season_number: season.season_number,
episode_id : ep.id,
episode_number: ep.episode_number,
_episodeLevel: true,
_episode : ep,
_torrentIdx : (ep.torrents ?? []).indexOf(t),
})
}
}
}
return result
}
// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx]
// Construit les épisodes résolus pour un torrent donné par son infohash
function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
const resolved: any[] = []
const h = hash.toLowerCase()
for (const season of sd.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const paths: any[] = ep.paths ?? []
// Nouveau format { infohash, path } ou legacy string
let filePath: string | null = null
for (const p of paths) {
if (typeof p === 'string') {
if (!filePath) filePath = p // legacy fallback
} else if (p?.infohash?.toLowerCase() === h) {
filePath = p.path
break
}
}
if (!filePath) continue
const filename = filePath.split('/').pop() ?? filePath
resolved.push({
episode_id : ep.id,
episode_number : ep.episode_number,
season_number : season.season_number,
season_id : season.id,
filename,
original_filename: filename,
})
}
}
return resolved
}
async function fankaiGet(endpoint: string): Promise<any> {
const res = await fetch(`${FANKAI_API}${endpoint}`)
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json()
}
function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>()
for (const t of torrents) {
if (!t.serie_id) continue
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
map.get(t.serie_id)!.push(t)
}
return map
function normalizeTitle(s: string): string {
return s.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
// ── Series ─────────────────────────────────────────────────────
function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
if (!url) return null
const stripped = url.replace(/^https?:\/\//, '')
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
}
function normalizeSerie(serie: any): any {
const poster = serie.poster_image ?? serie.images?.poster ?? null
const fanart = serie.fanart_image ?? serie.images?.fanart ?? null
return {
...serie,
poster_image: imgProxy(poster, 300),
fanart_image: imgProxy(fanart, 1280, 60),
}
}
function normalizeSeason(season: any): any {
const poster = season.poster_image ?? season.images?.poster ?? null
return {
...season,
poster_image: imgProxy(poster, 300),
}
}
function normalizeEpisode(ep: any): any {
const thumb = ep.thumb_image ?? ep.thumbnail ?? null
return {
...ep,
thumb_image: imgProxy(thumb, 400),
}
}
// ── Enrichissement seriesData avec original_filename depuis l'API ──────────────
// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename
// dans chaque épisode du serieData, indexé par episode_id
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
return Promise.all(seriesData.map(async (sd) => {
if (!sd) return sd
const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => {
let epsData: any[] = []
try {
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
} catch { return season }
// Index episode_id → original_filename
const origMap = new Map<number, string>()
for (const ep of epsData) {
if (ep.id && ep.original_filename) origMap.set(ep.id, ep.original_filename)
}
// Injecter dans les épisodes du serieData
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
...ep,
original_filename: origMap.get(ep.id) ?? null,
}))
return { ...season, episodes: enrichedEpisodes }
}))
return { ...sd, seasons: enrichedSeasons }
}))
}
// Helper réutilisable : charge + enrichit toutes les seriesData du cache
async function loadEnrichedSeriesData(): Promise<any[]> {
const ids = await readAvailable()
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
const valid = allSd.filter(Boolean)
return enrichSeriesDataWithOriginalFilenames(valid)
}
function computeSerieDownloadState(
serieTorrents : any[],
serieData : any | null,
organized : Record<string, Record<string, string>>,
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
activeTorrents: Set<string>
): 'none' | 'downloading' | 'partial' | 'complete' {
if (serieTorrents.length === 0) return 'none'
if (!serieData) return 'none'
// Un torrent de la série est en cours de téléchargement ?
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
const allTorrents = extractTorrentsFromSerieData(serieData)
if (allTorrents.length === 0) return 'none'
const hasActive = allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))
if (hasActive) return 'downloading'
// Compter les fichiers attendus vs organisés
let totalFiles = 0
let doneFiles = 0
for (const t of serieTorrents) {
const files: any[] = t.torrent_files ?? []
if (files.length === 0) {
// Fichier unique
totalFiles++
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
} else {
totalFiles += files.length
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
// Collecter tous les episode_id de la série et ceux organisés
const allEpisodeIds = new Set<number>()
const organizedEpisodeIds = new Set<number>()
for (const season of serieData.seasons ?? []) {
for (const ep of season.episodes ?? []) {
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) {
allEpisodeIds.add(ep.id)
}
}
}
if (totalFiles === 0) return 'none'
if (doneFiles === 0) return 'none'
if (doneFiles >= totalFiles) return 'complete'
// Vérifier via organized.json : si le filename du path est organisé
for (const t of allTorrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
const resolved = buildResolvedEpisodes(serieData, hash, t.season_number)
for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
}
}
if (allEpisodeIds.size === 0) return 'none'
if (organizedEpisodeIds.size === 0) return 'none'
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
return 'partial'
}
app.get('/api/series', requireAuth, async (_req, res) => {
try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
const byId = indexBySerie(torrents)
const [apiData, availableIds] = await Promise.all([
fankaiGet('/series'),
readAvailable(),
])
const availableSet = new Set<number>(availableIds)
// Charger organized.json
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
@@ -161,19 +379,31 @@ app.get('/api/series', requireAuth, async (_req, res) => {
const { category } = readSettings()
const active = await dispatchList(category ?? 'fankai')
for (const t of active) {
if (t.state === 'downloading' && t.hash)
activeTorrents.add(t.hash.toLowerCase())
if (t.hash) activeTorrents.add(t.hash.toLowerCase())
}
} catch {}
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
res.json({ series: seriesList.map((serie: any) => {
const serieTorrents = byId.get(serie.id) ?? []
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
// Charger les series data en parallèle uniquement pour celles qui ont des torrents
const serieDataMap = new Map<number, any>()
await Promise.all(
seriesRaw
.filter((s: any) => availableSet.has(s.id))
.map(async (s: any) => {
const sd = await readSerieData(s.id)
if (sd) serieDataMap.set(s.id, sd)
})
)
res.json({ series: seriesRaw.map((serie: any) => {
const hasTorrents = availableSet.has(serie.id)
const serieData = serieDataMap.get(serie.id) ?? null
return {
...serie,
torrent_count : serieTorrents.length,
has_torrents : serieTorrents.length > 0,
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0,
has_torrents : hasTorrents,
download_state: computeSerieDownloadState(serieData, organized, activeTorrents),
}
})})
} catch (err) {
@@ -184,129 +414,127 @@ app.get('/api/series', requireAuth, async (_req, res) => {
app.get('/api/series/:id', requireAuth, async (req, res) => {
const id = Number(req.params.id)
try {
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
const [serieRaw, seasonsData, serieData] = await Promise.all([
fankaiGet(`/series/${id}`),
fankaiGet(`/series/${id}/seasons`),
readSerieData(id),
])
const serie = normalizeSerie(serieRaw)
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
// Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.)
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
return { ...normalizeSeason(season), episodes }
}))
const torrents = readTorrents().filter(t => t.serie_id === id)
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap: Record<number, any> = {}
const seasonTorrentMap : Record<number, any> = {}
const integraleTorrents: any[] = []
const packEpisodesTorrents: any[] = []
for (const t of torrents) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
for (const ep of t.resolved_episodes ?? []) {
availableEpisodeIds.add(ep.episode_id)
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
}
if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? []
if (rs.length > 1) {
packEpisodesTorrents.push(t)
} else {
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
}
} else if (t.type === 'pack_integrale') {
integraleTorrents.push(t)
} else if (t.type === 'pack_episodes') {
packEpisodesTorrents.push(t)
}
}
let promotedIntegrale: any = null
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
const rs: any[] = t.resolved_seasons ?? []
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
}
}
if (integraleTorrents.length > 0 || promotedIntegrale) {
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
if (promotedIntegrale) {
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
}
}
function packEpisodesLabel(t: any): string {
const eps: any[] = t.resolved_episodes ?? []
if (!eps.length) return t.raw
const bySeason = new Map<number, number[]>()
for (const ep of eps) {
const sn = ep.season_number ?? 0
if (!bySeason.has(sn)) bySeason.set(sn, [])
bySeason.get(sn)!.push(ep.episode_number)
}
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
const s = nums.sort((a, b) => a - b)
const label = sn === 0 ? 'S0' : `S${sn}`
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
}).join(' · ')
}
// Charger organized.json pour les badges d'état
// Charger organized.json
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
// Construire un set des episode_id organisés
// On croise resolved_episodes de chaque torrent avec organized.json
const organizedEpisodeIds = new Set<number>()
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
const files = t.torrent_files ?? []
const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
for (const ep of t.resolved_episodes ?? []) {
// Vérifier si le fichier de cet épisode est organisé
const epFile = files.find((f: any) =>
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
)
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
organizedEpisodeIds.add(ep.episode_id)
// ── Construire les maps torrent depuis series/{id}.json ───────────
const availableEpisodeIds = new Set<number>()
const episodeTorrentMap : Record<number, any> = {} // episode_id → ref
const seasonTorrentMapBySn : Record<number, any> = {} // season_number → ref
const integraleTorrents : any[] = []
const packEpisodesTorrents : any[] = []
const organizedEpisodeIds = new Set<number>()
if (serieData) {
// ── Torrents niveau série (pack_integrale) ──────────────────
for (const t of (serieData.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false }
integraleTorrents.push({ ...t, raw: t.title })
const resolved = buildResolvedEpisodes(serieData, t.infohash)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
}
}
// ── Torrents niveau saison (pack_saison) ────────────────────
for (const season of serieData.seasons ?? []) {
for (const t of (season.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
seasonTorrentMapBySn[season.season_number] = ref
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
for (const ep of resolved) {
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
}
}
// ── Torrents niveau épisode ─────────────────────────────
for (const ep of season.episodes ?? []) {
for (const t of (ep.torrents ?? [])) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false }
episodeTorrentMap[ep.id] = ref
availableEpisodeIds.add(ep.id)
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
const paths: any[] = ep.paths ?? []
const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
const filename = match ? match.path.split('/').pop() : null
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
}
}
}
// Pack_saison multi-saisons → packEpisodesTorrents si couvre plusieurs saisons
// (cas où un seul torrent couvre plusieurs saisons mais pas toutes → affiché en hero)
// Pour l'instant on le traite comme pack_integrale si au niveau série
}
// ── Promotion intégrale ───────────────────────────────────────────
// Si pas de pack_integrale explicite mais un pack_saison couvre toutes les saisons
// → déjà géré car on met les torrents série en integraleTorrents directement
// ── Enrichissement des saisons API ───────────────────────────────
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => ({
...ep,
available : availableEpisodeIds.has(ep.id),
torrent : episodeTorrentMap[ep.id] ?? null,
organized : organizedEpisodeIds.has(ep.id),
}))
// State saison : none / partial / complete
const eps = season.episodes.map((ep: any) => {
// Chercher le torrent : niveau épisode d'abord, sinon le torrent de saison/intégrale sert juste de disponibilité
const epTorrent = episodeTorrentMap[ep.id] ?? null
// original_filename vient de l'API Fankai directement
return {
...ep,
available : availableEpisodeIds.has(ep.id),
torrent : epTorrent,
organized : organizedEpisodeIds.has(ep.id),
}
})
const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length
const seasonOrganizedState =
orgCount === 0 ? 'none' :
orgCount >= total && total > 0 ? 'complete' : 'partial'
orgCount === 0 ? 'none' :
orgCount >= total && total > 0 ? 'complete' : 'partial'
return {
...season,
torrent : seasonTorrentMap[season.id] ?? null,
organized_state : seasonOrganizedState,
organized_count : orgCount,
episodes : eps,
torrent : seasonTorrentMapBySn[season.season_number] ?? null,
organized_state : seasonOrganizedState,
organized_count : orgCount,
episodes : eps,
}
})
// ── Hero bundles ──────────────────────────────────────────────────
function packEpisodesLabel(t: any): string {
return t.raw ?? t.title ?? 'Pack'
}
const heroBundles: any[] = [
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })),
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw }))
]
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
@@ -329,38 +557,41 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai')
// Enrichir avec l'état d'organisation
let organized: Record<string, Record<string, string>> = {}
try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {}
let torrentFinal: any[] = []
try {
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
if (fs.existsSync(tfPath))
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
} catch {}
const enriched = torrents.map((t: any) => {
const enriched = await Promise.all(torrents.map(async (t: any) => {
const orgFiles = organized[t.hash] ?? {}
const meta = torrentFinal.find((m: any) =>
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
)
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
// Chercher les métadonnées via le cache GitHub
let totalFiles = 1
try {
const availableIds = await readAvailable()
for (const id of availableIds) {
const sd = await readSerieData(id)
if (!sd) continue
const allT = extractTorrentsFromSerieData(sd)
const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
if (match) {
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
totalFiles = resolved.length || 1
break
}
}
} catch {}
const doneFiles = Object.keys(orgFiles).length
let organizeState: 'none' | 'partial' | 'done' = 'none'
if (doneFiles >= totalFiles) organizeState = 'done'
else if (doneFiles > 0) organizeState = 'partial'
// Récupérer les erreurs depuis recentOrganized
const notif = recentOrganized.find(n => n.hash === t.hash)
const errorFiles = notif?.errorFiles ?? []
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
})
}))
res.json(enriched)
} catch (err) {
@@ -368,7 +599,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
}
})
// ── Résultats d'organisation récents (pour notifications frontend) ──
// ── Résultats d'organisation récents ──────────────────────────
interface OrganizeNotif {
hash : string
name : string
@@ -390,13 +621,11 @@ app.get('/api/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized)
})
// Vider les notifs lues
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0
res.json({ ok: true })
})
app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) {
@@ -404,14 +633,14 @@ app.post('/api/organize', requireAuth, async (req, res) => {
return
}
try {
const result = await organizeTorrent(hash, name, save_path)
const seriesData = await loadEnrichedSeriesData()
const result = await organizeTorrent(hash, name, save_path, seriesData)
res.json(result)
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
})
// ── Torrents status ────────────────────────────────────────────
// ── Logs ──────────────────────────────────────────────────────
app.get('/api/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100
@@ -449,27 +678,21 @@ app.get('/api/browse', requireAuth, (req, res) => {
}
})
app.get('/api/torrents/status', requireAuth, (_req, res) => {
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
try {
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
const data = JSON.parse(raw)
const count = Array.isArray(data) ? data.length : 0
res.json({ exists: true, count, empty: count === 0 })
const available = await readAvailable()
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
} catch { res.json({ exists: false, count: 0, empty: true }) }
})
// ── Update torrent_final.json ──────────────────────────────────
// ── Update : vide le cache pour forcer le rechargement depuis GitHub ──────────
app.post('/api/update', requireAuth, async (_req, res) => {
try {
const response = await fetch(GITHUB_RAW_URL)
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
const data = await response.json() as unknown[]
if (!Array.isArray(data)) throw new Error('Format invalide')
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
res.json({ ok: true, count: data.length })
cacheClear()
// Pré-charger available.json pour vérifier que GitHub répond
const availableIds = await readAvailable()
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
res.json({ ok: true, count: availableIds.length })
} catch (err) {
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
}
@@ -478,8 +701,9 @@ app.post('/api/update', requireAuth, async (_req, res) => {
app.post('/api/scan', requireAuth, async (_req, res) => {
try {
const { mediaPath } = readSettings()
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const seriesData = await loadEnrichedSeriesData()
const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result })
} catch (err) {
@@ -487,7 +711,7 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
}
})
// ── SPA fallback (prod) — doit être après toutes les routes API ─
// ── SPA fallback (prod) ────────────────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => {
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
@@ -495,28 +719,36 @@ if (fs.existsSync(PUBLIC_PATH)) {
}
// ── Start ──────────────────────────────────────────────────────
app.listen(PORT, () => {
try {
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
const count = Array.isArray(data) ? data.length : 0
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
else console.log(`[fankarr] ${count} torrents chargés`)
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
app.listen(PORT, async () => {
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`)
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
// Pré-chauffer le cache available.json au démarrage
try {
const available = await readAvailable()
console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`)
} catch (err) {
console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`)
}
// Scan initial du mediaPath
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings()
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
loadEnrichedSeriesData().then(seriesData =>
scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
).catch(err =>
logger.error('organize', `scanMediaPath échoué: ${err}`)
)
// Auto-organise toutes les 30s
// Auto-organise — polling différentiel toutes les 5 minutes
// Le worker ne se lance que si un torrent vient de passer en seeding
const autoOrganize = async () => {
try {
const { category } = readSettings()
const seriesData = await loadEnrichedSeriesData()
await autoOrganizeAll(
() => dispatchList(category ?? 'fankai'),
seriesData,
(result) => {
if (result.done > 0 || result.errors > 0) {
pushNotif({ ...result, at: new Date().toISOString() })
@@ -527,9 +759,9 @@ app.listen(PORT, () => {
console.error('[organize] Erreur auto-organise:', err)
}
}
// Premier run 30s après démarrage, puis toutes les 30s
// Premier run 10s après démarrage, puis toutes les 5 minutes
setTimeout(() => {
autoOrganize()
setInterval(autoOrganize, 30_000)
}, 30_000)
setInterval(autoOrganize, 5 * 60_000)
}, 10_000)
})
+2 -1
View File
@@ -8,6 +8,7 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@@ -19,7 +20,7 @@ export interface LogEntry {
meta? : Record<string, any>
}
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà
const IS_PROD = process.env.NODE_ENV === 'production'
+150 -149
View File
@@ -4,8 +4,8 @@
* Worker thread dédié à l'organisation des fichiers.
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
*
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
* Messages envoyés (parentPort.postMessage) :
* Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] }
* Messages envoyés :
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
* { type: 'result', hash: string, name: string, done, skipped, errors }
* { type: 'done' }
@@ -15,31 +15,26 @@ import fs from 'fs'
import fsp from 'fs/promises'
import path from 'path'
import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
// qui les écrit dans le fichier via logger.ts
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ──────
// ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try {
const p = path.join(process.cwd(), 'data', 'settings.json')
const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
}
// ─── Organized log ────────────────────────────────────────────
function loadOrganized(): Record<string, Record<string, string>> {
try {
if (!fs.existsSync(ORGANIZED_PATH)) return {}
@@ -58,18 +53,60 @@ function isOrganized(hash: string, filename: string): boolean {
return !!loadOrganized()[hash]?.[filename]
}
// ─── Torrent lookup ───────────────────────────────────────────
function findTorrentByHash(hash: string): any | null {
try {
if (!fs.existsSync(TORRENTS_PATH)) return null
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
} catch { return null }
// ─── Lookup torrent dans les seriesData passées via message ───
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
for (const sd of seriesData) {
// Niveau série
for (const t of sd.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: t, serieData: sd }
}
// Niveau saison
for (const season of sd.seasons ?? []) {
for (const t of season.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _season: season }, serieData: sd }
}
// Niveau épisode
for (const ep of season.episodes ?? []) {
for (const t of ep.torrents ?? []) {
if (t.infohash?.toLowerCase() === hash.toLowerCase())
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
}
}
}
}
return null
}
// ─── Dossiers exclus (bonus, musique, images) ────────────────
// Construit la map filename → { season_number, original_filename, fullPath }
// en matchant par infohash dans ep.paths[]
function buildFileMap(
serieData : any,
hash : string,
seasonFilter?: number
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
const h = hash.toLowerCase()
for (const season of serieData.seasons ?? []) {
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
for (const ep of season.episodes ?? []) {
const match = (ep.paths ?? []).find((p: any) => p.infohash?.toLowerCase() === h)
if (!match) continue
const filename = match.path.split('/').pop() ?? match.path
const original = ep.original_filename ?? filename
map.set(filename, {
season_number : season.season_number,
original_filename: original,
fullPath : match.path,
})
}
}
return map
}
// ─── Dossiers exclus ─────────────────────────────────────────
const EXCLUDED_FOLDERS = new Set([
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
@@ -87,28 +124,7 @@ function isInExcludedFolder(filePath: string[]): boolean {
return false
}
// ─── Map correction titres API → titres GitLab ───────────────
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
'Tower Of God Henshū' : 'Tower of God Henshū',
}
function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
}
// ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
@@ -127,43 +143,38 @@ async function fetchBinary(url: string): Promise<Buffer> {
}
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
const gitlabTitle = getGitlabSerieTitle(serieTitle)
const folderPath = `pack/${gitlabTitle}`
const folderPath = `pack/${serieTitle}`
log(`[nfo] Récupération dossier GitLab: ${serieTitle}`)
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier
let files: any[]
let files: any[] = []
try {
files = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
)
let page = 1
while (true) {
const batch = await fetchJson(
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
)
if (!Array.isArray(batch) || batch.length === 0) break
files.push(...batch)
if (batch.length < 100) break
page++
}
} catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`)
return
}
if (!Array.isArray(files) || files.length === 0) {
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
return
}
if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return }
const fileEntries = files.filter((f: any) => f.type === 'blob')
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) {
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const relativePath = entry.path.replace(`pack/${serieTitle}/`, '')
const destPath = path.join(destRoot, relativePath)
const destDir = path.dirname(destPath)
// Skip si déjà présent
if (fs.existsSync(destPath)) continue
try {
fs.mkdirSync(destDir, { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
fs.mkdirSync(path.dirname(destPath), { recursive: true })
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(serieTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
const data = await fetchBinary(rawUrl)
fs.writeFileSync(destPath, data)
log(`[nfo] ✓ ${relativePath}`)
@@ -171,15 +182,11 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
}
}
log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`)
log(`[nfo] GitLab synchronisé pour "${serieTitle}"`)
}
// ─── Filesystem ops ───────────────────────────────────────────
function seasonFolder(n: number): string {
return `Saison ${n}`
}
function seasonFolder(n: number): string { return `Saison ${n}` }
function tryHardlink(src: string, dest: string): boolean {
try { fs.linkSync(src, dest); return true }
@@ -190,47 +197,59 @@ function tryHardlink(src: string, dest: string): boolean {
}
// ─── Organise un torrent ──────────────────────────────────────
async function organizeTorrent(hash: string, name: string, savePath: string) {
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
log(`[organize] ── ${name} ──`)
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
const torrent = findTorrentByHash(hash)
if (!torrent) {
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
const found = findTorrentByHash(hash, seriesData)
if (!found) {
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
throw new Error(`Torrent introuvable : ${hash}`)
}
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
const serieTitle = torrent.serie_title || torrent.show_title || name
const torrentFiles: any[] = torrent.torrent_files ?? []
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
const { torrent, serieData } = found
const serieTitle = serieData.title ?? serieData.show_title ?? name
const torrentTitle = torrent.title ?? name
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
// ── Téléchargement NFO/images depuis GitLab ───────────────
// ── NFO/images depuis GitLab ──────────────────────────────
if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle)
await downloadGitlabFolder(serieTitle, serieDestRoot)
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
}
// ── Cas fichier unique ────────────────────────────────────
if (torrentFiles.length === 0) {
log(`[organize] torrent_files vide → tentative fichier unique`)
const candidates = [path.join(savePath, name), path.join(completePath, name)]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`)
else debug(`[organize] candidate absent: ${c}`)
if (fs.existsSync(c)) { src = c; break }
}
if (!src) throw new Error(`Source introuvable pour "${name}"`)
// ── Cas torrent épisode unique ────────────────────────────
if (torrent._episode) {
const ep = torrent._episode
const season = torrent._season
const match = (ep.paths ?? []).find((p: any) => p.infohash?.toLowerCase() === hash.toLowerCase())
const filePath = match?.path ?? null
const filename = filePath?.split('/').pop() ?? name
const original = ep.original_filename ?? filename
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
const dest = path.join(destDir, original)
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, name)
log(`[organize] dest: ${dest}`)
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
const candidates = filePath ? [
path.join(savePath, filePath),
path.join(completePath, filePath),
path.join(savePath, filename),
path.join(completePath, filename),
] : [
path.join(savePath, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
error(`[organize] ✗ ${filename} introuvable`)
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
}
if (!fs.existsSync(dest)) {
fs.mkdirSync(destDir, { recursive: true })
@@ -240,67 +259,47 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
await fsp.copyFile(src, dest)
await fsp.unlink(src)
}
log(`[organize] ✓ ${organizeMode} OK`)
} else {
log(`[organize] → dest existe déjà, skip`)
}
markOrganized(hash, name)
markOrganized(hash, filename)
log(`[organize] ✓ ${original} → Saison ${season.season_number}`)
return { ...result, total: 1, done: 1 }
}
// ── Map filename → season ─────────────────────────────────
const filenameSeasonMap = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.season_number !== undefined)
filenameSeasonMap.set(ep.filename, ep.season_number)
// ── Pack saison / intégrale ───────────────────────────────
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, hash, seasonFilter)
if (fileMap.size === 0) {
warn(`[organize] Aucun fichier mappé pour ${name}`)
return result
}
result.total = torrentFiles.length
for (const tf of torrentFiles) {
const filename = tf.filename
const filePath: string[] = tf.path
// Skip les fichiers dans des dossiers bonus/musique/images
if (isInExcludedFolder(filePath)) {
log(`[organize] skip bonus: ${filename}`)
result.skipped++
continue
}
result.total = fileMap.size
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
if (isOrganized(hash, filename)) { result.skipped++; continue }
// Source : on utilise save_path + fullPath (chemin relatif exact dans le torrent)
// save_path vient de qBittorrent → résiste aux renommages de dossier
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, fullPath),
path.join(completePath, fullPath),
path.join(savePath, filename),
path.join(completePath, name, ...filePath.slice(0, -1), filename),
path.join(completePath, name, filename),
path.join(completePath, filename),
]
let src: string | null = null
for (const c of candidates) {
if (fs.existsSync(c)) { src = c; break }
}
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
if (!src) {
const tried = candidates.map(c => ` • ${c}`).join('\n')
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
error(`[organize] ✗ ${filename} introuvable`)
result.errors.push({ file: filename, error: 'Source introuvable' })
continue
}
const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, filename)
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
const dest = path.join(destDir, original_filename)
if (fs.existsSync(dest)) {
markOrganized(hash, filename)
result.skipped++
continue
}
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
try {
fs.mkdirSync(destDir, { recursive: true })
@@ -312,7 +311,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
}
markOrganized(hash, filename)
result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
} catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`)
@@ -320,37 +319,39 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
}
}
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
return result
}
// ─── Boucle principale du worker ─────────────────────────────
parentPort?.on('message', async (msg: any) => {
if (msg.type !== 'run') return
const torrents: any[] = msg.torrents
const torrents: any[] = msg.torrents
const seriesData: any[] = msg.seriesData ?? []
const organized = loadOrganized()
for (const t of torrents) {
if (t.state !== 'seeding') continue
const torrent = findTorrentByHash(t.hash)
if (!torrent) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
const found = findTorrentByHash(t.hash, seriesData)
if (!found) {
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
continue
}
const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename])
// Vérifier si déjà tout organisé
const { torrent, serieData } = found
const seasonFilter = torrent._season?.season_number
const fileMap = buildFileMap(serieData, t.hash, seasonFilter)
const allDone = fileMap.size > 0
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
: !!organized[t.hash]?.[t.name]
if (allDone) continue
try {
const result = await organizeTorrent(t.hash, t.name, t.save_path)
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
} catch (err) {
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
+92 -73
View File
@@ -2,17 +2,29 @@
* organize.ts
* ===========
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
* Le worker gère tout le filesystem (copy, hardlink, move).
*/
import fs from 'fs'
import path from 'path'
import { Worker } from 'worker_threads'
import { logger } from './logger.js'
import { DATA_DIR } from './config.js'
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
// Un seul worker à la fois
let workerRunning = false
// État précédent des torrents pour détecter les passages downloading → seeding
const _prevStates = new Map<string, string>()
function resolveWorkerPath(): string {
if (typeof (globalThis as any).Bun !== 'undefined') {
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
}
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
}
export interface OrganizeResult {
total : number
skipped : number
@@ -22,13 +34,12 @@ export interface OrganizeResult {
/**
* Scan initial du mediaPath au démarrage.
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
* tous les fichiers déjà présents qui correspondent à un torrent connu.
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
*/
export async function scanMediaPath(
mediaPath : string,
torrentsPath: string,
organizedPath: string
mediaPath : string,
organizedPath: string,
seriesData : any[] = []
): Promise<{ found: number; added: number }> {
const result = { found: 0, added: 0 }
@@ -37,27 +48,18 @@ export async function scanMediaPath(
return result
}
// Charger torrent_final.json
let torrents: any[] = []
try {
if (!fs.existsSync(torrentsPath)) return result
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
} catch {
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
return result
}
// Construire un index filename → { hash, infohash }
// Construire index filename → infohash depuis les seriesData
const filenameIndex = new Map<string, string>() // filename → infohash
for (const t of torrents) {
const hash = t.infohash?.toLowerCase()
if (!hash) continue
for (const f of t.torrent_files ?? []) {
if (f.filename) filenameIndex.set(f.filename, hash)
}
// Fichier unique (torrent_files vide) → on indexe par raw name
if ((t.torrent_files ?? []).length === 0 && t.raw) {
filenameIndex.set(t.raw, hash)
for (const sd of seriesData) {
for (const season of sd.seasons ?? []) {
for (const ep of season.episodes ?? []) {
for (const p of ep.paths ?? []) {
const hash = p.infohash?.toLowerCase()
if (!hash || !p.path) continue
const filename = p.path.split('/').pop()
if (filename) filenameIndex.set(filename, hash)
}
}
}
}
@@ -68,7 +70,8 @@ export async function scanMediaPath(
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {}
// Parcourir récursivement mediaPath
const presentFiles = new Set<string>()
function walk(dir: string) {
let entries: fs.Dirent[]
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
@@ -81,8 +84,8 @@ export async function scanMediaPath(
result.found++
const hash = filenameIndex.get(entry.name)
if (!hash) continue
if (organized[hash]?.[entry.name]) continue // déjà connu
// Marquer comme organisé
presentFiles.add(`${hash}:${entry.name}`)
if (organized[hash]?.[entry.name]) continue
if (!organized[hash]) organized[hash] = {}
organized[hash][entry.name] = new Date().toISOString()
result.added++
@@ -92,9 +95,21 @@ export async function scanMediaPath(
walk(mediaPath)
if (result.added > 0) {
// Nettoyer les entrées orphelines
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) {
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
removed++
}
}
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
}
if (result.added > 0 || removed > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
} else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
}
@@ -103,16 +118,15 @@ export async function scanMediaPath(
}
/**
* Lance l'auto-organisation dans un worker thread.
* listFn est appelée dans le thread principal (accès qBittorrent),
* puis les données sont passées au worker.
* Auto-organisation — appelée depuis index.ts avec les seriesData du cache GitHub.
*/
export async function autoOrganizeAll(
listFn : () => Promise<any[]>,
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
listFn : () => Promise<any[]>,
seriesData : any[],
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> {
if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours, skip')
console.log('[organize] Worker déjà en cours, skip')
return
}
@@ -120,76 +134,81 @@ export async function autoOrganizeAll(
try {
torrents = await listFn()
} catch (err) {
logger.error('organize', "Impossible de récupérer la liste des torrents: " + (err instanceof Error ? err.message : String(err)))
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
return
}
if (torrents.length === 0) return
const isFirstRun = _prevStates.size === 0
const seedingCount = torrents.filter(t => t.state === 'seeding').length
const newlySeeding = torrents.filter(t => {
const prev = _prevStates.get(t.hash)
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
})
for (const t of torrents) _prevStates.set(t.hash, t.state)
if (!isFirstRun && newlySeeding.length === 0) return
if (seedingCount === 0) return
logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
if (newlySeeding.length > 0 && !isFirstRun) {
console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`)
} else {
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
}
workerRunning = true
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
const worker = new Worker(resolveWorkerPath())
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') {
logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
onResult?.({
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles : msg.errors,
hash : msg.hash,
name : msg.name,
done : msg.done,
skipped : msg.skipped,
errors : msg.errors.length,
errorFiles: msg.errors,
})
} else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé')
console.log('[organize] Worker terminé')
workerRunning = false
worker.terminate()
}
})
worker.on('error', (err) => {
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
workerRunning = false
})
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false })
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false })
worker.on('exit', (code) => {
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
workerRunning = false
})
// Envoyer les torrents au worker
worker.postMessage({ type: 'run', torrents })
worker.postMessage({ type: 'run', torrents, seriesData })
}
/**
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
* Lance aussi dans un worker thread.
* Organisation manuelle d'un torrent.
*/
export async function organizeTorrent(
hash : string,
name : string,
savePath: string
hash : string,
name : string,
savePath : string,
seriesData: any[]
): Promise<OrganizeResult> {
return new Promise((resolve, reject) => {
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
const worker = new Worker(workerPath)
// On envoie un seul torrent "fictif seeding"
const worker = new Worker(resolveWorkerPath())
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
worker.on('message', (msg: any) => {
if (msg.type === 'log') {
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
if (msg.level === 'error') console.error(msg.msg)
else if (msg.level === 'warn') console.warn(msg.msg)
else console.log(msg.msg)
} else if (msg.type === 'result') {
result.total = msg.total
result.skipped = msg.skipped
@@ -204,6 +223,6 @@ export async function organizeTorrent(
worker.on('error', (err) => { worker.terminate(); reject(err) })
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
})
}
+2 -1
View File
@@ -9,8 +9,9 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import { DATA_DIR } from './config.js'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
function generateSecret(): string {
return crypto.randomBytes(48).toString('hex')
+2 -1
View File
@@ -1,7 +1,8 @@
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
export interface Settings {
mediaPath : string
+2 -1
View File
@@ -9,6 +9,7 @@
import { randomUUID } from 'crypto'
import fs from 'fs'
import path from 'path'
import { DATA_DIR } from '../config.js'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -77,7 +78,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
function loadClients(): SavedClient[] {
try {
+2 -2
View File
@@ -1,8 +1,8 @@
<template>
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
<RouterView v-slot="{ Component, route }">
<RouterView v-slot="{ Component }">
<keep-alive include="CatalogView">
<component :is="Component" :key="route.path" />
<component :is="Component" />
</keep-alive>
</RouterView>
<ToastContainer />
+9
View File
@@ -61,6 +61,8 @@ export const useSeriesStore = defineStore('series', () => {
const loadingDetail = ref(false)
const error = ref<string | null>(null)
const _imageCache = new Set<string>()
async function fetchSeries() {
loadingSeries.value = true
error.value = null
@@ -69,6 +71,13 @@ export const useSeriesStore = defineStore('series', () => {
if (!res.ok) throw new Error(`Erreur ${res.status}`)
const data = await res.json()
series.value = data.series
// Précache des posters en arrière-plan
for (const s of data.series as Serie[]) {
if (s.poster_image && !_imageCache.has(s.poster_image)) {
_imageCache.add(s.poster_image)
new Image().src = s.poster_image
}
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
} finally {
+14 -13
View File
@@ -147,16 +147,8 @@
</svg>
</div>
<!-- Badge torrents dispo -->
<div v-if="serie.has_torrents"
class="absolute top-2 right-2 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none cursor-default group/badge">
{{ serie.torrent_count }}
<div class="absolute bottom-full right-0 mb-1.5 hidden group-hover/badge:block z-10">
<div class="bg-zinc-800 border border-white/10 text-white text-[10px] font-normal px-2 py-1 rounded whitespace-nowrap shadow-lg">
{{ serie.torrent_count }} torrent{{ serie.torrent_count > 1 ? 's' : '' }} téléchargeables
</div>
</div>
</div>
<!-- Badge état téléchargement -->
<div v-if="serie.download_state !== 'none'"
@@ -207,7 +199,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import { useAuthStore } from '@/stores/auth'
@@ -283,8 +275,11 @@ async function fetchOrganizeNotifs() {
} catch {}
}
onMounted(() => {
if (store.series.length === 0) store.fetchSeries()
// ── Cache images ───────────────────────────────────────────────
// Géré directement dans le store series.ts
onMounted(async () => {
if (store.series.length === 0) await store.fetchSeries()
fetchActiveDownloads()
fetchOrganizeNotifs()
dlInterval = setInterval(() => {
@@ -293,6 +288,12 @@ onMounted(() => {
}, 10000)
})
// Refresh au retour sur le catalogue (après update ou scan)
onActivated(async () => {
await store.fetchSeries()
fetchActiveDownloads()
})
onUnmounted(() => {
if (dlInterval) clearInterval(dlInterval)
})
+376 -448
View File
@@ -1,495 +1,423 @@
<template>
<div class="max-w-2xl mx-auto px-8 py-8">
<!-- Header -->
<div class="flex items-center gap-4 mb-8">
<button
@click="$router.push('/')"
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
← CATALOGUE
</button>
<h1 class="text-xl font-black text-white font-sans">Paramètres</h1>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<!-- Back -->
<div class="max-w-7xl mx-auto px-6 pt-6">
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 5l-7 7 7 7"/>
</svg>
Catalogue
</router-link>
</div>
<!-- Bannière données manquantes -->
<div v-if="torrentsStatus.empty" class="border border-[#e8513a]/40 bg-[#e8513a]/5 p-4 mb-6 flex items-center justify-between gap-4">
<div>
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
<p class="text-sm text-[#a0b4c4]">
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
Téléchargez les données pour utiliser Fankarr.
</p>
</div>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-5 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer whitespace-nowrap">
{{ updating ? 'TÉLÉCHARGEMENT...' : 'TÉLÉCHARGER' }}
</button>
<!-- Loading -->
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
<p class="text-sm">Chargement…</p>
</div>
<!-- Données torrents -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
<p class="text-[11px] text-[#5a7a94] mb-5">
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
</p>
<button @click="update" :disabled="updating"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ updating ? 'MISE À JOUR...' : 'METTRE À JOUR' }}
</button>
<!-- Error -->
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
<p class="text-sm">{{ store.error }}</p>
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
</div>
<!-- Clients torrent -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">TÉLÉCHARGEMENT</div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-sm font-bold text-white">Clients torrent</h2>
<button @click="openAddModal"
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
+ AJOUTER
</button>
</div>
<template v-else-if="data">
<!-- Hero -->
<div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20"
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
</div>
<div v-if="clients.length === 0" class="text-[11px] text-[#5a7a94]">
Aucun client configuré
</div>
<div v-else class="flex flex-col gap-2">
<div v-for="client in clients" :key="client.uuid"
class="flex items-center justify-between border border-[#1e2d3d] px-4 py-3">
<div class="flex items-center gap-3">
<div class="w-2 h-2 rounded-full"
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-[#1e2d3d]'"/>
<div>
<p class="text-sm text-white font-medium">{{ client.name }}</p>
<p class="text-[10px] text-[#5a7a94] tracking-widest mt-0.5">{{ clientLabel(client.type) }}</p>
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
<!-- Poster -->
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
</svg>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="testClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#3a8fe8] transition-colors cursor-pointer px-2 py-1">
TESTER
</button>
<button @click="deleteClient(client.uuid)"
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer px-2 py-1">
SUPPRIMER
</button>
<!-- Info -->
<div class="flex flex-col gap-3 justify-end min-w-0">
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
<div class="flex flex-wrap items-center gap-2">
<span v-if="data.serie.year" class="text-sm text-zinc-400">{{ data.serie.year }}</span>
<span
v-if="data.serie.status"
class="text-xs px-2 py-0.5 rounded-full font-medium border"
:class="data.serie.status.toLowerCase() === 'continuing'
? 'bg-green-500/15 text-green-400 border-green-500/20'
: 'bg-white/5 text-zinc-500 border-white/10'"
>{{ data.serie.status }}</span>
</div>
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
{{ data.serie.plot }}
</p>
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
<div class="flex flex-wrap gap-2 mt-1">
<button
v-for="(t, i) in data.torrents_integrale"
:key="i"
:title="t.raw"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[
t.label === 'Intégrale'
? 'bg-orange-500 hover:bg-orange-600 text-white'
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20',
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''
]"
:disabled="downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)"
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
>
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" />
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
</button>
</div>
<!-- Barre progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<template v-if="torrentProgress(extractHash(t))">
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
<div v-else class="mt-2 w-full max-w-sm">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(t))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
</div>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
<!-- Médias & Organisation -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
<!-- Seasons -->
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
<div
v-for="season in data.seasons"
:key="season.id"
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
>
<!-- Season header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
<div class="flex items-center gap-3">
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
<svg
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
class="transition-transform duration-200"
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
>
<path d="M18 15l-6-6-6 6"/>
</svg>
</button>
<div>
<h2 class="font-semibold text-sm">
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
— {{ season.title }}
</span>
</h2>
<p class="text-xs text-zinc-600 mt-0.5">
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
<span v-if="season.organized_state !== 'none'"
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
:class="season.organized_state === 'complete'
? 'bg-green-500/10 text-green-400'
: 'bg-yellow-500/10 text-yellow-500'">
{{ season.organized_state === 'complete'
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
</span>
</p>
</div>
</div>
<div class="flex flex-col gap-5">
<!-- Dossier complétion -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.completePath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
</div>
<!-- Dossier Jellyfin -->
<div>
<div class="flex items-center gap-2 mb-1.5">
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button @click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.mediaPath || '/' }}
</button>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
</div>
<!-- Mode organisation -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
<div class="flex gap-2">
<button @click="form.organizeMode = 'hardlink'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'hardlink'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
HARDLINK
</button>
<button @click="form.organizeMode = 'move'"
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
:class="form.organizeMode === 'move'
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
MOVE
<!-- Bouton pack_saison sur le header -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" />
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
</button>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
</p>
</div>
<!-- Support NFO -->
<div>
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NFO / MÉTADONNÉES</label>
<div class="flex items-center gap-3">
<button @click="form.nfoSupport = !form.nfoSupport"
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
:class="form.nfoSupport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
:class="form.nfoSupport ? 'translate-x-5' : 'translate-x-0'" />
</button>
<span class="text-xs text-[#c8d8e8]">Télécharger les NFO et images depuis GitLab</span>
<!-- Barre progression saison -->
<template v-if="torrentProgress(extractHash(season.torrent))">
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
class="px-5 py-2 border-b border-white/5">
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ Téléchargé
</span>
</div>
<div v-else class="px-5 py-2 border-b border-white/5">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement en cours</span>
<span>{{ torrentProgress(extractHash(season.torrent))!.progress }}%</span>
</div>
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }" />
</div>
</div>
</template>
<!-- Episodes -->
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
<div
v-for="ep in season.episodes"
:key="ep.id"
class="px-5 py-3 hover:bg-white/[0.02] transition"
>
<!-- Ligne principale -->
<div class="flex items-center gap-4">
<!-- Thumbnail -->
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
</div>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
</span>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
</div>
</div>
<!-- Badges + bouton download -->
<div class="shrink-0 flex items-center gap-2">
<!-- Badge Hors Fankai (torrent manuel) -->
<span
v-if="ep.torrent?.manual"
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
>
Hors Fankai
</span>
<!-- Badge organisé -->
<span v-if="ep.organized"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé
</span>
<!-- Badge téléchargé à 100% -->
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ téléchargé
</span>
<!-- Badge dispo/indispo -->
<span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'"
>
{{ ep.available ? 'dispo' : 'indispo' }}
</span>
<!-- Bouton download -->
<button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
]"
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
>
<DownloadIcon :spinning="downloading.has(`ep-${ep.id}`)" :size="12" />
{{ ep.organized ? '✓' : isAlreadyQueued(ep.torrent) ? '✓' : downloaded.has(`ep-${ep.id}`) ? '✓' : 'Ep.' }}
</button>
</div>
</div>
<!-- Barre progression épisode (seulement si < 100%) -->
<div v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
class="mt-2 sm:pl-28">
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span>
<span>{{ torrentProgress(extractHash(ep.torrent))!.progress }}%</span>
</div>
<div class="h-0.5 bg-white/10 rounded-full overflow-hidden">
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }" />
</div>
</div>
</div>
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
<span class="text-white">fankai_pack</span> au moment de l'organisation
</p>
</div>
<div class="pt-1 flex items-center gap-3">
<button type="button" @click="save" :disabled="saving"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ saving ? '...' : 'SAUVEGARDER' }}
</button>
<button type="button" @click="scan" :disabled="scanning"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
{{ scanning ? 'SCAN...' : 'ANALYSER BIBLIOTHÈQUE' }}
</button>
<span v-if="scanResult" class="text-[10px]"
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
</span>
</div>
</div>
</div>
</template>
<!-- Compte -->
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6">
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">COMPTE</div>
<h2 class="text-sm font-bold text-white mb-6">Session</h2>
<button type="button" @click="logout"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
SE DÉCONNECTER
</button>
</div>
<!-- Folder Picker -->
<FolderPicker
v-if="picker.open"
:initial-path="picker.currentPath"
@select="onPickerSelect"
@cancel="picker.open = false"
/>
<!-- Modal ajout client -->
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-sm font-bold text-white">Ajouter un client</h3>
<button @click="closeModal" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer">✕</button>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
<input v-model="modal.name" placeholder="Mon qBittorrent"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
<div class="mb-4">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">TYPE</label>
<select v-model="modal.type" @change="onTypeChange"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors cursor-pointer">
<option value="">Choisir un type...</option>
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
</select>
</div>
<div v-if="currentDefinition" class="flex flex-col gap-3 mb-6">
<div v-for="field in currentDefinition.fields" :key="field.key">
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">{{ field.label.toUpperCase() }}</label>
<input
v-model="modal.config[field.key]"
:type="field.type === 'password' ? 'password' : 'text'"
:placeholder="field.placeholder ?? ''"
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
</div>
</div>
<div class="flex gap-3">
<button @click="saveClient" :disabled="modal.saving || !modal.tested"
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
{{ modal.saving ? '...' : 'ENREGISTRER' }}
</button>
<button @click="testNewClient" :disabled="modal.testing || !modal.type"
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#3a8fe8] hover:text-[#3a8fe8] transition-colors disabled:opacity-50 cursor-pointer">
{{ modal.testing ? '...' : modal.tested ? 'TESTÉ ✓' : 'TESTER' }}
</button>
</div>
<p v-if="!modal.tested && modal.type" class="text-[10px] text-[#5a7a94] mt-3">
Testez la connexion avant d'enregistrer
</p>
<!-- Toast -->
<Transition name="toast">
<div
v-if="toast"
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
:class="toast.type === 'success'
? 'bg-zinc-800 border border-green-500/30 text-green-400'
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
>
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path d="M20 6 9 17l-5-5"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
{{ toast.message }}
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import FolderPicker from '@/components/FolderPicker.vue'
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesStore } from '@/stores/series'
import type { Season } from '@/stores/series'
const router = useRouter()
const auth = useAuthStore()
const { add: toast } = useToast()
// ── State ──────────────────────────────────────────────────────
const form = ref({
mediaPath : '',
completePath: '',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
})
const saving = ref(false)
const updating = ref(false)
const scanning = ref(false)
const scanResult = ref<{ found: number; added: number } | null>(null)
// ── Folder picker ──────────────────────────────────────────────
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
open: false, field: 'completePath', currentPath: '/'
})
function openPicker(field: 'completePath' | 'mediaPath') {
picker.value = {
open : true,
field,
currentPath: (form.value as any)[field] || '/',
// ── Composant icône download ──────────────────────────────────
const DownloadIcon = defineComponent({
props: { spinning: Boolean, size: { type: Number, default: 14 } },
setup(props) {
return () => props.spinning
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
])
}
}
function onPickerSelect(path: string) {
(form.value as any)[picker.value.field] = path
picker.value.open = false
}
const isDocker = ref(false)
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
const clients = ref<any[]>([])
const availableClients = ref<any[]>([])
const healthStatus = ref<Record<string, boolean | null>>({})
const modal = ref({
open : false,
name : '',
type : '',
config : {} as Record<string, string>,
testing: false,
saving : false,
tested : false,
})
// ── Computed ───────────────────────────────────────────────────
const currentDefinition = computed(() =>
availableClients.value.find(d => d.id === modal.value.type) ?? null
)
// ── Setup ─────────────────────────────────────────────────────
const route = useRoute()
const store = useSeriesStore()
function clientLabel(type: string): string {
return availableClients.value.find(d => d.id === type)?.label ?? type
}
const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<Set<string>>(new Set())
const downloaded = ref<Set<string>>(new Set())
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Init ───────────────────────────────────────────────────────
onMounted(async () => {
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
fetch('/api/settings', { credentials: 'include' }),
fetch('/api/torrents/status', { credentials: 'include' }),
fetch('/api/torrent-clients', { credentials: 'include' }),
fetch('/api/torrent-clients/available', { credentials: 'include' }),
fetch('/api/system', { credentials: 'include' }),
])
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
if (clientsRes.ok) clients.value = await clientsRes.json()
if (availRes.ok) availableClients.value = await availRes.json()
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
// ── Progression downloads ──────────────────────────────────────
interface ActiveTorrent { hash: string; progress: number; state: string; name: string }
const activeTorrents = ref<ActiveTorrent[]>([])
let pollTimer: ReturnType<typeof setInterval> | null = null
for (const c of clients.value) refreshHealth(c.uuid)
})
// ── Healthcheck ────────────────────────────────────────────────
async function refreshHealth(uuid: string) {
healthStatus.value[uuid] = null
async function fetchActiveDownloads() {
try {
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
} catch {
healthStatus.value[uuid] = false
}
const res = await fetch('/api/downloads', { credentials: 'include' })
if (!res.ok) return
const list: any[] = await res.json()
activeTorrents.value = list.map(t => ({
hash : t.hash,
progress: t.progress ?? 0,
state : t.state,
name : t.name,
}))
} catch {}
}
// ── Test / delete clients ──────────────────────────────────────
async function testClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
const { ok, message } = await res.json()
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
healthStatus.value[uuid] = ok
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
}
async function deleteClient(uuid: string) {
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
if (res.ok) {
clients.value = clients.value.filter(c => c.uuid !== uuid)
delete healthStatus.value[uuid]
toast('Client supprimé', 'success')
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
if (!torrent) return null
const magnet = torrent.magnet ?? ''
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
return m ? m[1].toLowerCase() : null
}
// ── Data ───────────────────────────────────────────────────────
const data = computed(() => store.currentSerie)
function seasonAvailableCount(season: Season) {
return season.episodes.filter(e => e.available).length
}
function toggleSeason(id: number) {
if (collapsedSeasons.value.has(id)) collapsedSeasons.value.delete(id)
else collapsedSeasons.value.add(id)
}
function formatDate(d: string) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
}
function showToast(type: 'success' | 'error', message: string) {
toast.value = { type, message }
setTimeout(() => { toast.value = null }, 3500)
}
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent)
if (!hash) return false
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
}
async function download(key: string, torrent_url: string | null, magnet: string | null) {
if (downloading.value.has(key) || downloaded.value.has(key)) return
downloading.value = new Set([...downloading.value, key])
const result = await store.download(torrent_url, magnet)
downloading.value = new Set([...downloading.value].filter(k => k !== key))
if (result.success) {
downloaded.value = new Set([...downloaded.value, key])
showToast('success', 'Téléchargement lancé')
fetchActiveDownloads()
} else {
toast('Erreur lors de la suppression', 'error')
showToast('error', result.error ?? 'Erreur inconnue')
}
}
// ── Modal ──────────────────────────────────────────────────────
function openAddModal() {
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
function load() {
store.fetchSerieDetail(Number(route.params.id))
}
function closeModal() {
modal.value.open = false
}
onMounted(() => {
load()
fetchActiveDownloads()
pollTimer = setInterval(fetchActiveDownloads, 5000)
})
function onTypeChange() {
modal.value.config = {}
modal.value.tested = false
const def = currentDefinition.value
if (def) {
for (const field of def.fields) {
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
}
}
}
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
async function testNewClient() {
modal.value.testing = true
modal.value.tested = false
try {
const res = await fetch('/api/torrent-clients/test-config', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
})
const { ok, message } = await res.json()
modal.value.tested = ok
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
modal.value.testing = false
}
}
async function saveClient() {
if (!modal.value.tested) return
modal.value.saving = true
try {
const res = await fetch('/api/torrent-clients', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name : modal.value.name || clientLabel(modal.value.type),
type : modal.value.type,
config: modal.value.config
})
})
if (res.ok) {
const client = await res.json()
clients.value.push(client)
refreshHealth(client.uuid)
closeModal()
toast('Client enregistré ✓', 'success')
} else {
const { error } = await res.json()
toast(error ?? "Erreur lors de l'enregistrement", 'error')
}
} finally {
modal.value.saving = false
}
}
// ── Données torrents ───────────────────────────────────────────
async function update() {
updating.value = true
try {
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
if (res.ok) {
const { count } = await res.json()
torrentsStatus.value = { exists: true, count, empty: count === 0 }
toast(`${count} torrents téléchargés ✓`, 'success')
} else {
const { error } = await res.json()
toast(error ?? 'Erreur lors de la mise à jour', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
updating.value = false
}
}
// ── Scan bibliothèque ──────────────────────────────────────────
async function scan() {
scanning.value = true
scanResult.value = null
try {
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
if (res.ok) {
const data = await res.json()
scanResult.value = { found: data.found, added: data.added }
toast(
data.added > 0
? `${data.found} fichiers scannés — ${data.added} ajoutés`
: `${data.found} fichiers scannés — rien de nouveau`,
'success'
)
} else {
toast('Erreur lors du scan', 'error')
}
} catch {
toast('Impossible de contacter le serveur', 'error')
} finally {
scanning.value = false
}
}
// ── Sauvegarder settings ───────────────────────────────────────
async function save() {
saving.value = true
try {
const res = await fetch('/api/settings', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(form.value)
})
if (res.ok) toast('Configuration sauvegardée', 'success')
else toast('Erreur lors de la sauvegarde', 'error')
} finally {
saving.value = false
}
}
async function logout() {
await auth.logout()
await router.push('/auth')
}
</script>
<style scoped>
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
</style>
+12 -26
View File
@@ -91,19 +91,12 @@
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button v-if="!isDocker"
@click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left text-white hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate">
{{ form.completePath || '/downloads/complete' }}
<button @click="openPicker('completePath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.completePath || '/' }}
</button>
<div v-else
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-[#5a7a94] opacity-60 font-mono truncate">
{{ form.completePath || '/downloads/complete' }}
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier de complétion de qBittorrent</span>
</p>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
</div>
<!-- Dossier Jellyfin -->
@@ -112,19 +105,12 @@
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
</div>
<button v-if="!isDocker"
@click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left text-white hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate">
{{ form.mediaPath || '/media/Kai' }}
<button @click="openPicker('mediaPath')"
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
{{ form.mediaPath || '/' }}
</button>
<div v-else
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-[#5a7a94] opacity-60 font-mono truncate">
{{ form.mediaPath || '/media/Kai' }}
</div>
<p class="text-[10px] text-[#5a7a94] mt-1.5">
<span v-if="isDocker">Défini par le volume Docker — non modifiable ici</span>
<span v-else>Dossier racine de la bibliothèque Fankai dans Jellyfin</span>
</p>
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
</div>
<!-- Mode organisation -->
@@ -269,8 +255,8 @@ const { add: toast } = useToast()
// ── State ──────────────────────────────────────────────────────
const form = ref({
mediaPath : '/media/Kai',
completePath: '/downloads/complete',
mediaPath : '',
completePath: '',
organizeMode: 'hardlink' as 'hardlink' | 'move',
nfoSupport : false,
})
+5 -2
View File
@@ -10,10 +10,13 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
build: {
outDir: 'dist/client'
},
server: {
port: 3000,
port: 3000, // ou autre port fixe
proxy: {
'/api': 'http://localhost:3001'
'/api': 'http://localhost:9898'
}
}
})