Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9badada4a3 | ||
|
|
5e881272a1 | ||
|
|
a229a1eb3b | ||
|
|
b621adc6f6 | ||
|
|
7d3bc413ef | ||
|
|
6f9b7892b1 | ||
|
|
2332b544e6 | ||
|
|
3b091b7aa8 | ||
|
|
e1e430a968 | ||
|
|
2d49125e09 | ||
|
|
721047c95f | ||
|
|
74662de2b6 | ||
|
|
84ef0333f3 | ||
|
|
c6624843b9 | ||
|
|
8bd30913cd | ||
|
|
d7eb932ed3 | ||
|
|
4594dc5189 | ||
|
|
894a621e85 | ||
|
|
23dbf86e70 | ||
|
|
d86138ef1c | ||
|
|
4aa6f99676 | ||
|
|
10432e0f99 | ||
|
|
4f69f8bf92 | ||
|
|
1511dd3ca3 | ||
|
|
23967a81d6 | ||
|
|
21ff025376 | ||
|
|
9ba19f9539 | ||
|
|
215d802b12 | ||
|
|
21dccbe5d9 | ||
|
|
329150f1c4 | ||
|
|
17f7234b49 | ||
|
|
8e529d3845 | ||
|
|
a3f3539a27 | ||
|
|
d5c37e8f2c | ||
|
|
a5f5dfb0da | ||
|
|
08531c80f3 | ||
|
|
96ce207027 | ||
|
|
13f9a27af8 | ||
|
|
d0d40c02b5 | ||
|
|
0e541a19ba | ||
|
|
1d71c40a2b | ||
|
|
adc3e91122 | ||
|
|
b260208701 | ||
|
|
3dd517c37f | ||
|
|
93397bee77 | ||
|
|
3f72c01911 | ||
|
|
77e6a2e38c | ||
|
|
bef99183aa | ||
|
|
7b84229510 | ||
|
|
23c46a621e | ||
|
|
4dc7255bb0 | ||
|
|
180513c98a | ||
|
|
00894bc069 | ||
|
|
a1b128640d | ||
|
|
8bec4c62fa | ||
|
|
a7ba6a54a3 | ||
|
|
33d0b9a52d | ||
|
|
46546d0178 | ||
|
|
756ced0001 | ||
|
|
2addb39ba3 | ||
|
|
232ede4292 | ||
|
|
f161e84a1d | ||
|
|
4d4a8029f6 | ||
|
|
2a1dcddd90 | ||
|
|
4d319f97bc | ||
|
|
3372d4cb40 | ||
|
|
eb22bb669f | ||
|
|
0d64a66977 | ||
|
|
b0e81166e1 | ||
|
|
79d7aa7485 | ||
|
|
af995a84cb | ||
|
|
5a71bdca3c | ||
|
|
77d918fbe0 | ||
|
|
a0b255338d | ||
|
|
e0b09c144f | ||
|
|
418d09b07f | ||
|
|
4c7936b019 | ||
|
|
77f42b5b3e | ||
|
|
bd69f7a730 | ||
|
|
3ce6523248 | ||
|
|
2efed9dcfc | ||
|
|
88e2a0bc38 | ||
|
|
338e3f8547 | ||
|
|
d1ccc902da | ||
|
|
5e54e24b60 | ||
|
|
aae2f592b5 | ||
|
|
bd94e6a460 | ||
|
|
cbe3e1a786 | ||
|
|
7a0917e053 | ||
|
|
8ac4f7a65b | ||
|
|
453cd47e85 | ||
|
|
d5679ec529 | ||
|
|
2f0be502c3 | ||
|
|
ec971915b5 | ||
|
|
bb71b241d1 | ||
|
|
7aa01755ec | ||
|
|
8ce0ca1f24 | ||
|
|
620aac6b38 | ||
|
|
e99e413e4e | ||
|
|
fb578cf9f9 | ||
|
|
78e3055344 | ||
|
|
15f5847c97 | ||
|
|
8736b58d8c | ||
|
|
12e8e2f93a | ||
|
|
ed2306bb70 | ||
|
|
b3fd57c2c8 | ||
|
|
c2da12409a | ||
|
|
178a57c444 | ||
|
|
1afe13c555 | ||
|
|
c8963cc4cf | ||
|
|
391b793e39 | ||
|
|
9a1519e6cd | ||
|
|
a12237af68 | ||
|
|
d304aa6a26 | ||
|
|
27092b3968 | ||
|
|
c1c36018b1 | ||
|
|
b462ec717b | ||
|
|
65518861ac | ||
|
|
ed75280e70 | ||
|
|
9f53a9fef5 | ||
|
|
3e5dc8d1d4 | ||
|
|
bcb7f656a1 | ||
|
|
372359d040 | ||
|
|
63847c583a | ||
|
|
e90f85af70 | ||
|
|
0c683d4e37 | ||
|
|
16b28004cf |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -84,24 +84,32 @@ jobs:
|
||||
name: frontend-dist
|
||||
path: public/
|
||||
|
||||
- name: Build binary
|
||||
- name: Build binary (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
mkdir -p binaries
|
||||
bun build server/index.ts \
|
||||
--compile \
|
||||
--target ${{ matrix.bun-target }} \
|
||||
--outfile ${{ matrix.binary }}
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
|
||||
- name: Build binary (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Build worker
|
||||
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
|
||||
|
||||
- name: Package archive (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, public -DestinationPath ${{ matrix.archive }}
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Package archive (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
chmod +x ${{ matrix.binary }}
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} public/
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
|
||||
|
||||
- name: Upload archive to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -7,6 +7,7 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
config
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
|
||||
+367
@@ -1,5 +1,372 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.1](https://github.com/Masutayunikon/FanKarr/compare/v2.3.0...v2.3.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add readsettings import ([ceb8e64](https://github.com/Masutayunikon/FanKarr/commit/ceb8e64a55937e20e27c1fb64865aba59516d518))
|
||||
|
||||
## [2.3.0](https://github.com/Masutayunikon/FanKarr/compare/v2.2.0...v2.3.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalogue:** badge IMPORTÉ et refresh automatique post-import ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **catalogue:** tri du catalogue avec 4 options ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **downloads:** bouton Organiser tout renommé Importer tout ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **organize:** nommage des fichiers selon original_filename ou formatted_name ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **settings:** ajout du toggle import automatique (activé par défaut) ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **settings:** textes génériques sans mention de logiciels spécifiques ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config directory to .gitignore ([2d8749a](https://github.com/Masutayunikon/FanKarr/commit/2d8749ae7bc2986871fb402c2a4242bcb87d3a77))
|
||||
|
||||
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add organize all button ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* organizer reuse the wrong name for gitlab path ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
|
||||
|
||||
## [2.1.3](https://github.com/Masutayunikon/FanKarr/compare/v2.1.2...v2.1.3) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use magnet before torrent ([ed4665a](https://github.com/Masutayunikon/FanKarr/commit/ed4665a378d15c0b45a2be32d612e41082a43df1))
|
||||
|
||||
## [2.1.2](https://github.com/Masutayunikon/FanKarr/compare/v2.1.1...v2.1.2) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path now work on every device ([cfc3440](https://github.com/Masutayunikon/FanKarr/commit/cfc3440a95cb73802778e01d5f256f473e0cc952))
|
||||
|
||||
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
|
||||
|
||||
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
|
||||
|
||||
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* suppression de torrent_final.json au profit d'un système de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h. Version majeure — nécessite un nouveau scraper et une mise à jour complète.
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** adaptation complète à la nouvelle structure API Fankai ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** cache mémoire GitHub TTL 6h avec invalidation via /api/update ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** matching torrents par hiérarchie série/saison/épisode ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* migration vers nouvelle architecture API et scraper par série ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **organize:** refonte complète sans torrent_final.json ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **scraper:** extraction nom bencode dans raw pour torrents locaux ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** badge "Hors Fankai" sur épisodes de torrents manuels ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** tooltip raw name sur boutons intégrale via :title ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état download basé sur épisodes organisés vs torrents bruts ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series:** boutons saison Yu-Gi-Oh via fallback season_number ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
|
||||
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
|
||||
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
|
||||
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
|
||||
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
|
||||
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
|
||||
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
|
||||
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
|
||||
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
|
||||
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
|
||||
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
|
||||
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
|
||||
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
|
||||
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
|
||||
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
|
||||
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
|
||||
## [1.10.1](https://github.com/Masutayunikon/FanKarr/compare/v1.10.0...v1.10.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
|
||||
## [1.10.0](https://github.com/Masutayunikon/FanKarr/compare/v1.9.5...v1.10.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.9.5](https://github.com/Masutayunikon/FanKarr/compare/v1.9.4...v1.9.5) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
|
||||
## [1.9.4](https://github.com/Masutayunikon/FanKarr/compare/v1.9.3...v1.9.4) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
|
||||
## [1.9.3](https://github.com/Masutayunikon/FanKarr/compare/v1.9.2...v1.9.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
|
||||
## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
|
||||
## [1.9.1](https://github.com/Masutayunikon/FanKarr/compare/v1.9.0...v1.9.1) (2026-03-13)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/client /app/public
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /config
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
# Fankarr
|
||||
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -11,7 +10,7 @@
|
||||

|
||||

|
||||
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||
</div>
|
||||
@@ -83,11 +82,10 @@ services:
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
|
||||
ports:
|
||||
- 3001:3001
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -95,15 +93,19 @@ services:
|
||||
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)
|
||||
|
||||
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
|
||||
| Système | Archive |
|
||||
|---|---|
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
| Système | Archive |
|
||||
| --------------------- | ------------------------- |
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
|
||||
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
|
||||
|
||||
@@ -120,22 +122,22 @@ chmod +x 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
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `3001` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
| Variable | Défaut | Description |
|
||||
| ---------------- | ------------ | ----------------------------------------------------------------------- |
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
1. Ouvrir `http://localhost:3001`
|
||||
1. Ouvrir `http://localhost:9898`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
|
||||
4. Vérifier les chemins `completePath` et `mediaPath`
|
||||
@@ -173,12 +175,12 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologie |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
|
||||
| Couche | Technologie |
|
||||
| ----------------- | ---------------------------------------- |
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
|
||||
| Packaging binaire | Bun (self-contained, pas de Node requis) |
|
||||
|
||||
---
|
||||
|
||||
+14
-15
@@ -1,15 +1,14 @@
|
||||
services:
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
ports:
|
||||
- 3001:3001
|
||||
restart: unless-stopped
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
|
||||
ports:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
|
||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||
|
||||
# Corriger les permissions du dossier data
|
||||
chown -R "$PUID:$PGID" /app/data
|
||||
chown -R "$PUID:$PGID" /config
|
||||
|
||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||
+8
-8
@@ -7,17 +7,16 @@
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
|
||||
"start": "node dist/server/index.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -25,10 +24,10 @@
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -39,6 +38,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
"vue-tsc": "^3.1.5",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
}
|
||||
}
|
||||
Generated
+7
-22
@@ -11,9 +11,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
@@ -650,9 +650,9 @@ packages:
|
||||
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
@@ -1087,10 +1087,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-addon-api@8.6.0:
|
||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -1100,10 +1096,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1919,10 +1911,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.0
|
||||
ast-kit: 2.2.0
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.6.0
|
||||
node-gyp-build: 4.8.4
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
@@ -2351,8 +2340,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
@@ -2361,8 +2348,6 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import bcrypt from 'bcrypt'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import { JWT_SECRET } from './secret.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
|
||||
@@ -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')
|
||||
+439
-228
@@ -12,24 +12,15 @@ import {
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
// ── 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 PORT = Number(process.env.PORT) || 3001
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const TORRENTS_PATH = path.join(BASE_DIR, 'data', 'torrent_final.json')
|
||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
@@ -51,8 +42,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport, autoImport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport }))
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -95,71 +86,293 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
function readTorrents(): any[] {
|
||||
// ── Cache GitHub ───────────────────────────────────────────────
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
function cacheGet<T>(key: string): T | null {
|
||||
const entry = _cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
function cacheClear(): void { _cache.clear() }
|
||||
|
||||
async function githubGet(urlPath: string): Promise<any> {
|
||||
const key = urlPath
|
||||
const cached = cacheGet(key)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function readAvailable(): Promise<number[]> {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
return await githubGet('available.json') as number[]
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try {
|
||||
return await githubGet(`series/${serieId}.json`)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique
|
||||
// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... }
|
||||
function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
|
||||
// Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'pack_integrale',
|
||||
serie_id : sd.id,
|
||||
serie_title: sd.title,
|
||||
// episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i]
|
||||
_serieLevel: true,
|
||||
_serieData : sd,
|
||||
_torrentIdx: (sd.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
|
||||
// Torrents au niveau saison → pack_saison
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'pack_saison',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
_seasonLevel : true,
|
||||
_season : season,
|
||||
_torrentIdx : (season.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
|
||||
// Torrents au niveau épisode → episode
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'episode',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
episode_id : ep.id,
|
||||
episode_number: ep.episode_number,
|
||||
_episodeLevel: true,
|
||||
_episode : ep,
|
||||
_torrentIdx : (ep.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx]
|
||||
// Construit les épisodes résolus pour un torrent donné par son infohash
|
||||
function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
|
||||
// Nouveau format { infohash, path } ou legacy string
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!filePath) filePath = p.replace(/\\/g, '/') // legacy fallback
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
filePath = p.path.replace(/\\/g, '/')
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!filePath) continue
|
||||
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({
|
||||
episode_id : ep.id,
|
||||
episode_number : ep.episode_number,
|
||||
season_number : season.season_number,
|
||||
season_id : season.id,
|
||||
filename,
|
||||
original_filename: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
if (!t.serie_id) continue
|
||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
||||
map.get(t.serie_id)!.push(t)
|
||||
}
|
||||
return map
|
||||
|
||||
function normalizeTitle(s: string): string {
|
||||
return s.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
|
||||
}
|
||||
|
||||
function normalizeSerie(serie: any): any {
|
||||
const poster = serie.poster_image ?? serie.images?.poster ?? null
|
||||
const fanart = serie.fanart_image ?? serie.images?.fanart ?? null
|
||||
return {
|
||||
...serie,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
fanart_image: imgProxy(fanart, 1280, 60),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSeason(season: any): any {
|
||||
const poster = season.poster_image ?? season.images?.poster ?? null
|
||||
return {
|
||||
...season,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEpisode(ep: any): any {
|
||||
const thumb = ep.thumb_image ?? ep.thumbnail ?? null
|
||||
return {
|
||||
...ep,
|
||||
thumb_image: imgProxy(thumb, 400),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrichissement seriesData avec original_filename depuis l'API ──────────────
|
||||
// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename
|
||||
// dans chaque épisode du serieData, indexé par episode_id
|
||||
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
|
||||
return Promise.all(seriesData.map(async (sd) => {
|
||||
if (!sd) return sd
|
||||
const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => {
|
||||
let epsData: any[] = []
|
||||
try {
|
||||
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
||||
} catch { return season }
|
||||
|
||||
// Index episode_id → original_filename + formatted_name
|
||||
const origMap = new Map<number, { original_filename: string; formatted_name: string }>()
|
||||
for (const ep of epsData) {
|
||||
if (ep.id) origMap.set(ep.id, {
|
||||
original_filename: ep.original_filename ?? '',
|
||||
formatted_name : ep.formatted_name ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
// Injecter dans les épisodes du serieData
|
||||
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
|
||||
...ep,
|
||||
original_filename: origMap.get(ep.id)?.original_filename ?? null,
|
||||
formatted_name : origMap.get(ep.id)?.formatted_name ?? null,
|
||||
}))
|
||||
|
||||
return { ...season, episodes: enrichedEpisodes }
|
||||
}))
|
||||
return { ...sd, seasons: enrichedSeasons }
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper réutilisable : charge + enrichit toutes les seriesData du cache
|
||||
async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
const valid = allSd.filter(Boolean)
|
||||
return enrichSeriesDataWithOriginalFilenames(valid)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(
|
||||
serieTorrents : any[],
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, string>>,
|
||||
activeTorrents: Set<string> // hashes actuellement dans qB en downloading
|
||||
activeTorrents: Set<string>
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (serieTorrents.length === 0) return 'none'
|
||||
if (!serieData) return 'none'
|
||||
|
||||
// Un torrent de la série est en cours de téléchargement ?
|
||||
const hasActive = serieTorrents.some(t => activeTorrents.has(t.infohash?.toLowerCase()))
|
||||
const allTorrents = extractTorrentsFromSerieData(serieData)
|
||||
if (allTorrents.length === 0) return 'none'
|
||||
|
||||
const hasActive = allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))
|
||||
if (hasActive) return 'downloading'
|
||||
|
||||
// Compter les fichiers attendus vs organisés
|
||||
let totalFiles = 0
|
||||
let doneFiles = 0
|
||||
for (const t of serieTorrents) {
|
||||
const files: any[] = t.torrent_files ?? []
|
||||
if (files.length === 0) {
|
||||
// Fichier unique
|
||||
totalFiles++
|
||||
if (organized[t.infohash?.toLowerCase()]?.[t.raw]) doneFiles++
|
||||
} else {
|
||||
totalFiles += files.length
|
||||
doneFiles += files.filter((f: any) => organized[t.infohash?.toLowerCase()]?.[f.filename]).length
|
||||
// Collecter tous les episode_id de la série et ceux organisés
|
||||
const allEpisodeIds = new Set<number>()
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
if ((ep.torrents ?? []).length > 0 || (ep.paths ?? []).length > 0) {
|
||||
allEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalFiles === 0) return 'none'
|
||||
if (doneFiles === 0) return 'none'
|
||||
if (doneFiles >= totalFiles) return 'complete'
|
||||
// Vérifier via organized.json : si le filename du path est organisé
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
const resolved = buildResolvedEpisodes(serieData, hash, t.season_number)
|
||||
for (const ep of resolved) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (allEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const [apiData, torrents] = await Promise.all([fankaiGet('/series'), Promise.resolve(readTorrents())])
|
||||
const byId = indexBySerie(torrents)
|
||||
const [apiData, availableIds] = await Promise.all([
|
||||
fankaiGet('/series'),
|
||||
readAvailable(),
|
||||
])
|
||||
const availableSet = new Set<number>(availableIds)
|
||||
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -170,19 +383,31 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
const { category } = readSettings()
|
||||
const active = await dispatchList(category ?? 'fankai')
|
||||
for (const t of active) {
|
||||
if (t.state === 'downloading' && t.hash)
|
||||
activeTorrents.add(t.hash.toLowerCase())
|
||||
if (t.hash) activeTorrents.add(t.hash.toLowerCase())
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
res.json({ series: seriesList.map((serie: any) => {
|
||||
const serieTorrents = byId.get(serie.id) ?? []
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
|
||||
// Charger les series data en parallèle uniquement pour celles qui ont des torrents
|
||||
const serieDataMap = new Map<number, any>()
|
||||
await Promise.all(
|
||||
seriesRaw
|
||||
.filter((s: any) => availableSet.has(s.id))
|
||||
.map(async (s: any) => {
|
||||
const sd = await readSerieData(s.id)
|
||||
if (sd) serieDataMap.set(s.id, sd)
|
||||
})
|
||||
)
|
||||
|
||||
res.json({ series: seriesRaw.map((serie: any) => {
|
||||
const hasTorrents = availableSet.has(serie.id)
|
||||
const serieData = serieDataMap.get(serie.id) ?? null
|
||||
return {
|
||||
...serie,
|
||||
torrent_count : serieTorrents.length,
|
||||
has_torrents : serieTorrents.length > 0,
|
||||
download_state: computeSerieDownloadState(serieTorrents, organized, activeTorrents),
|
||||
torrent_count : serieData ? extractTorrentsFromSerieData(serieData).length : 0,
|
||||
has_torrents : hasTorrents,
|
||||
download_state: computeSerieDownloadState(serieData, organized, activeTorrents),
|
||||
}
|
||||
})})
|
||||
} catch (err) {
|
||||
@@ -193,129 +418,127 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([
|
||||
fankaiGet(`/series/${id}`),
|
||||
fankaiGet(`/series/${id}/seasons`),
|
||||
readSerieData(id),
|
||||
])
|
||||
const serie = normalizeSerie(serieRaw)
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
|
||||
// Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.)
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
|
||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
|
||||
for (const t of torrents) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
availableEpisodeIds.add(ep.episode_id)
|
||||
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
|
||||
}
|
||||
if (t.type === 'pack_saison') {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
if (rs.length > 1) {
|
||||
packEpisodesTorrents.push(t)
|
||||
} else {
|
||||
const sids = rs.length > 0 ? rs.map((r: any) => r.season_id) : [t.season_id]
|
||||
for (const sid of sids) { if (sid) seasonTorrentMap[sid] = ref }
|
||||
}
|
||||
} else if (t.type === 'pack_integrale') {
|
||||
integraleTorrents.push(t)
|
||||
} else if (t.type === 'pack_episodes') {
|
||||
packEpisodesTorrents.push(t)
|
||||
}
|
||||
}
|
||||
|
||||
let promotedIntegrale: any = null
|
||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
|
||||
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
|
||||
}
|
||||
}
|
||||
|
||||
if (integraleTorrents.length > 0 || promotedIntegrale) {
|
||||
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
|
||||
if (promotedIntegrale) {
|
||||
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
|
||||
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function packEpisodesLabel(t: any): string {
|
||||
const eps: any[] = t.resolved_episodes ?? []
|
||||
if (!eps.length) return t.raw
|
||||
const bySeason = new Map<number, number[]>()
|
||||
for (const ep of eps) {
|
||||
const sn = ep.season_number ?? 0
|
||||
if (!bySeason.has(sn)) bySeason.set(sn, [])
|
||||
bySeason.get(sn)!.push(ep.episode_number)
|
||||
}
|
||||
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
|
||||
const s = nums.sort((a, b) => a - b)
|
||||
const label = sn === 0 ? 'S0' : `S${sn}`
|
||||
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
|
||||
}).join(' · ')
|
||||
}
|
||||
|
||||
// Charger organized.json pour les badges d'état
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Construire un set des episode_id organisés
|
||||
// On croise resolved_episodes de chaque torrent avec organized.json
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
const files = t.torrent_files ?? []
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
// Au moins un fichier organisé pour ce torrent → on marque les épisodes résolus
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
// Vérifier si le fichier de cet épisode est organisé
|
||||
const epFile = files.find((f: any) =>
|
||||
f.episode_ids?.includes(ep.episode_id) || f.episode_number === ep.episode_number
|
||||
)
|
||||
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
|
||||
organizedEpisodeIds.add(ep.episode_id)
|
||||
// ── Construire les maps torrent depuis series/{id}.json ───────────
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap : Record<number, any> = {} // episode_id → ref
|
||||
const seasonTorrentMapBySn : Record<number, any> = {} // season_number → ref
|
||||
const integraleTorrents : any[] = []
|
||||
const packEpisodesTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
|
||||
if (serieData) {
|
||||
// ── Torrents niveau série (pack_integrale) ──────────────────
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_integrale', raw: t.title, manual: t.manual ?? false }
|
||||
integraleTorrents.push({ ...t, raw: t.title })
|
||||
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Torrents niveau saison (pack_saison) ────────────────────
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
|
||||
seasonTorrentMapBySn[season.season_number] = ref
|
||||
|
||||
const resolved = buildResolvedEpisodes(serieData, t.infohash, season.season_number)
|
||||
for (const ep of resolved) availableEpisodeIds.add(ep.episode_id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
for (const ep of resolved) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Torrents niveau épisode ─────────────────────────────
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false }
|
||||
episodeTorrentMap[ep.id] = ref
|
||||
availableEpisodeIds.add(ep.id)
|
||||
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const paths: any[] = ep.paths ?? []
|
||||
const match = paths.find((p: any) => typeof p === 'object' && p.infohash?.toLowerCase() === t.infohash?.toLowerCase())
|
||||
const filename = match ? match.path.split('/').pop() : null
|
||||
if (filename && orgFiles[filename]) organizedEpisodeIds.add(ep.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pack_saison multi-saisons → packEpisodesTorrents si couvre plusieurs saisons
|
||||
// (cas où un seul torrent couvre plusieurs saisons mais pas toutes → affiché en hero)
|
||||
// Pour l'instant on le traite comme pack_integrale si au niveau série
|
||||
}
|
||||
|
||||
// ── Promotion intégrale ───────────────────────────────────────────
|
||||
// Si pas de pack_integrale explicite mais un pack_saison couvre toutes les saisons
|
||||
// → déjà géré car on met les torrents série en integraleTorrents directement
|
||||
|
||||
// ── Enrichissement des saisons API ───────────────────────────────
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
}))
|
||||
// State saison : none / partial / complete
|
||||
const eps = season.episodes.map((ep: any) => {
|
||||
// Chercher le torrent : niveau épisode d'abord, sinon le torrent de saison/intégrale sert juste de disponibilité
|
||||
const epTorrent = episodeTorrentMap[ep.id] ?? null
|
||||
// original_filename vient de l'API Fankai directement
|
||||
return {
|
||||
...ep,
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : epTorrent,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
}
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
const seasonOrganizedState =
|
||||
orgCount === 0 ? 'none' :
|
||||
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
||||
orgCount === 0 ? 'none' :
|
||||
orgCount >= total && total > 0 ? 'complete' : 'partial'
|
||||
return {
|
||||
...season,
|
||||
torrent : seasonTorrentMap[season.id] ?? null,
|
||||
organized_state : seasonOrganizedState,
|
||||
organized_count : orgCount,
|
||||
episodes : eps,
|
||||
torrent : seasonTorrentMapBySn[season.season_number] ?? null,
|
||||
organized_state : seasonOrganizedState,
|
||||
organized_count : orgCount,
|
||||
episodes : eps,
|
||||
}
|
||||
})
|
||||
|
||||
// ── Hero bundles ──────────────────────────────────────────────────
|
||||
function packEpisodesLabel(t: any): string {
|
||||
return t.raw ?? t.title ?? 'Pack'
|
||||
}
|
||||
|
||||
const heroBundles: any[] = [
|
||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw })),
|
||||
...(promotedIntegrale ? [{ label: 'Intégrale', torrent_url: promotedIntegrale.torrent_url, magnet: promotedIntegrale.magnet, raw: promotedIntegrale.raw }] : []),
|
||||
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.raw }))
|
||||
...integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })),
|
||||
...packEpisodesTorrents.map(t => ({ label: packEpisodesLabel(t), torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw }))
|
||||
]
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
|
||||
@@ -326,7 +549,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.torrent_url ?? req.body.magnet
|
||||
const url = req.body.magnet ?? req.body.torrent_url
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
@@ -338,38 +561,41 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
|
||||
// Enrichir avec l'état d'organisation
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
let torrentFinal: any[] = []
|
||||
try {
|
||||
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
if (fs.existsSync(tfPath))
|
||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const enriched = torrents.map((t: any) => {
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
const meta = torrentFinal.find((m: any) =>
|
||||
m.infohash?.toLowerCase() === t.hash?.toLowerCase()
|
||||
)
|
||||
const totalFiles = (meta?.torrent_files?.length ?? 0) || 1
|
||||
// Chercher les métadonnées via le cache GitHub
|
||||
let totalFiles = 1
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const allT = extractTorrentsFromSerieData(sd)
|
||||
const match = allT.find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) {
|
||||
const resolved = buildResolvedEpisodes(sd, match.infohash, match.season_number)
|
||||
totalFiles = resolved.length || 1
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
let organizeState: 'none' | 'partial' | 'done' = 'none'
|
||||
if (doneFiles >= totalFiles) organizeState = 'done'
|
||||
else if (doneFiles > 0) organizeState = 'partial'
|
||||
|
||||
// Récupérer les erreurs depuis recentOrganized
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
const errorFiles = notif?.errorFiles ?? []
|
||||
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles }
|
||||
})
|
||||
}))
|
||||
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
@@ -377,7 +603,7 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── Résultats d'organisation récents (pour notifications frontend) ──
|
||||
// ── Résultats d'organisation récents ──────────────────────────
|
||||
interface OrganizeNotif {
|
||||
hash : string
|
||||
name : string
|
||||
@@ -399,13 +625,11 @@ app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
// Vider les notifs lues
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
@@ -413,14 +637,14 @@ app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await organizeTorrent(hash, name, save_path)
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Torrents status ────────────────────────────────────────────
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
@@ -458,27 +682,21 @@ app.get('/api/browse', requireAuth, (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
||||
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
|
||||
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
|
||||
const data = JSON.parse(raw)
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
res.json({ exists: true, count, empty: count === 0 })
|
||||
const available = await readAvailable()
|
||||
res.json({ exists: available.length > 0, count: available.length, empty: available.length === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
// ── Update torrent_final.json ──────────────────────────────────
|
||||
// ── Update : vide le cache pour forcer le rechargement depuis GitHub ──────────
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
res.json({ ok: true, count: data.length })
|
||||
cacheClear()
|
||||
// Pré-charger available.json pour vérifier que GitHub répond
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
@@ -487,8 +705,9 @@ app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
@@ -496,7 +715,7 @@ app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
||||
// ── SPA fallback (prod) ────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
@@ -505,43 +724,35 @@ if (fs.existsSync(PUBLIC_PATH)) {
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, async () => {
|
||||
// Télécharger torrent_final.json si absent
|
||||
if (!fs.existsSync(TORRENTS_PATH)) {
|
||||
try {
|
||||
console.log('[fankarr] torrent_final.json absent → téléchargement...')
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
console.log(`[fankarr] ${data.length} torrents téléchargés`)
|
||||
} catch (err) {
|
||||
console.warn(`[fankarr] ⚠ Téléchargement échoué: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`)
|
||||
|
||||
// Pré-chauffer le cache available.json au démarrage
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`)
|
||||
} catch (err) {
|
||||
console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
|
||||
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
// Scan initial du mediaPath
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
|
||||
loadEnrichedSeriesData().then(seriesData =>
|
||||
scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
// Auto-organise — polling différentiel toutes les 5 minutes
|
||||
// Le worker ne se lance que si un torrent vient de passer en seeding
|
||||
const autoOrganize = async () => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai'),
|
||||
seriesData,
|
||||
(result) => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
@@ -552,9 +763,9 @@ app.listen(PORT, async () => {
|
||||
console.error('[organize] Erreur auto-organise:', err)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
// Premier run 10s après démarrage, puis toutes les 5 minutes
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 30_000)
|
||||
}, 30_000)
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
}, 10_000)
|
||||
})
|
||||
+2
-1
@@ -8,6 +8,7 @@
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface LogEntry {
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
|
||||
const LOGS_PATH = path.join(DATA_DIR, 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
+190
-156
@@ -4,8 +4,8 @@
|
||||
* Worker thread dédié à l'organisation des fichiers.
|
||||
* Tourne dans un thread séparé pour ne pas bloquer le event loop Express.
|
||||
*
|
||||
* Messages reçus (parentPort.on) : { type: 'run', torrents: TorrentInfo[] }
|
||||
* Messages envoyés (parentPort.postMessage) :
|
||||
* Messages reçus : { type: 'run', torrents: TorrentInfo[], seriesData: SerieData[] }
|
||||
* Messages envoyés :
|
||||
* { type: 'log', level: 'info'|'warn'|'error', msg: string }
|
||||
* { type: 'result', hash: string, name: string, done, skipped, errors }
|
||||
* { type: 'done' }
|
||||
@@ -15,38 +15,26 @@ import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
// Base directory : binaire Bun ou cwd (Docker/dev)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& 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')
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
|
||||
// qui les écrit dans le fichier via logger.ts
|
||||
|
||||
function log(msg: string) { parentPort?.postMessage({ type: 'log', level: 'info', msg }) }
|
||||
function warn(msg: string) { parentPort?.postMessage({ type: 'log', level: 'warn', msg }) }
|
||||
function error(msg: string) { parentPort?.postMessage({ type: 'log', level: 'error', msg }) }
|
||||
function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'debug', msg }) }
|
||||
|
||||
// ─── Settings (lu directement, pas d'import circulaire) ──────
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'data', 'settings.json')
|
||||
const p = path.join(DATA_DIR, 'settings.json')
|
||||
if (!fs.existsSync(p)) return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false }
|
||||
return JSON.parse(fs.readFileSync(p, 'utf-8'))
|
||||
} catch { return { mediaPath: '', completePath: '', organizeMode: 'hardlink', nfoSupport: false } }
|
||||
}
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
|
||||
function loadOrganized(): Record<string, Record<string, string>> {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
@@ -65,18 +53,89 @@ function isOrganized(hash: string, filename: string): boolean {
|
||||
return !!loadOrganized()[hash]?.[filename]
|
||||
}
|
||||
|
||||
// ─── Torrent lookup ───────────────────────────────────────────
|
||||
// ─── Lookup torrent dans les seriesData passées via message ───
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
for (const sd of seriesData) {
|
||||
// Niveau série
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
// Niveau saison
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
// Niveau épisode — compter combien d'épisodes partagent ce torrent
|
||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
) ?? []
|
||||
|
||||
function findTorrentByHash(hash: string): any | null {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return null
|
||||
const torrents = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
return torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
} catch { return null }
|
||||
if (matchingEpisodes.length === 1) {
|
||||
// Torrent propre à un seul épisode → cas épisode unique
|
||||
const ep = matchingEpisodes[0]
|
||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||
} else if (matchingEpisodes.length > 1) {
|
||||
// Même torrent partagé entre plusieurs épisodes → traiter comme pack_saison
|
||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus (bonus, musique, images) ────────────────
|
||||
// Construit la map filename → { season_number, original_filename, fullPath }
|
||||
// Supporte les deux formats : { infohash, path } et string (legacy)
|
||||
// destFilename : original_filename si nfoSupport, formatted_name sinon
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number
|
||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let matchedPath: string | null = null
|
||||
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
// nfoSupport → original_filename (correspond aux .nfo GitLab)
|
||||
// sinon → formatted_name de l'API (nommage Jellyfin/Plex standardisé)
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
original_filename: destName,
|
||||
fullPath : matchedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus ─────────────────────────────────────────
|
||||
const EXCLUDED_FOLDERS = new Set([
|
||||
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||
@@ -95,27 +154,23 @@ function isInExcludedFolder(filePath: string[]): boolean {
|
||||
}
|
||||
|
||||
// ─── Map correction titres API → titres GitLab ───────────────
|
||||
|
||||
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
function getGitlabSerieTitle(serieTitle: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ─── GitLab NFO downloader ────────────────────────────────────
|
||||
|
||||
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
@@ -136,40 +191,36 @@ async function fetchBinary(url: string): Promise<Buffer> {
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabSerieTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
|
||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||
|
||||
// Lister récursivement tous les fichiers du dossier
|
||||
let files: any[]
|
||||
let files: any[] = []
|
||||
try {
|
||||
files = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
|
||||
)
|
||||
let page = 1
|
||||
while (true) {
|
||||
const batch = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
|
||||
)
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
page++
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${serieTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
|
||||
return
|
||||
}
|
||||
if (files.length === 0) { warn(`[nfo] Aucun fichier trouvé pour "${serieTitle}"`); return }
|
||||
|
||||
const fileEntries = files.filter((f: any) => f.type === 'blob')
|
||||
log(`[nfo] ${fileEntries.length} fichiers à télécharger`)
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
// entry.path = "pack/Black Lagoon Henshū/Saison 1/fichier.nfo"
|
||||
// On retire le préfixe "pack/{gitlabTitle}/" pour obtenir le chemin relatif
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
const destDir = path.dirname(destPath)
|
||||
|
||||
// Skip si déjà présent
|
||||
if (fs.existsSync(destPath)) continue
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
|
||||
const data = await fetchBinary(rawUrl)
|
||||
fs.writeFileSync(destPath, data)
|
||||
@@ -178,15 +229,11 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
log(`[nfo] Dossier GitLab synchronisé pour "${serieTitle}"`)
|
||||
log(`[nfo] GitLab synchronisé pour "${gitlabTitle}"`)
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return `Saison ${n}`
|
||||
}
|
||||
function seasonFolder(n: number): string { return `Saison ${n}` }
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
@@ -197,47 +244,67 @@ function tryHardlink(src: string, dest: string): boolean {
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
async function organizeTorrent(hash: string, name: string, savePath: string, seriesData: any[]) {
|
||||
const { mediaPath, completePath, organizeMode, nfoSupport } = readSettings()
|
||||
const result = { total: 0, skipped: 0, done: 0, errors: [] as { file: string; error: string }[] }
|
||||
|
||||
log(`[organize] ── ${name} ──`)
|
||||
log(`[organize] hash=${hash} save_path=${savePath} mode=${organizeMode}`)
|
||||
|
||||
const torrent = findTorrentByHash(hash)
|
||||
if (!torrent) {
|
||||
error(`[organize] ✗ Hash ${hash} introuvable dans torrent_final.json`)
|
||||
const found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
log(`[organize] ✓ Trouvé : ${torrent.raw}`)
|
||||
|
||||
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||
log(`[organize] serie=${serieTitle} fichiers=${torrentFiles.length}`)
|
||||
const { torrent, serieData } = found
|
||||
const serieTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const torrentTitle = torrent.title ?? name
|
||||
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
|
||||
|
||||
// ── Téléchargement NFO/images depuis GitLab ───────────────
|
||||
// ── NFO/images depuis GitLab ──────────────────────────────
|
||||
if (nfoSupport) {
|
||||
const serieDestRoot = path.join(mediaPath, serieTitle)
|
||||
await downloadGitlabFolder(serieTitle, serieDestRoot)
|
||||
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
|
||||
}
|
||||
|
||||
// ── Cas fichier unique ────────────────────────────────────
|
||||
if (torrentFiles.length === 0) {
|
||||
log(`[organize] torrent_files vide → tentative fichier unique`)
|
||||
const candidates = [path.join(savePath, name), path.join(completePath, name)]
|
||||
let src: string | null = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) log(`[organize] ✓ source trouvée: ${c}`)
|
||||
else debug(`[organize] candidate absent: ${c}`)
|
||||
if (fs.existsSync(c)) { src = c; break }
|
||||
// ── Cas torrent épisode unique ────────────────────────────
|
||||
if (torrent._episode) {
|
||||
const ep = torrent._episode
|
||||
const season = torrent._season
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!src) throw new Error(`Source introuvable pour "${name}"`)
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (ep.formatted_name
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: (ep.original_filename ?? filename))
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
const seasonNum = torrent.resolved_episodes?.[0]?.season_number ?? 1
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, name)
|
||||
log(`[organize] dest: ${dest}`)
|
||||
if (isOrganized(hash, filename)) return { ...result, total: 1, skipped: 1 }
|
||||
|
||||
const candidates = filePath ? [
|
||||
path.join(savePath, filePath),
|
||||
path.join(completePath, filePath),
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, filename),
|
||||
] : [
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, filename),
|
||||
]
|
||||
|
||||
let src: string | null = null
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
error(`[organize] ✗ ${filename} introuvable`)
|
||||
return { ...result, total: 1, errors: [{ file: filename, error: 'Source introuvable' }] }
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
@@ -247,83 +314,47 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.unlink(src)
|
||||
}
|
||||
log(`[organize] ✓ ${organizeMode} OK`)
|
||||
} else {
|
||||
log(`[organize] → dest existe déjà, skip`)
|
||||
}
|
||||
markOrganized(hash, name)
|
||||
markOrganized(hash, filename)
|
||||
log(`[organize] ✓ ${destName} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
|
||||
// ── 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>()
|
||||
// ── Pack saison / intégrale ───────────────────────────────
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, hash, nfoSupport, seasonFilter)
|
||||
|
||||
// Index resolved_episodes par episode_number pour lookup rapide
|
||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.episode_number !== undefined && ep.season_number !== undefined)
|
||||
epByNum.set(ep.episode_number, ep.season_number)
|
||||
if (fileMap.size === 0) {
|
||||
warn(`[organize] Aucun fichier mappé pour ${name}`)
|
||||
return result
|
||||
}
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const sn: number | null = tf.season_number ?? null
|
||||
if (sn !== null) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tf.num !== undefined && epByNum.has(tf.num)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tf.num)!)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
}
|
||||
}
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const filename = tf.filename
|
||||
const filePath: string[] = tf.path
|
||||
|
||||
// Skip les fichiers dans des dossiers bonus/musique/images
|
||||
if (isInExcludedFolder(filePath)) {
|
||||
log(`[organize] skip bonus: ${filename}`)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
result.total = fileMap.size
|
||||
|
||||
for (const [filename, { season_number, original_filename, fullPath }] of fileMap) {
|
||||
if (isOrganized(hash, filename)) { result.skipped++; continue }
|
||||
|
||||
// Source : on utilise save_path + fullPath (chemin relatif exact dans le torrent)
|
||||
// save_path vient de qBittorrent → résiste aux renommages de dossier
|
||||
const candidates = [
|
||||
path.join(savePath, name, ...filePath.slice(0, -1), filename),
|
||||
path.join(savePath, ...filePath.slice(0, -1), filename),
|
||||
path.join(savePath, name, filename),
|
||||
path.join(savePath, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
path.join(savePath, filename),
|
||||
path.join(completePath, name, ...filePath.slice(0, -1), filename),
|
||||
path.join(completePath, name, filename),
|
||||
path.join(completePath, filename),
|
||||
]
|
||||
|
||||
let src: string | null = null
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) { src = c; break }
|
||||
}
|
||||
for (const c of candidates) { if (fs.existsSync(c)) { src = c; break } }
|
||||
|
||||
if (!src) {
|
||||
const tried = candidates.map(c => ` • ${c}`).join('\n')
|
||||
error(`[organize] ✗ ${filename} introuvable:\n${tried}`)
|
||||
error(`[organize] ✗ ${filename} introuvable`)
|
||||
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||
continue
|
||||
}
|
||||
|
||||
const seasonNum = filenameSeasonMap.get(filename) ?? 1
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, filename)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season_number))
|
||||
const dest = path.join(destDir, original_filename)
|
||||
|
||||
if (fs.existsSync(dest)) {
|
||||
markOrganized(hash, filename)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
if (fs.existsSync(dest)) { markOrganized(hash, filename); result.skipped++; continue }
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
@@ -335,7 +366,7 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
log(`[organize] ✓ ${filename} → Saison ${String(seasonNum).padStart(3,'0')}`)
|
||||
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||
@@ -343,37 +374,40 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
log(`[organize] ── Résultat ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
log(`[organize] ── ${name}: ${result.done} OK, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Boucle principale du worker ─────────────────────────────
|
||||
|
||||
parentPort?.on('message', async (msg: any) => {
|
||||
if (msg.type !== 'run') return
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const torrent = findTorrentByHash(t.hash)
|
||||
if (!torrent) {
|
||||
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé`)
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
if (!found) {
|
||||
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
|
||||
continue
|
||||
}
|
||||
|
||||
const files: any[] = torrent.torrent_files ?? []
|
||||
// Pour fichier unique : vérifier via le name directement
|
||||
const allDone = files.length > 0
|
||||
? files.every((f: any) => organized[t.hash]?.[f.filename])
|
||||
// Vérifier si déjà tout organisé
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
|
||||
if (allDone) continue
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path)
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, seriesData)
|
||||
parentPort?.postMessage({ type: 'result', hash: t.hash, name: t.name, ...result })
|
||||
} catch (err) {
|
||||
error(`[organize] Erreur ${t.name}: ${err instanceof Error ? err.message : err}`)
|
||||
|
||||
+113
-73
@@ -2,17 +2,30 @@
|
||||
* organize.ts
|
||||
* ===========
|
||||
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||
* Le worker gère tout le filesystem (copy, hardlink, move).
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { readSettings } from './settings.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
|
||||
// État précédent des torrents pour détecter les passages downloading → seeding
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
function resolveWorkerPath(): string {
|
||||
if (typeof (globalThis as any).Bun !== 'undefined') {
|
||||
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
|
||||
}
|
||||
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
}
|
||||
|
||||
export interface OrganizeResult {
|
||||
total : number
|
||||
skipped : number
|
||||
@@ -22,13 +35,12 @@ export interface OrganizeResult {
|
||||
|
||||
/**
|
||||
* Scan initial du mediaPath au démarrage.
|
||||
* Parcourt récursivement le dossier Jellyfin et marque dans organized.json
|
||||
* tous les fichiers déjà présents qui correspondent à un torrent connu.
|
||||
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
|
||||
*/
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
torrentsPath: string,
|
||||
organizedPath: string
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
seriesData : any[] = []
|
||||
): Promise<{ found: number; added: number }> {
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
@@ -37,27 +49,22 @@ export async function scanMediaPath(
|
||||
return result
|
||||
}
|
||||
|
||||
// Charger torrent_final.json
|
||||
let torrents: any[] = []
|
||||
try {
|
||||
if (!fs.existsSync(torrentsPath)) return result
|
||||
torrents = JSON.parse(fs.readFileSync(torrentsPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.error('organize', 'scanMediaPath: impossible de lire torrent_final.json')
|
||||
return result
|
||||
}
|
||||
|
||||
// Construire un index filename → { hash, infohash }
|
||||
// Construire index filename → infohash depuis les seriesData
|
||||
const filenameIndex = new Map<string, string>() // filename → infohash
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
for (const f of t.torrent_files ?? []) {
|
||||
if (f.filename) filenameIndex.set(f.filename, hash)
|
||||
}
|
||||
// Fichier unique (torrent_files vide) → on indexe par raw name
|
||||
if ((t.torrent_files ?? []).length === 0 && t.raw) {
|
||||
filenameIndex.set(t.raw, hash)
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p === 'string') {
|
||||
// Legacy : pas d'infohash disponible, on skip pour le scan
|
||||
continue
|
||||
}
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
const filename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (filename) filenameIndex.set(filename, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +75,8 @@ export async function scanMediaPath(
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Parcourir récursivement mediaPath
|
||||
const presentFiles = new Set<string>()
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
@@ -81,8 +89,8 @@ export async function scanMediaPath(
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
if (organized[hash]?.[entry.name]) continue // déjà connu
|
||||
// Marquer comme organisé
|
||||
presentFiles.add(`${hash}:${entry.name}`)
|
||||
if (organized[hash]?.[entry.name]) continue
|
||||
if (!organized[hash]) organized[hash] = {}
|
||||
organized[hash][entry.name] = new Date().toISOString()
|
||||
result.added++
|
||||
@@ -92,9 +100,21 @@ export async function scanMediaPath(
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
if (result.added > 0) {
|
||||
// Nettoyer les entrées orphelines
|
||||
let removed = 0
|
||||
for (const [hash, files] of Object.entries(organized)) {
|
||||
for (const filename of Object.keys(files)) {
|
||||
if (!presentFiles.has(`${hash}:${filename}`) && filenameIndex.get(filename) === hash) {
|
||||
delete organized[hash][filename]
|
||||
removed++
|
||||
}
|
||||
}
|
||||
if (Object.keys(organized[hash]).length === 0) delete organized[hash]
|
||||
}
|
||||
|
||||
if (result.added > 0 || removed > 0) {
|
||||
fs.writeFileSync(organizedPath, JSON.stringify(organized, null, 2), 'utf-8')
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, ${result.added} ajoutés à organized.json`)
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
}
|
||||
@@ -103,16 +123,19 @@ export async function scanMediaPath(
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance l'auto-organisation dans un worker thread.
|
||||
* listFn est appelée dans le thread principal (accès qBittorrent),
|
||||
* puis les données sont passées au worker.
|
||||
* Auto-import — appelée depuis index.ts avec les seriesData du cache GitHub.
|
||||
*/
|
||||
export async function autoOrganizeAll(
|
||||
listFn : () => Promise<any[]>,
|
||||
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
listFn : () => Promise<any[]>,
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
// Vérifier si l'import auto est activé
|
||||
const { autoImport } = readSettings()
|
||||
if (!autoImport) return
|
||||
|
||||
if (workerRunning) {
|
||||
logger.debug('organize', 'Worker déjà en cours, skip')
|
||||
console.log('[organize] Worker déjà en cours, skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,76 +143,93 @@ export async function autoOrganizeAll(
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch (err) {
|
||||
logger.error('organize', "Impossible de récupérer la liste des torrents: " + (err instanceof Error ? err.message : String(err)))
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (torrents.length === 0) return
|
||||
|
||||
const isFirstRun = _prevStates.size === 0
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
|
||||
const newlySeeding = torrents.filter(t => {
|
||||
const prev = _prevStates.get(t.hash)
|
||||
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
|
||||
})
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
// Charger organized.json pour vérifier s'il reste des torrents non organisés
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
|
||||
)
|
||||
|
||||
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
|
||||
if (seedingCount === 0) return
|
||||
|
||||
logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
console.log(`[organize] ${newlySeeding.length} nouveau(x) torrent(s) terminé(s) → lancement worker`)
|
||||
} else {
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
}
|
||||
workerRunning = true
|
||||
|
||||
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
|
||||
const worker = new Worker(workerPath)
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
done : msg.done,
|
||||
skipped : msg.skipped,
|
||||
errors : msg.errors.length,
|
||||
errorFiles : msg.errors,
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
done : msg.done,
|
||||
skipped : msg.skipped,
|
||||
errors : msg.errors.length,
|
||||
errorFiles: msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
console.log('[organize] Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('error', (err) => { console.error('[organize] Worker erreur:', err); workerRunning = false })
|
||||
worker.on('exit', (code) => { if (code !== 0) console.error(`[organize] Worker exit ${code}`); workerRunning = false })
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
// Envoyer les torrents au worker
|
||||
worker.postMessage({ type: 'run', torrents })
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation manuelle d'un torrent (appelée depuis l'API /organize).
|
||||
* Lance aussi dans un worker thread.
|
||||
* Organisation manuelle d'un torrent.
|
||||
*/
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath: string
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
): Promise<OrganizeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
const worker = new Worker(workerPath)
|
||||
|
||||
// On envoie un seul torrent "fictif seeding"
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
const fakeTorrent = { hash, name, save_path: savePath, state: 'seeding' }
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
@@ -204,6 +244,6 @@ export async function organizeTorrent(
|
||||
worker.on('error', (err) => { worker.terminate(); reject(err) })
|
||||
worker.on('exit', (code) => { if (code !== 0) reject(new Error(`Worker exit ${code}`)) })
|
||||
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent] })
|
||||
worker.postMessage({ type: 'run', torrents: [fakeTorrent], seriesData })
|
||||
})
|
||||
}
|
||||
+2
-1
@@ -9,8 +9,9 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
|
||||
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
|
||||
+5
-7
@@ -1,19 +1,16 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& 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')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean // import automatique quand un torrent passe en seeding
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
@@ -22,6 +19,7 @@ const defaults: Settings = {
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -77,7 +78,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(DATA_DIR, 'torrent_clients.json')
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
|
||||
+92
-92
@@ -8,10 +8,8 @@
|
||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||
</router-link>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Icône logs -->
|
||||
<router-link to="/logs"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
@@ -21,32 +19,25 @@
|
||||
</svg>
|
||||
</router-link>
|
||||
<router-link to="/downloads"
|
||||
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
class="relative p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<!-- Badge compteur -->
|
||||
<span v-if="activeDownloads > 0"
|
||||
class="absolute -top-0.5 -right-0.5 min-w-[16px] h-4 bg-orange-500 text-white text-[9px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||||
{{ activeDownloads }}
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
<router-link to="/settings"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
<button
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<button class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition" @click="handleLogout">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||
</svg>
|
||||
@@ -54,59 +45,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<!-- Search + Filters + Sort -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-48 max-w-sm">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"
|
||||
/>
|
||||
<input v-model="search" type="text" placeholder="Rechercher…"
|
||||
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Groupe 1 : état de la série -->
|
||||
<button
|
||||
v-for="f in filtersGroupe1"
|
||||
:key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-orange-500 border-orange-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value"
|
||||
>{{ f.label }}</button>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-for="f in filtersGroupe1" :key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-orange-500 border-orange-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value">{{ f.label }}</button>
|
||||
|
||||
<span class="w-px h-5 bg-white/10 mx-1" />
|
||||
|
||||
<!-- Groupe 2 : état du téléchargement -->
|
||||
<button
|
||||
v-for="f in filtersGroupe2"
|
||||
:key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value"
|
||||
>{{ f.label }}</button>
|
||||
<button v-for="f in filtersGroupe2" :key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-blue-500 border-blue-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value">{{ f.label }}</button>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
|
||||
<!-- Tri -->
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<button v-for="s in sortOptions" :key="s.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeSort === s.value
|
||||
? 'bg-white/10 border-white/20 text-zinc-100'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeSort = s.value">{{ s.label }}</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-zinc-600">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement du catalogue…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
@@ -117,12 +105,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
|
||||
<p class="text-sm">Aucune série trouvée</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
|
||||
<router-link
|
||||
v-for="serie in filtered"
|
||||
@@ -131,15 +117,9 @@
|
||||
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
|
||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||
>
|
||||
<!-- Poster -->
|
||||
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<img v-if="serie.poster_image" :src="serie.poster_image" :alt="serie.title"
|
||||
loading="lazy" class="w-full h-full object-cover"/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/>
|
||||
@@ -147,16 +127,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Badge état téléchargement -->
|
||||
<!-- Badge état -->
|
||||
<div v-if="serie.download_state !== 'none'"
|
||||
class="absolute bottom-2 left-2 flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[9px] font-bold leading-none backdrop-blur-sm"
|
||||
:class="serie.download_state === 'complete' ? 'bg-green-500/90 text-white' :
|
||||
serie.download_state === 'partial' ? 'bg-orange-500/90 text-white' :
|
||||
serie.download_state === 'downloading' ? 'bg-blue-500/90 text-white' : ''">
|
||||
<!-- Icône -->
|
||||
<svg v-if="serie.download_state === 'complete'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
@@ -168,32 +144,26 @@
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
{{ serie.download_state === 'complete' ? 'ORGANISÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
||||
{{ serie.download_state === 'complete' ? 'IMPORTÉ' : serie.download_state === 'downloading' ? 'EN COURS' : 'PARTIEL' }}
|
||||
</div>
|
||||
|
||||
<!-- Overlay hover -->
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div>
|
||||
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-white/5 text-zinc-600'"
|
||||
>{{ serie.status }}</span>
|
||||
<span v-if="serie.status" class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-white/5 text-zinc-600'">{{ serie.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -205,36 +175,66 @@ import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const search = ref('')
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const activeSort = ref('alpha')
|
||||
const activeDownloads = ref(0)
|
||||
|
||||
const filtersGroupe1 = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
const filtersGroupe2 = [
|
||||
{ label: 'DL complet', value: 'complete' },
|
||||
{ label: 'DL partiel', value: 'downloading' },
|
||||
{ label: 'Importés', value: 'complete' },
|
||||
{ label: 'En cours', value: 'downloading' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'A→Z', value: 'alpha' },
|
||||
{ label: 'Z→A', value: 'alpha-desc' },
|
||||
{ label: 'Récents', value: 'recent' },
|
||||
{ label: 'Importés', value: 'imported' },
|
||||
]
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = store.series
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
|
||||
let list = [...store.series]
|
||||
|
||||
// Filtres
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => s.download_state === 'downloading' || s.download_state === 'partial')
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Tri
|
||||
switch (activeSort.value) {
|
||||
case 'alpha':
|
||||
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||
break
|
||||
case 'alpha-desc':
|
||||
list.sort((a, b) => b.title.localeCompare(a.title, 'fr'))
|
||||
break
|
||||
case 'recent':
|
||||
list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0))
|
||||
break
|
||||
case 'imported':
|
||||
// Importés en premier, puis partiel, puis reste
|
||||
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
|
||||
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
|
||||
break
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
@@ -243,7 +243,7 @@ async function handleLogout() {
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
// ── Polling downloads actifs + notifications organisation ──────
|
||||
// ── Polling downloads + notifs import ─────────────────────────
|
||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||
const seenNotifs = new Set<string>()
|
||||
|
||||
@@ -261,23 +261,24 @@ async function fetchOrganizeNotifs() {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const notifs: any[] = await res.json()
|
||||
let hasNew = false
|
||||
for (const n of notifs) {
|
||||
const key = `${n.hash}-${n.at}`
|
||||
if (seenNotifs.has(key)) continue
|
||||
seenNotifs.add(key)
|
||||
hasNew = true
|
||||
if (n.done > 0) {
|
||||
const msg = n.errors > 0
|
||||
? `${n.name} — ${n.done} fichier(s) organisé(s), ${n.errors} erreur(s)`
|
||||
: `${n.name} — ${n.done} fichier(s) organisé(s) ✓`
|
||||
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
|
||||
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
|
||||
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||
}
|
||||
}
|
||||
// Rafraîchir le catalogue si de nouveaux imports ont eu lieu
|
||||
if (hasNew) await store.fetchSeries()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Cache images ───────────────────────────────────────────────
|
||||
// Géré directement dans le store series.ts
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
fetchActiveDownloads()
|
||||
@@ -288,7 +289,6 @@ onMounted(async () => {
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
// Refresh au retour sur le catalogue (après update ou scan)
|
||||
onActivated(async () => {
|
||||
await store.fetchSeries()
|
||||
fetchActiveDownloads()
|
||||
|
||||
+79
-67
@@ -10,6 +10,10 @@
|
||||
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="importAll" :disabled="importingAll"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-green-500 hover:text-green-500 transition-colors cursor-pointer disabled:opacity-50">
|
||||
{{ importingAll ? '...' : 'IMPORTER TOUT' }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 text-[10px] text-[#5a7a94] tracking-widest">
|
||||
<div class="w-1.5 h-1.5 rounded-full" :class="polling ? 'bg-green-500 animate-pulse' : 'bg-[#1e2d3d]'"/>
|
||||
{{ polling ? 'EN DIRECT' : 'PAUSE' }}
|
||||
@@ -32,49 +36,39 @@
|
||||
<!-- Onglets -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-0 border border-[#1e2d3d]">
|
||||
<button
|
||||
@click="activeTab = 'active'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
|
||||
:class="activeTab === 'active'
|
||||
? 'bg-[#e8513a] text-white border-[#e8513a]'
|
||||
: 'text-[#5a7a94] hover:text-white'">
|
||||
<button @click="activeTab = 'active'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer"
|
||||
:class="activeTab === 'active' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||
EN COURS
|
||||
<span v-if="activeTorrents.length > 0"
|
||||
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
<span v-if="activeTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
{{ activeTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'done'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
|
||||
:class="activeTab === 'done'
|
||||
? 'bg-[#e8513a] text-white'
|
||||
: 'text-[#5a7a94] hover:text-white'">
|
||||
<button @click="activeTab = 'done'"
|
||||
class="px-5 py-2 text-xs tracking-widest transition-colors cursor-pointer border-l border-[#1e2d3d]"
|
||||
:class="activeTab === 'done' ? 'bg-[#e8513a] text-white' : 'text-[#5a7a94] hover:text-white'">
|
||||
TERMINÉS
|
||||
<span v-if="doneTorrents.length > 0"
|
||||
class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
<span v-if="doneTorrents.length > 0" class="ml-1.5 bg-white/20 text-[9px] px-1.5 py-0.5 rounded-full">
|
||||
{{ doneTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toggle masquer organisés (onglet TERMINÉS uniquement) -->
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
||||
<button @click="hideOrganized = !hideOrganized"
|
||||
<button @click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
|
||||
:class="hideOrganized ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
:class="hideImported ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideOrganized ? 'translate-x-4' : 'translate-x-0'" />
|
||||
:class="hideImported ? 'translate-x-4' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER ORGANISÉS</span>
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER IMPORTÉS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun client torrent configuré</p>
|
||||
<router-link to="/settings"
|
||||
class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||
<router-link to="/settings" class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||
CONFIGURER →
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -93,37 +87,30 @@
|
||||
<p class="text-[11px]">
|
||||
{{ activeTab === 'active'
|
||||
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
||||
: hideOrganized ? 'Tous les torrents terminés sont organisés' : '' }}
|
||||
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in visibleTorrents" :key="t.hash"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
<div v-for="t in visibleTorrents" :key="t.hash" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
|
||||
<!-- Ligne principale -->
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
|
||||
:class="stateBadge(t.state).class">
|
||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border" :class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-bold text-white">{{ t.progress }}%</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-0.5">
|
||||
{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}
|
||||
</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||
@@ -132,7 +119,6 @@
|
||||
:style="{ width: `${t.progress}%` }"/>
|
||||
</div>
|
||||
|
||||
<!-- Infos bas -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||
@@ -140,7 +126,6 @@
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
|
||||
</div>
|
||||
|
||||
<!-- Badge organisation (seulement pour les terminés) -->
|
||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border border-red-500/40 text-red-400 cursor-default">
|
||||
@@ -158,19 +143,19 @@
|
||||
</div>
|
||||
|
||||
<span class="text-[9px] tracking-widest px-2 py-0.5 border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-[#1e2d3d] text-[#5a7a94]'">
|
||||
{{ t.organizeState === 'done' ? `ORGANISÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-red-500/40 text-red-400'">
|
||||
{{ t.organizeState === 'done' ? `IMPORTÉ (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'NON ORGANISÉ' }}
|
||||
'NON IMPORTÉ' }}
|
||||
</span>
|
||||
|
||||
<button v-if="t.organizeState !== 'done'"
|
||||
@click="organize(t)"
|
||||
:disabled="organizing[t.hash]"
|
||||
@click="importTorrent(t)"
|
||||
:disabled="importing[t.hash]"
|
||||
class="text-[9px] tracking-widest px-3 py-1 border transition-colors cursor-pointer border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a] disabled:opacity-50">
|
||||
{{ organizing[t.hash] ? '...' : 'ORGANISER' }}
|
||||
{{ importing[t.hash] ? '...' : 'IMPORTER' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,17 +170,17 @@ import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const polling = ref(true)
|
||||
const noClients = ref(false)
|
||||
const organizing = ref<Record<string, boolean>>({})
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideOrganized = ref(false)
|
||||
const torrents = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const polling = ref(true)
|
||||
const noClients = ref(false)
|
||||
const importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideImported = ref(false)
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Filtres onglets ────────────────────────────────────────────
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
||||
)
|
||||
@@ -206,11 +191,10 @@ const doneTorrents = computed(() =>
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
if (activeTab.value === 'active') return activeTorrents.value
|
||||
if (hideOrganized.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
if (hideImported.value) return doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
return doneTorrents.value
|
||||
})
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────
|
||||
const stats = computed(() => [
|
||||
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
@@ -218,7 +202,6 @@ const stats = computed(() => [
|
||||
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -227,7 +210,6 @@ async function fetchTorrents() {
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
} catch {
|
||||
// silencieux en polling
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -251,30 +233,62 @@ function togglePolling() {
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
// ── Organiser ──────────────────────────────────────────────────
|
||||
async function organize(torrent: any) {
|
||||
organizing.value[torrent.hash] = true
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||
})
|
||||
if (res.ok) {
|
||||
toast(`${torrent.name} organisé ✓`, 'success')
|
||||
toast(`${torrent.name} importé ✓`, 'success')
|
||||
await fetchTorrents()
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de l\'organisation', 'error')
|
||||
toast(error ?? "Erreur lors de l'import", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
organizing.value[torrent.hash] = false
|
||||
importing.value[torrent.hash] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers affichage ──────────────────────────────────────────
|
||||
async function importAll() {
|
||||
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
if (toImport.length === 0) {
|
||||
toast('Tous les torrents sont déjà importés', 'success')
|
||||
return
|
||||
}
|
||||
|
||||
importingAll.value = true
|
||||
let done = 0
|
||||
let errors = 0
|
||||
|
||||
for (const t of toImport) {
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name })
|
||||
})
|
||||
if (res.ok) done++
|
||||
else errors++
|
||||
} catch {
|
||||
errors++
|
||||
}
|
||||
}
|
||||
|
||||
importingAll.value = false
|
||||
await fetchTorrents()
|
||||
|
||||
if (errors === 0) toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||
else toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
||||
@@ -294,9 +308,7 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatSpeed(bps: number): string {
|
||||
return `${formatSize(bps)}/s`
|
||||
}
|
||||
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
|
||||
+31
-25
@@ -27,7 +27,8 @@
|
||||
<!-- Hero -->
|
||||
<div class="relative">
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-72 overflow-hidden pointer-events-none">
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20" />
|
||||
<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>
|
||||
|
||||
@@ -64,6 +65,7 @@
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
:title="t.raw"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
@@ -81,12 +83,10 @@
|
||||
<!-- Barre progression intégrale -->
|
||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||
<template v-if="torrentProgress(extractHash(t))">
|
||||
<!-- Badge 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">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
<!-- Barre en cours -->
|
||||
<div v-else class="mt-2 w-full max-w-sm">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement</span>
|
||||
@@ -160,16 +160,15 @@
|
||||
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression saison -->
|
||||
<template v-if="torrentProgress(extractHash(season.torrent))">
|
||||
<!-- Badge 100% -->
|
||||
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
|
||||
class="px-5 py-2 border-b border-white/5">
|
||||
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
</div>
|
||||
<!-- Barre en cours -->
|
||||
<div v-else class="px-5 py-2 border-b border-white/5">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement en cours</span>
|
||||
@@ -202,9 +201,9 @@
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
</span>
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
@@ -213,38 +212,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton épisode (type episode uniquement) -->
|
||||
<!-- Badges + bouton download -->
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
|
||||
<!-- Badge Hors Fankai (torrent manuel) -->
|
||||
<span
|
||||
v-if="ep.torrent?.manual"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
>
|
||||
Hors Fankai
|
||||
</span>
|
||||
|
||||
<!-- Badge organisé -->
|
||||
<span v-if="ep.organized"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ organisé
|
||||
</span>
|
||||
✓ organisé
|
||||
</span>
|
||||
|
||||
<!-- Badge téléchargé à 100% -->
|
||||
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ téléchargé
|
||||
</span>
|
||||
✓ téléchargé
|
||||
</span>
|
||||
|
||||
<!-- Badge dispo/indispo -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
||||
:class="ep.available
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton download -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="[
|
||||
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
|
||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
@@ -317,7 +326,7 @@ const store = useSeriesStore()
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(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)
|
||||
|
||||
// ── Progression downloads ──────────────────────────────────────
|
||||
@@ -339,13 +348,11 @@ async function fetchActiveDownloads() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Cherche si un torrent actif correspond à un hash donné
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return 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 {
|
||||
if (!torrent) return null
|
||||
const magnet = torrent.magnet ?? ''
|
||||
@@ -375,7 +382,6 @@ function showToast(type: 'success' | 'error', message: string) {
|
||||
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 {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
|
||||
+56
-56
@@ -15,7 +15,7 @@
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
|
||||
<p class="text-sm text-[#a0b4c4]">
|
||||
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
|
||||
{{ torrentsStatus.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser Fankarr.
|
||||
</p>
|
||||
</div>
|
||||
@@ -28,9 +28,9 @@
|
||||
<!-- Données torrents -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
|
||||
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
|
||||
<h2 class="text-sm font-bold text-white mb-1">Catalogue Fankai</h2>
|
||||
<p class="text-[11px] text-[#5a7a94] mb-5">
|
||||
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
|
||||
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} séries disponibles` }}
|
||||
</p>
|
||||
<button @click="update" :disabled="updating"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
@@ -78,17 +78,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Médias & Organisation -->
|
||||
<!-- Médias & Import -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Import</h2>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<!-- Dossier complétion -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER TÉLÉCHARGEMENTS</label>
|
||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')"
|
||||
@@ -96,13 +96,13 @@
|
||||
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier Jellyfin -->
|
||||
<!-- Dossier médiathèque -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">MÉDIATHÈQUE FANKAI</label>
|
||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')"
|
||||
@@ -110,12 +110,12 @@
|
||||
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de votre médiathèque Fankai (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode organisation -->
|
||||
<!-- Mode import -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE D'IMPORT</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="form.organizeMode = 'hardlink'"
|
||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||
@@ -129,11 +129,28 @@
|
||||
:class="form.organizeMode === 'move'
|
||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
MOVE
|
||||
DÉPLACER
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
|
||||
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Import automatique -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">IMPORT AUTOMATIQUE</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="form.autoImport = !form.autoImport"
|
||||
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
|
||||
:class="form.autoImport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="form.autoImport ? 'translate-x-5' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs text-[#c8d8e8]">Importer automatiquement dès qu'un téléchargement est terminé</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
Vérifie toutes les 5 minutes si de nouveaux fichiers sont prêts à être importés
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +168,7 @@
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
|
||||
<span class="text-white">fankai_pack</span> au moment de l'organisation
|
||||
<span class="text-white">fankai_pack</span> lors de l'import
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +179,7 @@
|
||||
</button>
|
||||
<button type="button" @click="scan" :disabled="scanning"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{{ scanning ? 'SCAN...' : 'ANALYSER BIBLIOTHÈQUE' }}
|
||||
{{ scanning ? 'SCAN...' : 'ANALYSER MÉDIATHÈQUE' }}
|
||||
</button>
|
||||
<span v-if="scanResult" class="text-[10px]"
|
||||
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
|
||||
@@ -200,7 +217,7 @@
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
|
||||
<input v-model="modal.name" placeholder="Mon qBittorrent"
|
||||
<input v-model="modal.name" placeholder="Mon client torrent"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
||||
</div>
|
||||
|
||||
@@ -259,11 +276,12 @@ const form = ref({
|
||||
completePath: '',
|
||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const updating = ref(false)
|
||||
const scanning = ref(false)
|
||||
const saving = ref(false)
|
||||
const updating = ref(false)
|
||||
const scanning = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
|
||||
// ── Folder picker ──────────────────────────────────────────────
|
||||
@@ -272,23 +290,19 @@ const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; current
|
||||
})
|
||||
|
||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||
picker.value = {
|
||||
open : true,
|
||||
field,
|
||||
currentPath: (form.value as any)[field] || '/',
|
||||
}
|
||||
picker.value = { open: true, field, currentPath: (form.value as any)[field] || '/' }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
(form.value as any)[picker.value.field] = path
|
||||
picker.value.open = false
|
||||
}
|
||||
const isDocker = ref(false)
|
||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||
|
||||
const clients = ref<any[]>([])
|
||||
const isDocker = ref(false)
|
||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||
const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
@@ -333,9 +347,7 @@ async function refreshHealth(uuid: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
||||
} catch {
|
||||
healthStatus.value[uuid] = false
|
||||
}
|
||||
} catch { healthStatus.value[uuid] = false }
|
||||
}
|
||||
|
||||
// ── Test / delete clients ──────────────────────────────────────
|
||||
@@ -362,9 +374,7 @@ function openAddModal() {
|
||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.value.open = false
|
||||
}
|
||||
function closeModal() { modal.value.open = false }
|
||||
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
@@ -382,9 +392,7 @@ async function testNewClient() {
|
||||
modal.value.tested = false
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients/test-config', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
@@ -402,14 +410,8 @@ async function saveClient() {
|
||||
modal.value.saving = true
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name : modal.value.name || clientLabel(modal.value.type),
|
||||
type : modal.value.type,
|
||||
config: modal.value.config
|
||||
})
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ name: modal.value.name || clientLabel(modal.value.type), type: modal.value.type, config: modal.value.config })
|
||||
})
|
||||
if (res.ok) {
|
||||
const client = await res.json()
|
||||
@@ -426,7 +428,7 @@ async function saveClient() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Données torrents ───────────────────────────────────────────
|
||||
// ── Données ────────────────────────────────────────────────────
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
@@ -434,7 +436,7 @@ async function update() {
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
torrentsStatus.value = { exists: true, count, empty: count === 0 }
|
||||
toast(`${count} torrents téléchargés ✓`, 'success')
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
@@ -446,7 +448,7 @@ async function update() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan bibliothèque ──────────────────────────────────────────
|
||||
// ── Scan ───────────────────────────────────────────────────────
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanResult.value = null
|
||||
@@ -457,12 +459,12 @@ async function scan() {
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
toast(
|
||||
data.added > 0
|
||||
? `${data.found} fichiers scannés — ${data.added} ajoutés`
|
||||
: `${data.found} fichiers scannés — rien de nouveau`,
|
||||
? `${data.found} fichiers analysés — ${data.added} ajoutés`
|
||||
: `${data.found} fichiers analysés — rien de nouveau`,
|
||||
'success'
|
||||
)
|
||||
} else {
|
||||
toast('Erreur lors du scan', 'error')
|
||||
toast('Erreur lors de l\'analyse', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
@@ -471,14 +473,12 @@ async function scan() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarder settings ───────────────────────────────────────
|
||||
// ── Sauvegarder ────────────────────────────────────────────────
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ export default defineConfig({
|
||||
outDir: 'dist/client'
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3000, // ou autre port fixe
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
'/api': 'http://localhost:9898'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user