Compare commits

...
89 Commits
Author SHA1 Message Date
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
20 changed files with 642 additions and 230 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ node_modules
.git .git
.gitignore .gitignore
*.md *.md
data config
.env .env
.env.* .env.*
!.env.example !.env.example
+5 -2
View File
@@ -97,16 +97,19 @@ jobs:
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }} bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
shell: pwsh shell: pwsh
- name: Build worker
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
- name: Package archive (Windows) - name: Package archive (Windows)
if: matrix.target == 'windows-x64' if: matrix.target == 'windows-x64'
run: Compress-Archive -Path ${{ matrix.binary }}, public -DestinationPath ${{ matrix.archive }} run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
shell: pwsh shell: pwsh
- name: Package archive (Unix) - name: Package archive (Unix)
if: matrix.target != 'windows-x64' if: matrix.target != 'windows-x64'
run: | run: |
chmod +x ${{ matrix.binary }} chmod +x ${{ matrix.binary }}
zip -r ${{ matrix.archive }} ${{ matrix.binary }} public/ zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
- name: Upload archive to Release - name: Upload archive to Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
+264
View File
@@ -1,5 +1,269 @@
# Changelog # Changelog
## [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) ## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
+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 --from=build /app/dist/client /app/public
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN mkdir -p /app/data RUN mkdir -p /config
EXPOSE 3001 EXPOSE 3001
ENV NODE_ENV=production ENV NODE_ENV=production
+29 -27
View File
@@ -2,7 +2,6 @@
# Fankarr # Fankarr
![Version](https://img.shields.io/github/v/release/masutayunikon/fankarr?style=flat-square&color=e8513a&label=version) ![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 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) ![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) ![Node](https://img.shields.io/badge/node-20-5a7a94?style=flat-square)
![Vue](https://img.shields.io/badge/vue-3-42b883?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 Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
</div> </div>
@@ -83,11 +82,10 @@ services:
- PGID=1000 # GID de votre utilisateur (id -g) - PGID=1000 # GID de votre utilisateur (id -g)
- TZ=Europe/Paris # Votre timezone - TZ=Europe/Paris # Votre timezone
volumes: volumes:
- ./data:/app/data # Config, logs, base de données - ./config:/config # Config, logs, base de données
- /votre/chemin/media:/media/Kai # Destination Jellyfin - /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
ports: ports:
- 3001:3001 - 9898:9898
restart: unless-stopped restart: unless-stopped
``` ```
@@ -95,15 +93,19 @@ services:
docker compose up -d docker compose up -d
``` ```
> **⚠️ 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) ### 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) : Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
| Système | Archive | | Système | Archive |
|---|---| | --------------------- | ------------------------- |
| Windows x64 | `fankarr-windows-x64.zip` | | Windows x64 | `fankarr-windows-x64.zip` |
| Linux x64 | `fankarr-linux-x64.zip` | | Linux x64 | `fankarr-linux-x64.zip` |
| macOS (Apple Silicon) | `fankarr-macos.zip` | | macOS (Apple Silicon) | `fankarr-macos.zip` |
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble : Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
@@ -120,22 +122,22 @@ chmod +x fankarr
./fankarr ./fankarr
``` ```
Fankarr sera accessible sur `http://localhost:3001`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire. 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 ### Variables d'environnement
| Variable | Défaut | Description | | Variable | Défaut | Description |
|---|---|---| | ---------------- | ------------ | ----------------------------------------------------------------------- |
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) | | `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) | | `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
| `TZ` | — | Timezone (ex: `Europe/Paris`) | | `TZ` | — | Timezone (ex: `Europe/Paris`) |
| `PORT` | `3001` | Port d'écoute du serveur | | `PORT` | `9898` | Port d'écoute du serveur |
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent | | `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 | | `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
### Premier démarrage ### Premier démarrage
1. Ouvrir `http://localhost:3001` 1. Ouvrir `http://localhost:9898`
2. Créer un mot de passe à la première connexion 2. Créer un mot de passe à la première connexion
3. Aller dans **Paramètres** → ajouter votre client qBittorrent 3. Aller dans **Paramètres** → ajouter votre client qBittorrent
4. Vérifier les chemins `completePath` et `mediaPath` 4. Vérifier les chemins `completePath` et `mediaPath`
@@ -173,12 +175,12 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
## Stack technique ## Stack technique
| Couche | Technologie | | Couche | Technologie |
|---|---| | ----------------- | ---------------------------------------- |
| Frontend | Vue 3 + Vite + Tailwind CSS | | Frontend | Vue 3 + Vite + Tailwind CSS |
| Backend | Express 5 + TypeScript | | Backend | Express 5 + TypeScript |
| Auth | JWT + bcrypt | | Auth | JWT + bcrypt |
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) | | Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
| Packaging binaire | Bun (self-contained, pas de Node requis) | | Packaging binaire | Bun (self-contained, pas de Node requis) |
--- ---
+14 -15
View File
@@ -1,15 +1,14 @@
services: fankarr:
fankarr: image: masutayunikon/fankarr:latest
image: masutayunikon/fankarr:latest container_name: fankarr
container_name: fankarr environment:
environment: - PUID=1000 # UID de votre utilisateur (id -u)
- PUID=1000 # UID de votre utilisateur (id -u) - PGID=1000 # GID de votre utilisateur (id -g)
- PGID=1000 # GID de votre utilisateur (id -g) - TZ=Europe/Paris # Votre timezone
- TZ=Europe/Paris # Votre timezone volumes:
volumes: - ./config:/config # Config, logs, base de données
- ./data:/app/data # Config, logs, base de données - /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
- /votre/chemin/media:/media/Kai # Destination Jellyfin ports:
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent - 9898:9898
ports: restart: unless-stopped
- 3001:3001
restart: unless-stopped
+1 -1
View File
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
echo "[fankarr] Running as UID=${PUID} GID=${PGID}" echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
# Corriger les permissions du dossier data # Corriger les permissions du dossier data
chown -R "$PUID:$PGID" /app/data chown -R "$PUID:$PGID" /config
exec gosu "$PUID:$PGID" node dist/server/index.js exec gosu "$PUID:$PGID" node dist/server/index.js
+7 -7
View File
@@ -8,16 +8,15 @@
"dev:server": "tsx watch server/index.ts", "dev:server": "tsx watch server/index.ts",
"dev:client": "vite", "dev:client": "vite",
"build": "vite build && 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", "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", "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", "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", "build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
"start": "node dist/server/index.js", "start": "node dist/server/index.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.2.1", "@tailwindcss/vite": "^4.2.1",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@@ -25,10 +24,10 @@
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"vue": "^3.5.25", "vue": "^3.5.25",
"vue-router": "^5.0.3" "vue-router": "^5.0.3",
"bcryptjs": "^3.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.9", "@types/jsonwebtoken": "^9.0.9",
@@ -39,6 +38,7 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "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': '@tailwindcss/vite':
specifier: ^4.2.1 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)) 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: bcryptjs:
specifier: ^6.0.0 specifier: ^3.0.3
version: 6.0.0 version: 3.0.3
cookie-parser: cookie-parser:
specifier: ^1.4.7 specifier: ^1.4.7
version: 1.4.7 version: 1.4.7
@@ -650,9 +650,9 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
bcrypt@6.0.0: bcryptjs@3.0.3:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
engines: {node: '>= 18'} hasBin: true
birpc@2.9.0: birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -1087,10 +1087,6 @@ packages:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'} 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: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -1100,10 +1096,6 @@ packages:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 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: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1919,10 +1911,7 @@ snapshots:
'@babel/parser': 7.29.0 '@babel/parser': 7.29.0
ast-kit: 2.2.0 ast-kit: 2.2.0
bcrypt@6.0.0: bcryptjs@3.0.3: {}
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
birpc@2.9.0: {} birpc@2.9.0: {}
@@ -2351,8 +2340,6 @@ snapshots:
negotiator@1.0.0: {} negotiator@1.0.0: {}
node-addon-api@8.6.0: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-fetch@3.3.2: node-fetch@3.3.2:
@@ -2361,8 +2348,6 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-gyp-build@4.8.4: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
on-finished@2.4.1: on-finished@2.4.1:
+3 -2
View File
@@ -1,11 +1,12 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import bcrypt from 'bcrypt' import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
import type { Request, Response, NextFunction } from 'express' import type { Request, Response, NextFunction } from 'express'
import { JWT_SECRET } from './secret.js' import { JWT_SECRET } from './secret.js'
import { DATA_DIR } from './config.js'
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json') const DATA_PATH = path.join(DATA_DIR, 'auth.json')
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
interface AuthData { 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')
+115 -59
View File
@@ -12,22 +12,14 @@ import {
import qbittorrentDriver from './torrent-clients/qbittorrent.js' import qbittorrentDriver from './torrent-clients/qbittorrent.js'
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js' import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js' import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
import { DATA_DIR, BASE_DIR } from './config.js'
registerDriver(qbittorrentDriver) registerDriver(qbittorrentDriver)
// ── Base directory ─────────────────────────────────────────────
// Binaire Bun compilé : data/ et public/ sont à côté de l'exécutable
// Docker / dev : relatif au cwd (comportement inchangé)
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
&& path.dirname((process as any).execPath) !== process.cwd()
export const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
const app = express() const app = express()
const PORT = Number(process.env.PORT) || 3001 const PORT = Number(process.env.PORT) || 9898
const FANKAI_API = 'https://metadata.fankai.fr' const FANKAI_API = 'https://metadata.fankai.fr'
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json') const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json')
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json' ?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
@@ -107,6 +99,15 @@ async function fankaiGet(endpoint: string): Promise<any> {
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`) if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
return res.json() return res.json()
} }
function normalizeTitle(s: string): string {
return s.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // strip accents (ï→i, é→e...)
.replace(/[^a-z0-9]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function indexBySerie(torrents: any[]): Map<number, any[]> { function indexBySerie(torrents: any[]): Map<number, any[]> {
const map = new Map<number, any[]>() const map = new Map<number, any[]>()
for (const t of torrents) { for (const t of torrents) {
@@ -117,49 +118,65 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
return map return map
} }
function indexByNormalizedTitle(torrents: any[]): Map<string, any[]> {
const map = new Map<string, any[]>()
for (const t of torrents) {
const title = t.serie_title || t.show_title
if (!title) continue
const key = normalizeTitle(title)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(t)
}
return map
}
// ── Series ───────────────────────────────────────────────────── // ── Series ─────────────────────────────────────────────────────
function computeSerieDownloadState( function computeSerieDownloadState(
serieTorrents : any[], serieTorrents : any[],
organized : Record<string, Record<string, string>>, organized : Record<string, Record<string, string>>,
activeTorrents: Set<string> // hashes actuellement dans qB en downloading activeTorrents: Set<string>
): 'none' | 'downloading' | 'partial' | 'complete' { ): 'none' | 'downloading' | 'partial' | 'complete' {
if (serieTorrents.length === 0) return 'none' if (serieTorrents.length === 0) return 'none'
// Un torrent de la série est en cours de téléchargement ?
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase())) const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
if (hasActive) return 'downloading' if (hasActive) return 'downloading'
// Compter les fichiers attendus vs organisés // Collecter tous les épisodes uniques de la série (par episode_id)
let totalFiles = 0 const allEpisodeIds = new Set<number>()
let doneFiles = 0 const organizedEpisodeIds = new Set<number>()
for (const t of serieTorrents) { for (const t of serieTorrents) {
const hash = t.infohash?.toLowerCase()
const orgFiles = organized[hash] ?? {}
const files: any[] = t.torrent_files ?? [] const files: any[] = t.torrent_files ?? []
if (files.length === 0) {
// Fichier unique for (const ep of t.resolved_episodes ?? []) {
totalFiles++ allEpisodeIds.add(ep.episode_id)
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++ // Cherche le fichier correspondant à cet épisode dans torrent_files
} else { const epFile = files.find((f: any) => f.filename === ep.filename)
totalFiles += files.length if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length organizedEpisodeIds.add(ep.episode_id)
}
} }
} }
if (totalFiles === 0) return 'none' if (allEpisodeIds.size === 0) return 'none'
if (doneFiles === 0) return 'none' if (organizedEpisodeIds.size === 0) return 'none'
if (doneFiles >= totalFiles) return 'complete' if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
return 'partial' return 'partial'
} }
app.get('/api/series', requireAuth, async (_req, res) => { app.get('/api/series', requireAuth, async (_req, res) => {
try { try {
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())]) const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
const byId = indexBySerie(torrents) const byId = indexBySerie(torrents)
const byNormTitle = indexByNormalizedTitle(torrents)
// Charger organized.json // Charger organized.json
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
@@ -175,9 +192,29 @@ app.get('/api/series', requireAuth, async (_req, res) => {
} }
} catch {} } catch {}
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? []) const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
// Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente
const seenTitles = new Map<string, any>()
for (const serie of seriesRaw) {
const key = normalizeTitle(serie.title ?? serie.show_title ?? '')
if (!seenTitles.has(key)) {
seenTitles.set(key, serie)
} else {
const existing = seenTitles.get(key)!
const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0
const newHasTorrents = (byId.get(serie.id) ?? byNormTitle.get(key) ?? []).length > 0
if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie)
else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie)
}
}
const seriesList = [...seenTitles.values()]
res.json({ series: seriesList.map((serie: any) => { res.json({ series: seriesList.map((serie: any) => {
const serieTorrents = byId.get(serie.id) ?? [] // Fallback par titre normalisé si l'ID ne matche pas (Kaï→Kai migration)
const serieTorrents = byId.get(serie.id)
?? byNormTitle.get(normalizeTitle(serie.title ?? serie.show_title ?? ''))
?? []
return { return {
...serie, ...serie,
torrent_count : serieTorrents.length, torrent_count : serieTorrents.length,
@@ -200,19 +237,32 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) } return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
})) }))
const torrents = readTorrents().filter(t => t.serie_id === id) const allTorrents = readTorrents()
const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '')
const torrents = allTorrents.filter(t =>
t.serie_id === id ||
normalizeTitle(t.serie_title ?? t.show_title ?? '') === normApiTitle
)
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id)) const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
const availableEpisodeIds = new Set<number>() const availableEpisodeIds = new Set<number>()
const availableBySnEn = new Set<string>() // "sn:en"
const episodeTorrentMap: Record<number, any> = {} const episodeTorrentMap: Record<number, any> = {}
const seasonTorrentMap : Record<number, any> = {} const episodeTorrentMapBySnEn: Record<string, any> = {}
const seasonTorrentMap : Record<number, any> = {} // keyed by season_id (scraper)
const seasonTorrentMapBySn: Record<number, any> = {} // keyed by season_number (fallback)
const integraleTorrents: any[] = [] const integraleTorrents: any[] = []
const packEpisodesTorrents: any[] = [] const packEpisodesTorrents: any[] = []
for (const t of torrents) { for (const t of torrents) {
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw } const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw, manual: t.manual ?? false }
for (const ep of t.resolved_episodes ?? []) { for (const ep of t.resolved_episodes ?? []) {
availableEpisodeIds.add(ep.episode_id) availableEpisodeIds.add(ep.episode_id)
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref const snEnKey = `${ep.season_number}:${ep.episode_number}`
availableBySnEn.add(snEnKey)
if (t.type === 'episode') {
episodeTorrentMap[ep.episode_id] = ref
episodeTorrentMapBySnEn[snEnKey] = ref
}
} }
if (t.type === 'pack_saison') { if (t.type === 'pack_saison') {
const rs: any[] = t.resolved_seasons ?? [] const rs: any[] = t.resolved_seasons ?? []
@@ -221,6 +271,8 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
} else { } else {
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id] 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 } for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
const sns = rs.length > 0 ? rs.map((r: any) => r.season_number) : [t.season_number]
for (const sn of sns) { if (sn != null) seasonTorrentMapBySn[sn] = ref }
} }
} else if (t.type === 'pack_integrale') { } else if (t.type === 'pack_integrale') {
integraleTorrents.push(t) integraleTorrents.push(t)
@@ -229,6 +281,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
} }
} }
// ── DEBUG ──────────────────────────────────────────────
console.log(`[debug /api/series/${id}] serie="${serie.title}" allSeasonIds=`, [...allSeasonIds])
console.log(`[debug /api/series/${id}] seasonTorrentMap=`, seasonTorrentMap)
console.log(`[debug /api/series/${id}] seasons from API:`, seasonsWithEpisodes.map((s: any) => ({ id: s.id, season_number: s.season_number, title: s.title })))
console.log(`[debug /api/series/${id}] torrents matched:`, torrents.map(t => ({ raw: t.raw, type: t.type, season_id: t.season_id, resolved_seasons: t.resolved_seasons })))
// ── END DEBUG ──────────────────────────────────────────
let promotedIntegrale: any = null let promotedIntegrale: any = null
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) { if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
for (const t of torrents.filter(t => t.type === 'pack_saison')) { for (const t of torrents.filter(t => t.type === 'pack_saison')) {
@@ -265,27 +324,25 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
// Charger organized.json pour les badges d'état // Charger organized.json pour les badges d'état
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
// Construire un set des episode_id organisés
// On croise resolved_episodes de chaque torrent avec organized.json
const organizedEpisodeIds = new Set<number>() const organizedEpisodeIds = new Set<number>()
const organizedBySnEn = new Set<string>()
for (const t of torrents) { for (const t of torrents) {
const hash = t.infohash?.toLowerCase() const hash = t.infohash?.toLowerCase()
const files = t.torrent_files ?? [] const files = t.torrent_files ?? []
const orgFiles = organized[hash] ?? {} const orgFiles = organized[hash] ?? {}
if (Object.keys(orgFiles).length === 0) continue if (Object.keys(orgFiles).length === 0) continue
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
for (const ep of t.resolved_episodes ?? []) { for (const ep of t.resolved_episodes ?? []) {
// Vérifier si le fichier de cet épisode est organisé
const epFile = files.find((f: any) => const epFile = files.find((f: any) =>
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
) )
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) { if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
organizedEpisodeIds.add(ep.episode_id) organizedEpisodeIds.add(ep.episode_id)
organizedBySnEn.add(`${ep.season_number}:${ep.episode_number}`)
} }
} }
} }
@@ -293,11 +350,14 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => { const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
const eps = season.episodes.map((ep: any) => ({ const eps = season.episodes.map((ep: any) => ({
...ep, ...ep,
available : availableEpisodeIds.has(ep.id), available : availableEpisodeIds.has(ep.id)
torrent : episodeTorrentMap[ep.id] ?? null, || availableBySnEn.has(`${season.season_number}:${ep.episode_number}`),
organized : organizedEpisodeIds.has(ep.id), torrent : episodeTorrentMap[ep.id]
?? episodeTorrentMapBySnEn[`${season.season_number}:${ep.episode_number}`]
?? null,
organized : organizedEpisodeIds.has(ep.id)
|| organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`),
})) }))
// State saison : none / partial / complete
const total = eps.filter((e: any) => e.available).length const total = eps.filter((e: any) => e.available).length
const orgCount = eps.filter((e: any) => e.organized).length const orgCount = eps.filter((e: any) => e.organized).length
const seasonOrganizedState = const seasonOrganizedState =
@@ -305,7 +365,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
orgCount >= total && total > 0 ? 'complete' : 'partial' orgCount >= total && total > 0 ? 'complete' : 'partial'
return { return {
...season, ...season,
torrent : seasonTorrentMap[season.id] ?? null, torrent : seasonTorrentMap[season.id] ?? seasonTorrentMapBySn[season.season_number] ?? null,
organized_state : seasonOrganizedState, organized_state : seasonOrganizedState,
organized_count : orgCount, organized_count : orgCount,
episodes : eps, episodes : eps,
@@ -338,17 +398,16 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
const { category } = readSettings() const { category } = readSettings()
const torrents = await dispatchList(category ?? 'fankai') const torrents = await dispatchList(category ?? 'fankai')
// Enrichir avec l'état d'organisation
let organized: Record<string, Record<string, string>> = {} let organized: Record<string, Record<string, string>> = {}
try { try {
const orgPath = path.join(process.cwd(), 'data', 'organized.json') const orgPath = path.join(DATA_DIR, 'organized.json')
if (fs.existsSync(orgPath)) if (fs.existsSync(orgPath))
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
} catch {} } catch {}
let torrentFinal: any[] = [] let torrentFinal: any[] = []
try { try {
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json') const tfPath = path.join(DATA_DIR, 'torrent_final.json')
if (fs.existsSync(tfPath)) if (fs.existsSync(tfPath))
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8')) torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
} catch {} } catch {}
@@ -364,7 +423,6 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
if (doneFiles >= totalFiles) organizeState = 'done' if (doneFiles >= totalFiles) organizeState = 'done'
else if (doneFiles > 0) organizeState = 'partial' else if (doneFiles > 0) organizeState = 'partial'
// Récupérer les erreurs depuis recentOrganized
const notif = recentOrganized.find(n => n.hash === t.hash) const notif = recentOrganized.find(n => n.hash === t.hash)
const errorFiles = notif?.errorFiles ?? [] const errorFiles = notif?.errorFiles ?? []
@@ -377,7 +435,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
} }
}) })
// ── Résultats d'organisation récents (pour notifications frontend) ── // ── Résultats d'organisation récents ──────────────────────────
interface OrganizeNotif { interface OrganizeNotif {
hash : string hash : string
name : string name : string
@@ -399,13 +457,11 @@ app.get('/api/organize/recent', requireAuth, (_req, res) => {
res.json(recentOrganized) res.json(recentOrganized)
}) })
// Vider les notifs lues
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => { app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
recentOrganized.length = 0 recentOrganized.length = 0
res.json({ ok: true }) res.json({ ok: true })
}) })
app.post('/api/organize', requireAuth, async (req, res) => { app.post('/api/organize', requireAuth, async (req, res) => {
const { hash, name, save_path } = req.body const { hash, name, save_path } = req.body
if (!hash || !name || !save_path) { if (!hash || !name || !save_path) {
@@ -420,7 +476,6 @@ app.post('/api/organize', requireAuth, async (req, res) => {
} }
}) })
// ── Torrents status ────────────────────────────────────────────
// ── Logs ────────────────────────────────────────────────────── // ── Logs ──────────────────────────────────────────────────────
app.get('/api/logs', requireAuth, (req, res) => { app.get('/api/logs', requireAuth, (req, res) => {
const limit = Number(req.query.limit) || 100 const limit = Number(req.query.limit) || 100
@@ -487,7 +542,7 @@ app.post('/api/update', requireAuth, async (_req, res) => {
app.post('/api/scan', requireAuth, async (_req, res) => { app.post('/api/scan', requireAuth, async (_req, res) => {
try { try {
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH) const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`) logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
res.json({ ok: true, ...result }) res.json({ ok: true, ...result })
@@ -496,7 +551,7 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
} }
}) })
// ── SPA fallback (prod) — doit être après toutes les routes API ─ // ── SPA fallback (prod) ────────────────────────────────────────
if (fs.existsSync(PUBLIC_PATH)) { if (fs.existsSync(PUBLIC_PATH)) {
app.get('*path', (_req, res) => { app.get('*path', (_req, res) => {
res.sendFile(path.join(PUBLIC_PATH, 'index.html')) res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
@@ -530,13 +585,14 @@ app.listen(PORT, async () => {
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`) console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents // Scan initial du mediaPath pour reconnaître les fichiers déjà présents
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json') const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
const { mediaPath } = readSettings() const { mediaPath } = readSettings()
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err => scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
logger.error('organize', `scanMediaPath échoué: ${err}`) logger.error('organize', `scanMediaPath échoué: ${err}`)
) )
// Auto-organise toutes les 30s // Auto-organise — polling différentiel toutes les 5 minutes
// Le worker ne se lance que si un torrent vient de passer en seeding
const autoOrganize = async () => { const autoOrganize = async () => {
try { try {
const { category } = readSettings() const { category } = readSettings()
@@ -552,9 +608,9 @@ app.listen(PORT, async () => {
console.error('[organize] Erreur auto-organise:', err) console.error('[organize] Erreur auto-organise:', err)
} }
} }
// Premier run 30s après démarrage, puis toutes les 30s // Premier run 10s après démarrage, puis toutes les 5 minutes
setTimeout(() => { setTimeout(() => {
autoOrganize() autoOrganize()
setInterval(autoOrganize, 30_000) setInterval(autoOrganize, 5 * 60_000)
}, 30_000) }, 10_000)
}) })
+2 -1
View File
@@ -8,6 +8,7 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { DATA_DIR } from './config.js'
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
@@ -19,7 +20,7 @@ export interface LogEntry {
meta? : Record<string, any> meta? : Record<string, any>
} }
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl') const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
const MAX_LINES = 2000 // rotation auto au-delà const MAX_LINES = 2000 // rotation auto au-delà
const IS_PROD = process.env.NODE_ENV === 'production' const IS_PROD = process.env.NODE_ENV === 'production'
+68 -44
View File
@@ -15,31 +15,22 @@ import fs from 'fs'
import fsp from 'fs/promises' import fsp from 'fs/promises'
import path from 'path' import path from 'path'
import { parentPort } from 'worker_threads' import { parentPort } from 'worker_threads'
import { DATA_DIR } from './config.js'
// Base directory : binaire Bun ou cwd (Docker/dev) const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json')
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
&& path.dirname((process as any).execPath) !== process.cwd()
const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json')
const ORGANIZED_PATH = path.join(BASE_DIR, 'data', 'organized.json')
// ─── Utils log ──────────────────────────────────────────────── // ─── Utils log ────────────────────────────────────────────────
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
// qui les écrit dans le fichier via logger.ts
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) } function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) } function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) } function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) } function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
// ─── Settings (lu directement, pas d'import circulaire) ────── // ─── Settings ─────────────────────────────────────────────────
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } { function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
try { try {
const p = path.join(process.cwd(), 'data', 'settings.json') const p = path.join(DATA_DIR, 'settings.json')
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
return JSON.parse(fs.readFileSync(p, 'utf-8')) return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } } } catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
@@ -112,8 +103,6 @@ function getGitlabSerieTitle(serieTitle: string): string {
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
} }
// ─── GitLab NFO downloader ──────────────────────────────────── // ─── GitLab NFO downloader ────────────────────────────────────
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository' const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
@@ -139,18 +128,24 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`) log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
// Lister récursivement tous les fichiers du dossier let files: any[] = []
let files: any[]
try { try {
files = await fetchJson( let page = 1
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main` 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) { } catch (err) {
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`) warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
return return
} }
if (!Array.isArray(files) || files.length === 0) { if (files.length === 0) {
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`) warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
return return
} }
@@ -159,13 +154,10 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
log(`[nfo] ${fileEntries.length} fichiers à télécharger`) log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
for (const entry of fileEntries) { for (const entry of fileEntries) {
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '') const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
const destPath = path.join(destRoot, relativePath) const destPath = path.join(destRoot, relativePath)
const destDir = path.dirname(destPath) const destDir = path.dirname(destPath)
// Skip si déjà présent
if (fs.existsSync(destPath)) continue if (fs.existsSync(destPath)) continue
try { try {
@@ -218,8 +210,28 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
// ── Téléchargement NFO/images depuis GitLab ─────────────── // ── Téléchargement NFO/images depuis GitLab ───────────────
if (nfoSupport) { if (nfoSupport) {
const serieDestRoot = path.join(mediaPath, serieTitle) const serieTitlesMap = new Map<number, string>()
await downloadGitlabFolder(serieTitle, serieDestRoot) serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle)
const allTorrents: any[] = (() => {
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
})()
const serieIdToTitle = new Map<number, string>()
for (const t of allTorrents) {
if (t.serie_id && t.serie_title) serieIdToTitle.set(t.serie_id, t.serie_title)
}
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.serie_id && !serieTitlesMap.has(ep.serie_id)) {
const title = ep.serie_title ?? serieIdToTitle.get(ep.serie_id)
if (title) serieTitlesMap.set(ep.serie_id, title)
}
}
for (const title of serieTitlesMap.values()) {
const serieDestRoot = path.join(mediaPath, title)
await downloadGitlabFolder(title, serieDestRoot)
}
} }
// ── Cas fichier unique ──────────────────────────────────── // ── Cas fichier unique ────────────────────────────────────
@@ -256,28 +268,38 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
} }
// ── Map filename → season ───────────────────────────────── // ── Map filename → season ─────────────────────────────────
// Priorité : season_number dans torrent_files (packs multi-saisons)
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
const filenameSeasonMap = new Map<string, number>() const filenameSeasonMap = new Map<string, number>()
// Index resolved_episodes par episode_number pour lookup rapide // Index par filename depuis resolved_episodes (évite les collisions episode_number)
const epByNum = new Map<number, number>() // ep_number → season_number const filenameToSeason = new Map<string, number>()
for (const ep of torrent.resolved_episodes ?? []) { for (const ep of torrent.resolved_episodes ?? []) {
if (ep.episode_number !== undefined && ep.season_number !== undefined) if (ep.filename && ep.season_number !== undefined)
epByNum.set(ep.episode_number, ep.season_number) filenameToSeason.set(ep.filename, ep.season_number)
} }
for (const tf of torrentFiles) { for (const tf of torrentFiles) {
const sn: number | null = tf.season_number ?? null // Priorité 1 : resolved_episodes (saison API Fankai)
if (sn !== null) { const fromResolved = filenameToSeason.get(tf.filename)
filenameSeasonMap.set(tf.filename, sn) if (fromResolved !== undefined) {
} else if (tf.num !== undefined && epByNum.has(tf.num)) { filenameSeasonMap.set(tf.filename, fromResolved)
filenameSeasonMap.set(tf.filename, epByNum.get(tf.num)!) // Priorité 2 : season_number dans torrent_files (dossier Saison/Partie)
} else if (tf.season_number != null) {
filenameSeasonMap.set(tf.filename, Number(tf.season_number))
// Priorité 3 : season_number du torrent
} else if (torrent.season_number !== undefined) { } else if (torrent.season_number !== undefined) {
filenameSeasonMap.set(tf.filename, torrent.season_number) filenameSeasonMap.set(tf.filename, torrent.season_number)
} }
} }
// ── Map filename → original_filename (depuis l'API) ────────
// Permet de renommer le fichier selon le nom standardisé Fankai
const filenameToOriginal = new Map<string, string>()
for (const ep of torrent.resolved_episodes ?? []) {
if (ep.filename && ep.original_filename && ep.filename !== ep.original_filename) {
filenameToOriginal.set(ep.filename, ep.original_filename)
}
}
result.total = torrentFiles.length result.total = torrentFiles.length
for (const tf of torrentFiles) { for (const tf of torrentFiles) {
@@ -285,7 +307,9 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
const filePath: string[] = tf.path const filePath: string[] = tf.path
// Skip les fichiers dans des dossiers bonus/musique/images // Skip les fichiers dans des dossiers bonus/musique/images
if (isInExcludedFolder(filePath)) { // Exception : fichiers avec season_number explicite (saison 0)
const tfMeta = torrentFiles.find((tf: any) => tf.filename === filename)
if (isInExcludedFolder(filePath) && tfMeta?.season_number == null) {
log(`[organize] skip bonus: ${filename}`) log(`[organize] skip bonus: ${filename}`)
result.skipped++ result.skipped++
continue continue
@@ -315,9 +339,10 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
continue continue
} }
const seasonNum = filenameSeasonMap.get(filename) ?? 1 const seasonNum = filenameSeasonMap.get(filename) ?? 1
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum)) const destFilename = filenameToOriginal.get(filename) ?? filename
const dest = path.join(destDir, filename) const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
const dest = path.join(destDir, destFilename)
if (fs.existsSync(dest)) { if (fs.existsSync(dest)) {
markOrganized(hash, filename) markOrganized(hash, filename)
@@ -335,7 +360,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
} }
markOrganized(hash, filename) markOrganized(hash, filename)
result.done++ result.done++
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`) log(`[organize] ✓ ${destFilename} → Saison ${String(seasonNum).padStart(3,'0')}`)
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Erreur inconnue' const msg = err instanceof Error ? err.message : 'Erreur inconnue'
error(`[organize] ✗ ${filename}: ${msg}`) error(`[organize] ✗ ${filename}: ${msg}`)
@@ -365,7 +390,6 @@ parentPort?.on('message', async (msg: any) => {
} }
const files: any[] = torrent.torrent_files ?? [] const files: any[] = torrent.torrent_files ?? []
// Pour fichier unique : vérifier via le name directement
const allDone = files.length > 0 const allDone = files.length > 0
? files.every((f: any) => organized[t.hash]?.[f.filename]) ? files.every((f: any) => organized[t.hash]?.[f.filename])
: !!organized[t.hash]?.[t.name] : !!organized[t.hash]?.[t.name]
+73 -13
View File
@@ -13,6 +13,22 @@ import { logger } from './logger.js'
// Un seul worker à la fois // Un seul worker à la fois
let workerRunning = false let workerRunning = false
// État précédent des torrents pour détecter les passages downloading → seeding
const _prevStates = new Map<string, string>() // hash → state
// Résolution du chemin du worker
// En binaire Bun compilé : le worker est à côté de l'exécutable
// En Docker/dev : à côté du fichier courant
function resolveWorkerPath(): string {
// Si Bun est présent → le worker est toujours à côté de l'exécutable
// (que ce soit un binaire compilé ou `bun run` en dev)
// En Node/tsx → on utilise import.meta.url
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 { export interface OrganizeResult {
total : number total : number
skipped : number skipped : number
@@ -68,6 +84,9 @@ export async function scanMediaPath(
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8')) organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
} catch {} } catch {}
// Construire le set des fichiers présents sur le disque (pour nettoyage)
const presentFiles = new Set<string>() // "hash:filename"
// Parcourir récursivement mediaPath // Parcourir récursivement mediaPath
function walk(dir: string) { function walk(dir: string) {
let entries: fs.Dirent[] let entries: fs.Dirent[]
@@ -81,6 +100,7 @@ export async function scanMediaPath(
result.found++ result.found++
const hash = filenameIndex.get(entry.name) const hash = filenameIndex.get(entry.name)
if (!hash) continue if (!hash) continue
presentFiles.add(`${hash}:${entry.name}`)
if (organized[hash]?.[entry.name]) continue // déjà connu if (organized[hash]?.[entry.name]) continue // déjà connu
// Marquer comme organisé // Marquer comme organisé
if (!organized[hash]) organized[hash] = {} if (!organized[hash]) organized[hash] = {}
@@ -92,9 +112,28 @@ export async function scanMediaPath(
walk(mediaPath) walk(mediaPath)
if (result.added > 0) { // Nettoyer les entrées orphelines (fichiers supprimés)
let removed = 0
for (const [hash, files] of Object.entries(organized)) {
for (const filename of Object.keys(files)) {
if (!presentFiles.has(`${hash}:${filename}`)) {
// Vérifier que ce hash est bien dans le filenameIndex
// (évite de supprimer des entrées de torrents non-scannés)
if (filenameIndex.get(filename) === hash) {
delete organized[hash][filename]
removed++
}
}
}
// Supprimer l'entrée du torrent si elle est vide
if (Object.keys(organized[hash]).length === 0) {
delete organized[hash]
}
}
if (result.added > 0 || removed > 0) {
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8') fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`) logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés, ${removed} entrées orphelines supprimées`)
} else { } else {
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`) logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
} }
@@ -112,7 +151,7 @@ export async function autoOrganizeAll(
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
): Promise<void> { ): Promise<void> {
if (workerRunning) { if (workerRunning) {
logger.debug('organize', 'Worker déjà en cours, skip') console.log('[organize] Worker déjà en cours, skip')
return return
} }
@@ -120,27 +159,46 @@ export async function autoOrganizeAll(
try { try {
torrents = await listFn() torrents = await listFn()
} catch (err) { } 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 return
} }
if (torrents.length === 0) return if (torrents.length === 0) return
const isFirstRun = _prevStates.size === 0
const seedingCount = torrents.filter(t => t.state === 'seeding').length const seedingCount = torrents.filter(t => t.state === 'seeding').length
// Détecter les torrents qui viennent de passer en seeding (downloading → seeding)
const newlySeeding = torrents.filter(t => {
const prev = _prevStates.get(t.hash)
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
})
// Mettre à jour l'état précédent
for (const t of torrents) _prevStates.set(t.hash, t.state)
// Lancer seulement si : premier run avec des seeders, ou nouveau torrent terminé
if (!isFirstRun && newlySeeding.length === 0) return
if (seedingCount === 0) return if (seedingCount === 0) return
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 workerRunning = true
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js') const workerPath = resolveWorkerPath()
const worker = new Worker(workerPath) const worker = new Worker(workerPath)
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
if (msg.type === 'log') { 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') { } 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?.({ onResult?.({
hash : msg.hash, hash : msg.hash,
name : msg.name, name : msg.name,
@@ -150,19 +208,19 @@ export async function autoOrganizeAll(
errorFiles : msg.errors, errorFiles : msg.errors,
}) })
} else if (msg.type === 'done') { } else if (msg.type === 'done') {
logger.debug('organize', 'Worker terminé') console.log('[organize] Worker terminé')
workerRunning = false workerRunning = false
worker.terminate() worker.terminate()
} }
}) })
worker.on('error', (err) => { worker.on('error', (err) => {
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`) console.error('[organize] Worker erreur:', err)
workerRunning = false workerRunning = false
}) })
worker.on('exit', (code) => { worker.on('exit', (code) => {
if (code !== 0) logger.error('organize', `Worker exit code ${code}`) if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
workerRunning = false workerRunning = false
}) })
@@ -180,7 +238,7 @@ export async function organizeTorrent(
savePath: string savePath: string
): Promise<OrganizeResult> { ): Promise<OrganizeResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js') const workerPath = resolveWorkerPath()
const worker = new Worker(workerPath) const worker = new Worker(workerPath)
// On envoie un seul torrent "fictif seeding" // On envoie un seul torrent "fictif seeding"
@@ -189,7 +247,9 @@ export async function organizeTorrent(
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
if (msg.type === 'log') { 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') { } else if (msg.type === 'result') {
result.total = msg.total result.total = msg.total
result.skipped = msg.skipped result.skipped = msg.skipped
+2 -1
View File
@@ -9,8 +9,9 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import crypto from 'crypto' import crypto from 'crypto'
import { DATA_DIR } from './config.js'
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key') const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
function generateSecret(): string { function generateSecret(): string {
return crypto.randomBytes(48).toString('hex') return crypto.randomBytes(48).toString('hex')
+2 -6
View File
@@ -1,12 +1,8 @@
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { DATA_DIR } from './config.js'
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined' const DATA_PATH = path.join(DATA_DIR, 'settings.json')
&& path.dirname((process as any).execPath) !== process.cwd()
const BASE_DIR = _isBunBinary
? path.dirname((process as any).execPath)
: process.cwd()
const DATA_PATH = path.join(BASE_DIR, 'data', 'settings.json')
export interface Settings { export interface Settings {
mediaPath : string mediaPath : string
+2 -1
View File
@@ -9,6 +9,7 @@
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import { DATA_DIR } from '../config.js'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -77,7 +78,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
// ─── Persistence (clients enregistrés) ──────────────────────────────────────── // ─── Persistence (clients enregistrés) ────────────────────────────────────────
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json') const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
function loadClients(): SavedClient[] { function loadClients(): SavedClient[] {
try { try {
+31 -25
View File
@@ -27,7 +27,8 @@
<!-- Hero --> <!-- Hero -->
<div class="relative"> <div class="relative">
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none"> <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" /> <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 class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
</div> </div>
@@ -64,6 +65,7 @@
<button <button
v-for="(t, i) in data.torrents_integrale" v-for="(t, i) in data.torrents_integrale"
:key="i" :key="i"
:title="t.raw"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
:class="[ :class="[
t.label === 'Intégrale' t.label === 'Intégrale'
@@ -81,12 +83,10 @@
<!-- Barre progression intégrale --> <!-- Barre progression intégrale -->
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`"> <template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
<template v-if="torrentProgress(extractHash(t))"> <template v-if="torrentProgress(extractHash(t))">
<!-- Badge 100% -->
<span v-if="torrentProgress(extractHash(t))!.progress === 100" <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"> 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é ✓ Téléchargé
</span> </span>
<!-- Barre en cours -->
<div v-else class="mt-2 w-full max-w-sm"> <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"> <div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement</span> <span>Téléchargement</span>
@@ -160,16 +160,15 @@
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }} {{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
</button> </button>
</div> </div>
<!-- Barre progression saison --> <!-- Barre progression saison -->
<template v-if="torrentProgress(extractHash(season.torrent))"> <template v-if="torrentProgress(extractHash(season.torrent))">
<!-- Badge 100% -->
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100" <div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
class="px-5 py-2 border-b border-white/5"> 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"> <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é ✓ Téléchargé
</span> </span>
</div> </div>
<!-- Barre en cours -->
<div v-else class="px-5 py-2 border-b border-white/5"> <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"> <div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
<span>Téléchargement en cours</span> <span>Téléchargement en cours</span>
@@ -202,9 +201,9 @@
<!-- Info --> <!-- Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xs text-zinc-600 shrink-0 font-mono"> <span class="text-xs text-zinc-600 shrink-0 font-mono">
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }} {{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
</span> </span>
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span> <span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
</div> </div>
<div class="flex items-center gap-3 mt-0.5"> <div class="flex items-center gap-3 mt-0.5">
@@ -213,38 +212,48 @@
</div> </div>
</div> </div>
<!-- Bouton épisode (type episode uniquement) --> <!-- Badges + bouton download -->
<div class="shrink-0 flex items-center gap-2"> <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é --> <!-- Badge organisé -->
<span v-if="ep.organized" <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"> class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
✓ organisé ✓ organisé
</span> </span>
<!-- Badge téléchargé à 100% --> <!-- Badge téléchargé à 100% -->
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 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"> 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é ✓ téléchargé
</span> </span>
<!-- Badge dispo/indispo --> <!-- Badge dispo/indispo -->
<span <span
class="inline-block text-[10px] px-2 py-0.5 rounded-full border" class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
:class="ep.available :class="ep.available
? 'bg-blue-500/10 text-blue-400 border-blue-500/20' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-white/5 text-zinc-600 border-white/8'" : 'bg-white/5 text-zinc-600 border-white/8'"
> >
{{ ep.available ? 'dispo' : 'indispo' }} {{ ep.available ? 'dispo' : 'indispo' }}
</span> </span>
<!-- Bouton download --> <!-- Bouton download -->
<button <button
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
:class="[ :class="[
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized 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-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' : '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" :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)" @click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
> >
@@ -317,7 +326,7 @@ const store = useSeriesStore()
const collapsedSeasons = ref<Set<number>>(new Set()) const collapsedSeasons = ref<Set<number>>(new Set())
const downloading = ref<Set<string>>(new Set()) const downloading = ref<Set<string>>(new Set())
const downloaded = ref<Set<string>>(new Set()) // envoyés avec succès → bloqués const downloaded = ref<Set<string>>(new Set())
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null) const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
// ── Progression downloads ────────────────────────────────────── // ── Progression downloads ──────────────────────────────────────
@@ -339,13 +348,11 @@ async function fetchActiveDownloads() {
} catch {} } catch {}
} }
// Cherche si un torrent actif correspond à un hash donné
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null { function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
if (!hash) return null if (!hash) return null
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
} }
// Hash d'un torrent de saison ou d'épisode (extrait depuis magnet ou url)
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null { function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
if (!torrent) return null if (!torrent) return null
const magnet = torrent.magnet ?? '' const magnet = torrent.magnet ?? ''
@@ -375,7 +382,6 @@ function showToast(type: 'success' | 'error', message: string) {
setTimeout(() => { toast.value = null }, 3500) setTimeout(() => { toast.value = null }, 3500)
} }
// Vérifie si un torrent est déjà en cours ou terminé dans qBittorrent
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean { function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
const hash = extractHash(torrent) const hash = extractHash(torrent)
if (!hash) return false if (!hash) return false
+2 -2
View File
@@ -14,9 +14,9 @@ export default defineConfig({
outDir: 'dist/client' outDir: 'dist/client'
}, },
server: { server: {
port: 3000, port: 3000, // ou autre port fixe
proxy: { proxy: {
'/api': 'http://localhost:3001' '/api': 'http://localhost:9898'
} }
} }
}) })