Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
558f4e2a1b | ||
|
|
352c18029f | ||
|
|
465d3dba8b | ||
|
|
7b82d60c69 | ||
|
|
af03aecf0d | ||
|
|
f878298cef | ||
|
|
fc5dd1d36d | ||
|
|
1b73e7d0a3 | ||
|
|
8030bf14f9 | ||
|
|
908a8b6076 | ||
|
|
0d2708ca09 | ||
|
|
fb7e443c23 | ||
|
|
8fab68a0e4 | ||
|
|
0c06941452 | ||
|
|
8b8c5cc930 | ||
|
|
1dc01a101f | ||
|
|
054dcb915a | ||
|
|
d0c876b885 | ||
|
|
ce0bf1b8ab | ||
|
|
014c7bbbc3 | ||
|
|
f3bcc68104 | ||
|
|
a8978c1a43 | ||
|
|
40632c8b06 | ||
|
|
71d3c557e9 | ||
|
|
0825cb2f44 | ||
|
|
cec376ade6 | ||
|
|
f01a37dfd8 | ||
|
|
c9adcb5abc | ||
|
|
3dbb0757c4 | ||
|
|
8417aa98ca | ||
|
|
00348996b5 | ||
|
|
89fba07e35 | ||
|
|
59f2c06b61 | ||
|
|
0f0b0c07f6 | ||
|
|
0c8fa42f4f | ||
|
|
8f6e0340ff | ||
|
|
693f8a96ae | ||
|
|
f38a43d396 | ||
|
|
510e1f7b5a | ||
|
|
91f5918233 | ||
|
|
71e3423ad4 | ||
|
|
15b4a99c86 | ||
|
|
14f02d1bdd | ||
|
|
ac6ac46a14 | ||
|
|
66f5771ca7 | ||
|
|
40a12a870b | ||
|
|
e7afe138e1 | ||
|
|
8656e7fa36 | ||
|
|
d21ddd6dfc | ||
|
|
867186db98 | ||
|
|
dd320e92dc | ||
|
|
44ac228ad6 | ||
|
|
1a4adabef9 | ||
|
|
a6fc655cb7 | ||
|
|
c0fb601ef0 | ||
|
|
b9b940e874 | ||
|
|
5a006664c0 | ||
|
|
43c4422b4f | ||
|
|
6410fcee1f | ||
|
|
3b281b60ee | ||
|
|
0e6e13332f | ||
|
|
b8f0984ec6 | ||
|
|
e3bca6be87 | ||
|
|
b88e020fb5 | ||
|
|
7f3f8656b3 | ||
|
|
97da422037 | ||
|
|
43dbfefc7a | ||
|
|
0c576fb59f | ||
|
|
c097cd4d1f | ||
|
|
e67efe39f4 | ||
|
|
237530321e | ||
|
|
91c27cf2f3 | ||
|
|
19d09de88e | ||
|
|
5850265f6d | ||
|
|
27f55ab2be | ||
|
|
f871644330 | ||
|
|
a5e97088c4 | ||
|
|
ca4542d71b | ||
|
|
61418c6f77 | ||
|
|
1d89a8f61e | ||
|
|
db5454393f | ||
|
|
e18c6a1c54 | ||
|
|
49d60aeabd | ||
|
|
c78537c9d8 |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 859 KiB |
@@ -0,0 +1,119 @@
|
||||
name: Build Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Vue frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload frontend dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/client/
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.target }}
|
||||
needs: build-frontend
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: windows-x64
|
||||
os: windows-latest
|
||||
bun-target: bun-windows-x64
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64.zip
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-x64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-x64.zip
|
||||
- target: macos
|
||||
os: macos-latest
|
||||
bun-target: bun-darwin-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-macos.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: public/
|
||||
|
||||
- name: Build binary (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
mkdir -p binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
|
||||
- name: Build binary (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Build worker
|
||||
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
|
||||
|
||||
- name: Package archive (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Package archive (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
chmod +x ${{ matrix.binary }}
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
|
||||
|
||||
- name: Upload archive to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.archive }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
binaries
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
+470
@@ -1,5 +1,475 @@
|
||||
# Changelog
|
||||
|
||||
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
|
||||
|
||||
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
|
||||
|
||||
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* suppression de torrent_final.json au profit d'un système de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h. Version majeure — nécessite un nouveau scraper et une mise à jour complète.
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** adaptation complète à la nouvelle structure API Fankai ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** cache mémoire GitHub TTL 6h avec invalidation via /api/update ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** matching torrents par hiérarchie série/saison/épisode ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* migration vers nouvelle architecture API et scraper par série ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **organize:** refonte complète sans torrent_final.json ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **scraper:** extraction nom bencode dans raw pour torrents locaux ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** badge "Hors Fankai" sur épisodes de torrents manuels ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** tooltip raw name sur boutons intégrale via :title ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état download basé sur épisodes organisés vs torrents bruts ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series:** boutons saison Yu-Gi-Oh via fallback season_number ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
|
||||
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
|
||||
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
|
||||
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
|
||||
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
|
||||
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
|
||||
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
|
||||
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
|
||||
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
|
||||
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
|
||||
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
|
||||
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
|
||||
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
|
||||
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
|
||||
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
|
||||
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
|
||||
## [1.10.1](https://github.com/Masutayunikon/FanKarr/compare/v1.10.0...v1.10.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
|
||||
## [1.10.0](https://github.com/Masutayunikon/FanKarr/compare/v1.9.5...v1.10.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.9.5](https://github.com/Masutayunikon/FanKarr/compare/v1.9.4...v1.9.5) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
|
||||
## [1.9.4](https://github.com/Masutayunikon/FanKarr/compare/v1.9.3...v1.9.4) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
|
||||
## [1.9.3](https://github.com/Masutayunikon/FanKarr/compare/v1.9.2...v1.9.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
|
||||
## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
|
||||
## [1.9.1](https://github.com/Masutayunikon/FanKarr/compare/v1.9.0...v1.9.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.9.0](https://github.com/Masutayunikon/FanKarr/compare/v1.8.2...v1.9.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
|
||||
## [1.8.2](https://github.com/Masutayunikon/FanKarr/compare/v1.8.1...v1.8.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
|
||||
## [1.8.1](https://github.com/Masutayunikon/FanKarr/compare/v1.8.0...v1.8.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
|
||||
## [1.8.0](https://github.com/Masutayunikon/FanKarr/compare/v1.7.3...v1.8.0) (2026-03-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
|
||||
## [1.7.3](https://github.com/Masutayunikon/FanKarr/compare/v1.7.2...v1.7.3) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
|
||||
## [1.7.2](https://github.com/Masutayunikon/FanKarr/compare/v1.7.1...v1.7.2) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
|
||||
## [1.7.1](https://github.com/Masutayunikon/FanKarr/compare/v1.7.0...v1.7.1) (2026-03-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
|
||||
## [1.7.0](https://github.com/Masutayunikon/FanKarr/compare/v1.6.0...v1.7.0) (2026-03-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
|
||||
## [1.6.0](https://github.com/Masutayunikon/FanKarr/compare/v1.5.0...v1.6.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
|
||||
## [1.5.0](https://github.com/Masutayunikon/FanKarr/compare/v1.4.1...v1.5.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
|
||||
## [1.4.1](https://github.com/Masutayunikon/FanKarr/compare/v1.4.0...v1.4.1) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
|
||||
## [1.4.0](https://github.com/Masutayunikon/FanKarr/compare/v1.3.0...v1.4.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
|
||||
## [1.3.0](https://github.com/Masutayunikon/FanKarr/compare/v1.2.0...v1.3.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
|
||||
## [1.2.0](https://github.com/Masutayunikon/FanKarr/compare/v1.1.0...v1.2.0) (2026-03-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
|
||||
## [1.1.0](https://github.com/Masutayunikon/FanKarr/compare/v1.0.6...v1.1.0) (2026-03-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
|
||||
## [1.0.6](https://github.com/Masutayunikon/FanKarr/compare/v1.0.5...v1.0.6) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
|
||||
## [1.0.5](https://github.com/Masutayunikon/FanKarr/compare/v1.0.4...v1.0.5) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
|
||||
## [1.0.4](https://github.com/Masutayunikon/FanKarr/compare/v1.0.3...v1.0.4) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
|
||||
## [1.0.3](https://github.com/Masutayunikon/FanKarr/compare/v1.0.2...v1.0.3) (2026-03-10)
|
||||
|
||||
|
||||
|
||||
+8
-4
@@ -15,13 +15,17 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN pnpm run build
|
||||
|
||||
# ── Production ─────────────────────────────────────────────────
|
||||
FROM base
|
||||
FROM node:20-slim
|
||||
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/client /app/public
|
||||
|
||||
RUN mkdir -p /app/data
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /config
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
CMD ["node", "dist/server/index.js"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -1,5 +1,193 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
<div align="center">
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
# Fankarr
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**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>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue catalogue : toutes les séries Fankai avec leur état d'organisation. Les badges indiquent si les fichiers sont déjà présents dans votre médiathèque Jellyfin.
|
||||
|
||||
---
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Catalogue complet** des séries Fankai avec affiches, statut et badges d'état
|
||||
- **Téléchargement en un clic** vers qBittorrent — par épisode, par saison ou intégrale
|
||||
- **Organisation automatique** des fichiers téléchargés vers votre médiathèque (hardlink ou déplacement)
|
||||
- **Suivi par épisode** — badge ✓ organisé sur chaque épisode et compteur par saison
|
||||
- **Notifications** toast automatiques quand une organisation se termine
|
||||
- **Page téléchargements** avec statut d'organisation et badge erreurs avec détail au hover
|
||||
- **Logs centralisés** avec filtre par niveau/source, rotation automatique et clear depuis l'UI
|
||||
- **Multi-client torrent** — architecture extensible (qBittorrent supporté)
|
||||
- **Authentification** par mot de passe avec session JWT
|
||||
- **Compatible Runtipi / Docker**
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue série : détail des épisodes avec leur état de disponibilité torrent et d'organisation dans Jellyfin.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Vue téléchargements : suivi en temps réel de l'organisation avec détail des erreurs au survol.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
> Page logs : historique complet des événements avec filtres et gestion de la taille.
|
||||
|
||||
---
|
||||
|
||||
## Données torrent — Scraper
|
||||
|
||||
Les données du catalogue sont récupérées depuis le repo [`fankarr-scraper`](https://github.com/masutayunikon/fankarr-scraper).
|
||||
|
||||
Ce repo contient un pipeline de scripts Python qui collecte, parse et résout les torrents Fankai depuis les trackers publics, puis les croise avec l'API metadata de Fankai pour identifier chaque épisode. Les scripts tournent automatiquement **toutes les 6 heures** via GitHub Actions et publient le fichier `torrent_final.json` dans le repo.
|
||||
|
||||
Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour** dans les paramètres. Aucun scraping ne se fait en local.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker Compose (recommandé)
|
||||
|
||||
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
|
||||
|
||||
```yaml
|
||||
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:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
|
||||
ports:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
```bash
|
||||
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` |
|
||||
|
||||
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
|
||||
|
||||
```
|
||||
fankarr-windows-x64/
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
└── public/ ← ne pas déplacer séparément
|
||||
```
|
||||
|
||||
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
| ---------------- | ------------ | ----------------------------------------------------------------------- |
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (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: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`
|
||||
5. Choisir le mode d'organisation : `hardlink` (recommandé) ou `move`
|
||||
6. Retourner au catalogue et télécharger
|
||||
|
||||
---
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes** vers la structure attendue par Jellyfin :
|
||||
|
||||
```
|
||||
/media/Kai/
|
||||
└── Black Lagoon Henshū/
|
||||
└── Saison 1/
|
||||
├── Black Lagoon Henshū S01E01.mkv
|
||||
├── Black Lagoon Henshū S01E02.mkv
|
||||
└── ...
|
||||
```
|
||||
|
||||
Le mode **hardlink** est recommandé si vos dossiers `complete` et `media` sont sur le même filesystem — les fichiers ne sont pas déplacés, qBittorrent continue de seeder.
|
||||
|
||||
---
|
||||
|
||||
## Runtipi
|
||||
|
||||
Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
1. Dans Runtipi, aller dans **Paramètres → App Stores**
|
||||
2. Ajouter l'URL : `https://github.com/Masutayunikon/runtipi-appstore`
|
||||
3. Fankarr apparaît dans le catalogue → **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) |
|
||||
| Packaging binaire | Bun (self-contained, pas de Node requis) |
|
||||
|
||||
---
|
||||
|
||||
## Liens
|
||||
|
||||
- [fankai.fr](https://fankai.fr) — Le projet Fankai
|
||||
- [Plugin Jellyfin Fankai](https://github.com/Nackophilz/fankai_jellyfin) — Reconnaissance des métadonnées dans Jellyfin
|
||||
- [fankarr-scraper](https://github.com/masutayunikon/fankarr-scraper) — Pipeline de collecte des torrents
|
||||
- [runtipi-appstore](https://github.com/Masutayunikon/runtipi-appstore) — Appstore Runtipi personnel
|
||||
@@ -0,0 +1,14 @@
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
|
||||
ports:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||
|
||||
# Corriger les permissions du dossier data
|
||||
chown -R "$PUID:$PGID" /config
|
||||
|
||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||
+10
-5
@@ -7,12 +7,16 @@
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build --outDir dist/client && tsc -p tsconfig.server.json",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
|
||||
"start": "node dist/server/index.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -20,10 +24,10 @@
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -34,6 +38,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
"vue-tsc": "^3.1.5",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
}
|
||||
}
|
||||
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:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
+6
-5
@@ -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 JWT_SECRET = process.env.JWT_SECRET || 'fankarr-secret-change-me'
|
||||
const DATA_PATH = path.join(DATA_DIR, 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
@@ -63,7 +64,7 @@ export function authSetup(req: Request, res: Response): void {
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
@@ -87,7 +88,7 @@ export function authLogin(req: Request, res: Response): void {
|
||||
return
|
||||
}
|
||||
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '30d' })
|
||||
const token = jwt.sign({ username }, JWT_SECRET, { expiresIn: '1d' })
|
||||
res.cookie('fankarr_token', token, { httpOnly: true, sameSite: 'lax' })
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
+584
-129
@@ -10,22 +10,23 @@ import {
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const GITHUB_BASE = process.env.GITHUB_BASE
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main'
|
||||
|
||||
app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── Static frontend (prod) ─────────────────────────────────────
|
||||
const PUBLIC_PATH = path.join(process.cwd(), 'public')
|
||||
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.use(express.static(PUBLIC_PATH))
|
||||
}
|
||||
@@ -41,8 +42,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -85,39 +86,326 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
function readTorrents(): any[] {
|
||||
// ── Cache GitHub ───────────────────────────────────────────────
|
||||
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000 // 6 heures
|
||||
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const _cache = new Map<string, CacheEntry<any>>()
|
||||
|
||||
function cacheGet<T>(key: string): T | null {
|
||||
const entry = _cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) { _cache.delete(key); return null }
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
function cacheSet<T>(key: string, data: T): void {
|
||||
_cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
function cacheClear(): void { _cache.clear() }
|
||||
|
||||
async function githubGet(urlPath: string): Promise<any> {
|
||||
const key = urlPath
|
||||
const cached = cacheGet(key)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`${GITHUB_BASE}/${urlPath}`)
|
||||
if (!res.ok) throw new Error(`GitHub ${res.status}: ${urlPath}`)
|
||||
const data = await res.json()
|
||||
cacheSet(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function readAvailable(): Promise<number[]> {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
return await githubGet('available.json') as number[]
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
async function readSerieData(serieId: number): Promise<any | null> {
|
||||
try {
|
||||
return await githubGet(`series/${serieId}.json`)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// Extrait tous les torrents d'un series/{id}.json avec leur type et niveau hiérarchique
|
||||
// Retourne une liste plate compatible avec l'ancienne interface { infohash, torrent_url, magnet, raw, type, ... }
|
||||
function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
|
||||
// Torrents au niveau série → pack_integrale (ou pack_saison si une seule saison)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'pack_integrale',
|
||||
serie_id : sd.id,
|
||||
serie_title: sd.title,
|
||||
// episodes couverts : tous les épisodes de toutes les saisons qui ont paths[i]
|
||||
_serieLevel: true,
|
||||
_serieData : sd,
|
||||
_torrentIdx: (sd.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
|
||||
// Torrents au niveau saison → pack_saison
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'pack_saison',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
_seasonLevel : true,
|
||||
_season : season,
|
||||
_torrentIdx : (season.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
|
||||
// Torrents au niveau épisode → episode
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
result.push({
|
||||
...t,
|
||||
raw : t.title,
|
||||
type : 'episode',
|
||||
serie_id : sd.id,
|
||||
serie_title : sd.title,
|
||||
season_id : season.id,
|
||||
season_number: season.season_number,
|
||||
episode_id : ep.id,
|
||||
episode_number: ep.episode_number,
|
||||
_episodeLevel: true,
|
||||
_episode : ep,
|
||||
_torrentIdx : (ep.torrents ?? []).indexOf(t),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Construit resolved_episodes pour un torrent à partir des paths[torrentIdx]
|
||||
// Construit les épisodes résolus pour un torrent donné par son infohash
|
||||
function buildResolvedEpisodes(sd: any, hash: string, seasonFilter?: number): any[] {
|
||||
const resolved: any[] = []
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of sd.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
|
||||
// Nouveau format { infohash, path } ou legacy string
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!filePath) filePath = p // legacy fallback
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
filePath = p.path
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!filePath) continue
|
||||
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({
|
||||
episode_id : ep.id,
|
||||
episode_number : ep.episode_number,
|
||||
season_number : season.season_number,
|
||||
season_id : season.id,
|
||||
filename,
|
||||
original_filename: filename,
|
||||
})
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
if (!t.serie_id) continue
|
||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
||||
map.get(t.serie_id)!.push(t)
|
||||
}
|
||||
return map
|
||||
|
||||
function normalizeTitle(s: string): string {
|
||||
return s.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function imgProxy(url: string | null | undefined, w: number, q = 70): string | null {
|
||||
if (!url) return null
|
||||
const stripped = url.replace(/^https?:\/\//, '')
|
||||
return `https://wsrv.nl/?url=${stripped}&w=${w}&q=${q}`
|
||||
}
|
||||
|
||||
function normalizeSerie(serie: any): any {
|
||||
const poster = serie.poster_image ?? serie.images?.poster ?? null
|
||||
const fanart = serie.fanart_image ?? serie.images?.fanart ?? null
|
||||
return {
|
||||
...serie,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
fanart_image: imgProxy(fanart, 1280, 60),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSeason(season: any): any {
|
||||
const poster = season.poster_image ?? season.images?.poster ?? null
|
||||
return {
|
||||
...season,
|
||||
poster_image: imgProxy(poster, 300),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEpisode(ep: any): any {
|
||||
const thumb = ep.thumb_image ?? ep.thumbnail ?? null
|
||||
return {
|
||||
...ep,
|
||||
thumb_image: imgProxy(thumb, 400),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Enrichissement seriesData avec original_filename depuis l'API ──────────────
|
||||
// Fetche /seasons/{id}/episodes pour chaque saison et injecte original_filename
|
||||
// dans chaque épisode du serieData, indexé par episode_id
|
||||
async function enrichSeriesDataWithOriginalFilenames(seriesData: any[]): Promise<any[]> {
|
||||
return Promise.all(seriesData.map(async (sd) => {
|
||||
if (!sd) return sd
|
||||
const enrichedSeasons = await Promise.all((sd.seasons ?? []).map(async (season: any) => {
|
||||
let epsData: any[] = []
|
||||
try {
|
||||
const res = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
epsData = Array.isArray(res) ? res : (res.episodes ?? [])
|
||||
} catch { return season }
|
||||
|
||||
// Index episode_id → original_filename
|
||||
const origMap = new Map<number, string>()
|
||||
for (const ep of epsData) {
|
||||
if (ep.id && ep.original_filename) origMap.set(ep.id, ep.original_filename)
|
||||
}
|
||||
|
||||
// Injecter dans les épisodes du serieData
|
||||
const enrichedEpisodes = (season.episodes ?? []).map((ep: any) => ({
|
||||
...ep,
|
||||
original_filename: origMap.get(ep.id) ?? null,
|
||||
}))
|
||||
|
||||
return { ...season, episodes: enrichedEpisodes }
|
||||
}))
|
||||
return { ...sd, seasons: enrichedSeasons }
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper réutilisable : charge + enrichit toutes les seriesData du cache
|
||||
async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
const valid = allSd.filter(Boolean)
|
||||
return enrichSeriesDataWithOriginalFilenames(valid)
|
||||
}
|
||||
|
||||
function computeSerieDownloadState(
|
||||
serieData : any | null,
|
||||
organized : Record<string, Record<string, string>>,
|
||||
activeTorrents: Set<string>
|
||||
): 'none' | 'downloading' | 'partial' | 'complete' {
|
||||
if (!serieData) return 'none'
|
||||
|
||||
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'
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
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 seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
res.json({ series: seriesList.map((serie: any) => ({
|
||||
...serie,
|
||||
torrent_count: byId.get(serie.id)?.length ?? 0,
|
||||
has_torrents : byId.has(serie.id)
|
||||
}))})
|
||||
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(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
// Récupérer les hashes actifs depuis qB (best effort)
|
||||
const activeTorrents = new Set<string>()
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const active = await dispatchList(category ?? 'fankai')
|
||||
for (const t of active) {
|
||||
if (t.hash) activeTorrents.add(t.hash.toLowerCase())
|
||||
}
|
||||
} catch {}
|
||||
|
||||
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 : serieData ? extractTorrentsFromSerieData(serieData).length : 0,
|
||||
has_torrents : hasTorrents,
|
||||
download_state: computeSerieDownloadState(serieData, organized, activeTorrents),
|
||||
}
|
||||
})})
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
@@ -126,89 +414,127 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const id = Number(req.params.id)
|
||||
try {
|
||||
const [serie, seasonsData] = await Promise.all([fankaiGet(`/series/${id}`), fankaiGet(`/series/${id}/seasons`)])
|
||||
const [serieRaw, seasonsData, serieData] = await Promise.all([
|
||||
fankaiGet(`/series/${id}`),
|
||||
fankaiGet(`/series/${id}/seasons`),
|
||||
readSerieData(id),
|
||||
])
|
||||
const serie = normalizeSerie(serieRaw)
|
||||
const seasons = Array.isArray(seasonsData) ? seasonsData : (seasonsData.seasons ?? [])
|
||||
|
||||
// Fetch épisodes depuis l'API Fankai (avec original_filename, thumb_image, etc.)
|
||||
const seasonsWithEpisodes = await Promise.all(seasons.map(async (season: any) => {
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
const epsData = await fankaiGet(`/seasons/${season.id}/episodes`)
|
||||
const episodes = (Array.isArray(epsData) ? epsData : (epsData.episodes ?? [])).map(normalizeEpisode)
|
||||
return { ...normalizeSeason(season), episodes }
|
||||
}))
|
||||
|
||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
// Charger organized.json
|
||||
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 {}
|
||||
|
||||
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 }
|
||||
// ── 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)
|
||||
}
|
||||
} else if (t.type === 'pack_integrale') {
|
||||
integraleTorrents.push(t)
|
||||
} else if (t.type === 'pack_episodes') {
|
||||
packEpisodesTorrents.push(t)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
// ── 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
|
||||
|
||||
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)
|
||||
// ── Enrichissement des saisons API ───────────────────────────────
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
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'
|
||||
return {
|
||||
...season,
|
||||
torrent : seasonTorrentMapBySn[season.season_number] ?? null,
|
||||
organized_state : seasonOrganizedState,
|
||||
organized_count : orgCount,
|
||||
episodes : eps,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── Hero bundles ──────────────────────────────────────────────────
|
||||
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(' · ')
|
||||
return t.raw ?? t.title ?? 'Pack'
|
||||
}
|
||||
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => ({
|
||||
...season,
|
||||
torrent: seasonTorrentMap[season.id] ?? null,
|
||||
episodes: season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available: availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null
|
||||
}))
|
||||
}))
|
||||
|
||||
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 })
|
||||
@@ -230,13 +556,76 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { category } = readSettings()
|
||||
const torrents = await dispatchList(category ?? 'fankai')
|
||||
res.json(torrents)
|
||||
|
||||
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 enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
// 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'
|
||||
|
||||
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) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Organize ───────────────────────────────────────────────────
|
||||
// ── Résultats d'organisation récents ──────────────────────────
|
||||
interface OrganizeNotif {
|
||||
hash : string
|
||||
name : string
|
||||
done : number
|
||||
skipped : number
|
||||
errors : number
|
||||
errorFiles : { file: string; error: string }[]
|
||||
at : string
|
||||
}
|
||||
const recentOrganized: OrganizeNotif[] = []
|
||||
const MAX_NOTIFS = 20
|
||||
|
||||
function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
}
|
||||
|
||||
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
|
||||
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) {
|
||||
@@ -244,41 +633,85 @@ 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 ────────────────────────────────────────────
|
||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||
// ── Logs ──────────────────────────────────────────────────────
|
||||
app.get('/api/logs', requireAuth, (req, res) => {
|
||||
const limit = Number(req.query.limit) || 100
|
||||
const level = (req.query.level as string) || 'all'
|
||||
const source = (req.query.source as string) || undefined
|
||||
const entries = readLogs({ limit, level: level as any, source })
|
||||
res.json({ entries, size: logsFileSize() })
|
||||
})
|
||||
|
||||
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── System info ────────────────────────────────────────────────
|
||||
app.get('/api/system', requireAuth, (_req, res) => {
|
||||
const isDocker = fs.existsSync('/.dockerenv')
|
||||
res.json({ isDocker })
|
||||
})
|
||||
|
||||
app.get('/api/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
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 entries = fs.readdirSync(dirPath, { withFileTypes: true })
|
||||
const dirs = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => e.name)
|
||||
.filter(n => !n.startsWith('.'))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
const parent = dirPath === '/' ? null : path.dirname(dirPath)
|
||||
res.json({ path: dirPath, parent, dirs })
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err instanceof Error ? err.message : 'Erreur lecture dossier' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/torrents/status', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
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' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) — doit être après toutes les routes API ─
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
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) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── SPA fallback (prod) ────────────────────────────────────────
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
@@ -286,27 +719,49 @@ if (fs.existsSync(PUBLIC_PATH)) {
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
app.listen(PORT, async () => {
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
console.log(`[fankarr] Cache GitHub TTL: 6h — premier fetch au démarrage...`)
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
// Pré-chauffer le cache available.json au démarrage
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
console.log(`[fankarr] ${available.length} séries disponibles (via GitHub)`)
|
||||
} catch (err) {
|
||||
console.warn(`[fankarr] ⚠ Impossible de charger available.json: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
// Scan initial du mediaPath
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
loadEnrichedSeriesData().then(seriesData =>
|
||||
scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData)
|
||||
).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
)
|
||||
|
||||
// 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()
|
||||
await autoOrganizeAll(() => dispatchList(category ?? 'fankai'))
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
await autoOrganizeAll(
|
||||
() => dispatchList(category ?? 'fankai'),
|
||||
seriesData,
|
||||
(result) => {
|
||||
if (result.done > 0 || result.errors > 0) {
|
||||
pushNotif({ ...result, at: new Date().toISOString() })
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[organize] Erreur auto-organise:', err)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
// Premier run 10s après démarrage, puis toutes les 5 minutes
|
||||
setTimeout(() => {
|
||||
autoOrganize()
|
||||
setInterval(autoOrganize, 30_000)
|
||||
}, 30_000)
|
||||
setInterval(autoOrganize, 5 * 60_000)
|
||||
}, 10_000)
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* logger.ts
|
||||
* =========
|
||||
* Logger centralisé — écrit dans data/logs.jsonl (une ligne JSON par event)
|
||||
* Niveaux : debug | info | warn | error
|
||||
* En prod (NODE_ENV=production) les lignes debug ne sont pas écrites sur disque.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
export interface LogEntry {
|
||||
at : string // ISO timestamp
|
||||
level : LogLevel
|
||||
source : string // 'organize' | 'api' | 'scraper' | ...
|
||||
msg : string
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
// ── Écriture ──────────────────────────────────────────────────
|
||||
|
||||
function writeLine(entry: LogEntry) {
|
||||
try {
|
||||
fs.appendFileSync(LOGS_PATH, JSON.stringify(entry) + '\n', 'utf-8')
|
||||
// Rotation si trop grand
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
const lines = content.split('\n').filter(Boolean)
|
||||
if (lines.length > MAX_LINES) {
|
||||
fs.writeFileSync(LOGS_PATH, lines.slice(-MAX_LINES).join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── API publique ──────────────────────────────────────────────
|
||||
|
||||
export function log(level: LogLevel, source: string, msg: string, meta?: Record<string, any>) {
|
||||
const entry: LogEntry = { at: new Date().toISOString(), level, source, msg, meta }
|
||||
|
||||
// Console
|
||||
const prefix = `[${source}]`
|
||||
if (level === 'error') console.error(prefix, msg, meta ?? '')
|
||||
else if (level === 'warn') console.warn(prefix, msg, meta ?? '')
|
||||
else if (level === 'debug' && !IS_PROD) console.log(prefix, msg, meta ?? '')
|
||||
else if (level === 'info') console.log(prefix, msg, meta ?? '')
|
||||
|
||||
// Disque — pas les debug en prod
|
||||
if (level === 'debug' && IS_PROD) return
|
||||
writeLine(entry)
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug: (source: string, msg: string, meta?: Record<string, any>) => log('debug', source, msg, meta),
|
||||
info : (source: string, msg: string, meta?: Record<string, any>) => log('info', source, msg, meta),
|
||||
warn : (source: string, msg: string, meta?: Record<string, any>) => log('warn', source, msg, meta),
|
||||
error: (source: string, msg: string, meta?: Record<string, any>) => log('error', source, msg, meta),
|
||||
}
|
||||
|
||||
// ── Lecture pour l'API ────────────────────────────────────────
|
||||
|
||||
export interface LogsReadOptions {
|
||||
limit? : number // nb de lignes (défaut 100)
|
||||
level? : LogLevel | 'all' // filtre niveau
|
||||
source? : string // filtre source
|
||||
}
|
||||
|
||||
export function readLogs(opts: LogsReadOptions = {}): LogEntry[] {
|
||||
try {
|
||||
if (!fs.existsSync(LOGS_PATH)) return []
|
||||
const content = fs.readFileSync(LOGS_PATH, 'utf-8')
|
||||
let lines = content.split('\n').filter(Boolean)
|
||||
|
||||
// Parse
|
||||
let entries: LogEntry[] = []
|
||||
for (const line of lines) {
|
||||
try { entries.push(JSON.parse(line)) } catch {}
|
||||
}
|
||||
|
||||
// Filtres
|
||||
if (opts.level && opts.level !== 'all')
|
||||
entries = entries.filter(e => e.level === opts.level)
|
||||
if (opts.source)
|
||||
entries = entries.filter(e => e.source === opts.source)
|
||||
|
||||
// Plus récents en premier
|
||||
entries.reverse()
|
||||
|
||||
// Limite
|
||||
const limit = opts.limit ?? 100
|
||||
return entries.slice(0, limit)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export function clearLogs(): void {
|
||||
try { fs.writeFileSync(LOGS_PATH, '', 'utf-8') } catch {}
|
||||
}
|
||||
|
||||
export function logsFileSize(): number {
|
||||
try { return fs.statSync(LOGS_PATH).size } catch { return 0 }
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* organize-worker.ts
|
||||
* ==================
|
||||
* 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 : { 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' }
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
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 ─────────────────────────────────────────────────
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
try {
|
||||
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 {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, filename: string) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][filename] = new Date().toISOString()
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, filename: string): boolean {
|
||||
return !!loadOrganized()[hash]?.[filename]
|
||||
}
|
||||
|
||||
// ─── Lookup torrent dans les seriesData passées via message ───
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
for (const sd of seriesData) {
|
||||
// Niveau série
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
// Niveau saison
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const t of season.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
// Niveau épisode
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of ep.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Construit la map filename → { season_number, original_filename, fullPath }
|
||||
// en matchant par infohash dans ep.paths[]
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
seasonFilter?: number
|
||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const match = (ep.paths ?? []).find((p: any) => p.infohash?.toLowerCase() === h)
|
||||
if (!match) continue
|
||||
const filename = match.path.split('/').pop() ?? match.path
|
||||
const original = ep.original_filename ?? filename
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
original_filename: original,
|
||||
fullPath : match.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus ─────────────────────────────────────────
|
||||
const EXCLUDED_FOLDERS = new Set([
|
||||
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||
'images', 'image', 'scans', 'scan', 'soundtrack', 'music',
|
||||
])
|
||||
|
||||
function isInExcludedFolder(filePath: string[]): boolean {
|
||||
for (const folder of filePath.slice(0, -1)) {
|
||||
const f = folder.toLowerCase().trim()
|
||||
if (EXCLUDED_FOLDERS.has(f)) return true
|
||||
for (const excl of EXCLUDED_FOLDERS) {
|
||||
if (f.startsWith(excl)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── 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'
|
||||
|
||||
async function fetchJson(url: string): Promise<any> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchBinary(url: string): Promise<Buffer> {
|
||||
const { default: nodeFetch } = await import('node-fetch')
|
||||
const res = await (nodeFetch as any)(url, { headers: { 'User-Agent': 'fankarr' } })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} — ${url}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const folderPath = `pack/${serieTitle}`
|
||||
log(`[nfo] Récupération dossier GitLab: ${serieTitle}`)
|
||||
|
||||
let files: any[] = []
|
||||
try {
|
||||
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 "${serieTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
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) {
|
||||
const relativePath = entry.path.replace(`pack/${serieTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
if (fs.existsSync(destPath)) continue
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(serieTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
|
||||
const data = await fetchBinary(rawUrl)
|
||||
fs.writeFileSync(destPath, data)
|
||||
log(`[nfo] ✓ ${relativePath}`)
|
||||
} catch (err) {
|
||||
warn(`[nfo] ✗ ${relativePath}: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
log(`[nfo] GitLab synchronisé pour "${serieTitle}"`)
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
function seasonFolder(n: number): string { return `Saison ${n}` }
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try { fs.linkSync(src, dest); return true }
|
||||
catch (err) {
|
||||
warn(`[organize] hardlink échoué (${err instanceof Error ? err.message : err})`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
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 found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`[organize] ✗ Hash ${hash} introuvable dans les données série`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const serieTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const torrentTitle = torrent.title ?? name
|
||||
log(`[organize] ✓ Trouvé : ${torrentTitle} — série: ${serieTitle}`)
|
||||
|
||||
// ── NFO/images depuis GitLab ──────────────────────────────
|
||||
if (nfoSupport) {
|
||||
await downloadGitlabFolder(serieTitle, path.join(mediaPath, serieTitle))
|
||||
}
|
||||
|
||||
// ── Cas torrent épisode unique ────────────────────────────
|
||||
if (torrent._episode) {
|
||||
const ep = torrent._episode
|
||||
const season = torrent._season
|
||||
const match = (ep.paths ?? []).find((p: any) => p.infohash?.toLowerCase() === hash.toLowerCase())
|
||||
const filePath = match?.path ?? null
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const original = ep.original_filename ?? filename
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, original)
|
||||
|
||||
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 })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.unlink(src)
|
||||
}
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
log(`[organize] ✓ ${original} → Saison ${season.season_number}`)
|
||||
return { ...result, total: 1, done: 1 }
|
||||
}
|
||||
|
||||
// ── Pack saison / intégrale ───────────────────────────────
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, hash, seasonFilter)
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
warn(`[organize] Aucun fichier mappé pour ${name}`)
|
||||
return result
|
||||
}
|
||||
|
||||
result.total = 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, fullPath),
|
||||
path.join(completePath, fullPath),
|
||||
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`)
|
||||
result.errors.push({ file: filename, error: 'Source introuvable' })
|
||||
continue
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
try {
|
||||
fs.mkdirSync(destDir, { recursive: true })
|
||||
if (organizeMode === 'hardlink') {
|
||||
if (!tryHardlink(src, dest)) await fsp.copyFile(src, dest)
|
||||
} else {
|
||||
await fsp.copyFile(src, dest)
|
||||
await fsp.unlink(src)
|
||||
}
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
log(`[organize] ✓ ${original_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`[organize] ✗ ${filename}: ${msg}`)
|
||||
result.errors.push({ file: filename, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
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 seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
if (!found) {
|
||||
warn(`[organize] hash ${t.hash} (${t.name}) non trouvé dans les données série`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si déjà tout organisé
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, seasonFilter)
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
|
||||
if (allDone) continue
|
||||
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path, 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}`)
|
||||
}
|
||||
}
|
||||
|
||||
parentPort?.postMessage({ type: 'done' })
|
||||
})
|
||||
+193
-213
@@ -1,84 +1,30 @@
|
||||
/**
|
||||
* Organizer
|
||||
* =========
|
||||
* Déplace ou hardlinke les fichiers téléchargés vers la médiathèque Jellyfin.
|
||||
* Incrémental : skip les fichiers déjà organisés.
|
||||
*
|
||||
* Structure dest :
|
||||
* mediaPath / serie_title / Saison 001 / filename.mkv
|
||||
* organize.ts
|
||||
* ===========
|
||||
* Lance l'organizer dans un Worker Thread dédié pour ne pas bloquer Express.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { readSettings } from './settings.js'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
|
||||
// ─── Organized log ────────────────────────────────────────────
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
|
||||
function loadOrganized(): Record<string, Record<string, string>> {
|
||||
try {
|
||||
if (!fs.existsSync(ORGANIZED_PATH)) return {}
|
||||
return JSON.parse(fs.readFileSync(ORGANIZED_PATH, 'utf-8'))
|
||||
} catch { return {} }
|
||||
}
|
||||
// État précédent des torrents pour détecter les passages downloading → seeding
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
function saveOrganized(data: Record<string, Record<string, string>>) {
|
||||
fs.writeFileSync(ORGANIZED_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
function markOrganized(hash: string, filename: string) {
|
||||
const data = loadOrganized()
|
||||
if (!data[hash]) data[hash] = {}
|
||||
data[hash][filename] = new Date().toISOString()
|
||||
saveOrganized(data)
|
||||
}
|
||||
|
||||
function isOrganized(hash: string, filename: string): boolean {
|
||||
const data = loadOrganized()
|
||||
return !!data[hash]?.[filename]
|
||||
}
|
||||
|
||||
// ─── Torrent lookup ───────────────────────────────────────────
|
||||
|
||||
function readTorrents(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
function findTorrentByHash(hash: string): any | null {
|
||||
const torrents = readTorrents()
|
||||
return torrents.find(t => t.infohash?.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
// ─── Filesystem ops ───────────────────────────────────────────
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
function seasonFolder(n: number): string {
|
||||
return `Saison ${String(n).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
function tryHardlink(src: string, dest: string): boolean {
|
||||
try {
|
||||
fs.linkSync(src, dest)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
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')
|
||||
}
|
||||
|
||||
function copyFile(src: string, dest: string) {
|
||||
fs.copyFileSync(src, dest)
|
||||
}
|
||||
|
||||
// ─── Résultat d'organisation ──────────────────────────────────
|
||||
|
||||
export interface OrganizeResult {
|
||||
total : number
|
||||
skipped : number
|
||||
@@ -86,163 +32,197 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
/**
|
||||
* Scan initial du mediaPath au démarrage.
|
||||
* Construit l'index filename → infohash depuis les seriesData passées en paramètre.
|
||||
*/
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
seriesData : any[] = []
|
||||
): Promise<{ found: number; added: number }> {
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string, // nom du dossier torrent dans completePath
|
||||
savePath: string // save_path retourné par qBittorrent
|
||||
): Promise<OrganizeResult> {
|
||||
const settings = readSettings()
|
||||
const { mediaPath, completePath, organizeMode } = settings
|
||||
|
||||
const result: OrganizeResult = { total: 0, skipped: 0, done: 0, errors: [] }
|
||||
|
||||
// Trouver le torrent dans torrent_final.json via infohash
|
||||
const torrent = findTorrentByHash(hash)
|
||||
if (!torrent) {
|
||||
throw new Error(`Torrent introuvable dans torrent_final.json pour le hash : "${hash}"`)
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
logger.warn('organize', `scanMediaPath: ${mediaPath} inexistant, skip`)
|
||||
return result
|
||||
}
|
||||
|
||||
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||
|
||||
if (torrentFiles.length === 0) {
|
||||
throw new Error(`Aucun fichier trouvé dans le torrent "${name}"`)
|
||||
}
|
||||
|
||||
// Résoudre season_number par episode_id
|
||||
const epSeasonMap = new Map<number, number>()
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.filename) {
|
||||
epSeasonMap.set(ep.episode_number, ep.season_number)
|
||||
}
|
||||
}
|
||||
// Map filename → season_number
|
||||
const filenameSeasonMap = new Map<string, number>()
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.filename && ep.season_number !== undefined) {
|
||||
filenameSeasonMap.set(ep.filename, ep.season_number)
|
||||
}
|
||||
}
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const filename = tf.filename
|
||||
const filePath: string[] = tf.path // ex: ["Saga 1 - ...", "file.mkv"]
|
||||
|
||||
// Skip si déjà organisé
|
||||
if (isOrganized(hash, filename)) {
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Construire le chemin source
|
||||
// save_path peut déjà contenir le nom du dossier ou pas selon le client
|
||||
const torrentDir = path.join(savePath, name)
|
||||
const torrentDirAlt = savePath // au cas où save_path inclut déjà le nom
|
||||
|
||||
// Chemin source = dossier torrent + sous-dossiers + fichier
|
||||
const subPath = filePath.length > 1
|
||||
? path.join(...filePath.slice(0, -1), filename)
|
||||
: filename
|
||||
|
||||
let src = path.join(torrentDir, subPath)
|
||||
if (!fs.existsSync(src)) {
|
||||
// Fallback: save_path sans le nom du torrent
|
||||
src = path.join(torrentDirAlt, subPath)
|
||||
}
|
||||
if (!fs.existsSync(src)) {
|
||||
// Fallback: fichier directement dans save_path/name
|
||||
src = path.join(torrentDir, filename)
|
||||
}
|
||||
if (!fs.existsSync(src)) {
|
||||
result.errors.push({ file: filename, error: `Source introuvable : ${src}` })
|
||||
continue
|
||||
}
|
||||
|
||||
// Déterminer la saison
|
||||
let seasonNum = filenameSeasonMap.get(filename)
|
||||
if (seasonNum === undefined) {
|
||||
// Fallback : season_number = 1
|
||||
seasonNum = 1
|
||||
}
|
||||
|
||||
// Construire le chemin destination
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(seasonNum))
|
||||
const dest = path.join(destDir, filename)
|
||||
|
||||
// Skip si dest existe déjà
|
||||
if (fs.existsSync(dest)) {
|
||||
markOrganized(hash, filename)
|
||||
result.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
ensureDir(destDir)
|
||||
|
||||
if (organizeMode === 'hardlink') {
|
||||
const ok = tryHardlink(src, dest)
|
||||
if (!ok) {
|
||||
// Hardlink impossible (filesystems différents) → copy
|
||||
console.warn(`[organize] hardlink échoué pour ${filename}, fallback copy`)
|
||||
copyFile(src, dest)
|
||||
// Construire index filename → infohash depuis les seriesData
|
||||
const filenameIndex = new Map<string, string>() // filename → infohash
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
const filename = p.path.split('/').pop()
|
||||
if (filename) filenameIndex.set(filename, hash)
|
||||
}
|
||||
} else {
|
||||
// move = copy + delete source
|
||||
copyFile(src, dest)
|
||||
fs.unlinkSync(src)
|
||||
}
|
||||
|
||||
markOrganized(hash, filename)
|
||||
result.done++
|
||||
console.log(`[organize] ${organizeMode} ${filename} → ${dest}`)
|
||||
} catch (err) {
|
||||
result.errors.push({
|
||||
file : filename,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Charger organized.json existant
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
if (fs.existsSync(organizedPath))
|
||||
organized = JSON.parse(fs.readFileSync(organizedPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const presentFiles = new Set<string>()
|
||||
|
||||
function walk(dir: string) {
|
||||
let entries: fs.Dirent[]
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
|
||||
catch { return }
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
} else if (entry.isFile() && entry.name.endsWith('.mkv')) {
|
||||
result.found++
|
||||
const hash = filenameIndex.get(entry.name)
|
||||
if (!hash) continue
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(mediaPath)
|
||||
|
||||
// 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, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
} else {
|
||||
logger.info('organize', `scanMediaPath: ${result.found} fichiers scannés, rien de nouveau`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Auto-organise polling ────────────────────────────────────
|
||||
// Appelé depuis index.ts au démarrage, toutes les 30s
|
||||
// Détecte les torrents "seeding" non encore organisés
|
||||
|
||||
/**
|
||||
* Auto-organisation — appelée depuis index.ts avec les seriesData du cache GitHub.
|
||||
*/
|
||||
export async function autoOrganizeAll(
|
||||
listFn: () => Promise<any[]>
|
||||
listFn : () => Promise<any[]>,
|
||||
seriesData : any[],
|
||||
onResult ?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
if (workerRunning) {
|
||||
console.log('[organize] Worker déjà en cours, skip')
|
||||
return
|
||||
}
|
||||
|
||||
let torrents: any[]
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch { return }
|
||||
|
||||
const organized = loadOrganized()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
// Vérifier si tous les fichiers sont déjà organisés
|
||||
const torrent = findTorrentByHash(t.hash)
|
||||
if (!torrent) continue
|
||||
|
||||
const files: any[] = torrent.torrent_files ?? []
|
||||
if (files.length === 0) continue
|
||||
|
||||
const allDone = files.every(f => organized[t.hash]?.[f.filename])
|
||||
if (allDone) continue
|
||||
|
||||
console.log(`[organize] Auto-organisation : ${t.name}`)
|
||||
try {
|
||||
const result = await organizeTorrent(t.hash, t.name, t.save_path)
|
||||
console.log(`[organize] ${t.name} → ${result.done} fichiers, ${result.skipped} skippés, ${result.errors.length} erreurs`)
|
||||
} catch (err) {
|
||||
console.error(`[organize] Erreur pour ${t.name}:`, err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
return
|
||||
}
|
||||
|
||||
if (torrents.length === 0) return
|
||||
|
||||
const isFirstRun = _prevStates.size === 0
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
|
||||
const newlySeeding = torrents.filter(t => {
|
||||
const prev = _prevStates.get(t.hash)
|
||||
return t.state === 'seeding' && (prev === undefined || prev !== 'seeding')
|
||||
})
|
||||
|
||||
for (const t of torrents) _prevStates.set(t.hash, t.state)
|
||||
|
||||
if (!isFirstRun && newlySeeding.length === 0) return
|
||||
if (seedingCount === 0) return
|
||||
|
||||
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 worker = new Worker(resolveWorkerPath())
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
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') {
|
||||
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,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
console.log('[organize] Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
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.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation manuelle d'un torrent.
|
||||
*/
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
): Promise<OrganizeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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') {
|
||||
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
|
||||
result.done = msg.done
|
||||
result.errors = msg.errors
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
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], seriesData })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* secret.ts
|
||||
* =========
|
||||
* Génère et persiste le JWT secret dans data/secret.key
|
||||
* Si JWT_SECRET est défini en variable d'environnement, il est utilisé tel quel.
|
||||
* Sinon, on lit data/secret.key — s'il n'existe pas, on en génère un et on le sauvegarde.
|
||||
*/
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const SECRET_PATH = path.join(DATA_DIR, 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
}
|
||||
|
||||
function loadOrCreateSecret(): string {
|
||||
// 1. Variable d'environnement — priorité absolue
|
||||
if (process.env.JWT_SECRET && process.env.JWT_SECRET !== 'fankarr-secret-change-me') {
|
||||
return process.env.JWT_SECRET
|
||||
}
|
||||
|
||||
// 2. Fichier existant
|
||||
if (fs.existsSync(SECRET_PATH)) {
|
||||
const secret = fs.readFileSync(SECRET_PATH, 'utf-8').trim()
|
||||
if (secret.length > 0) return secret
|
||||
}
|
||||
|
||||
// 3. Génération + sauvegarde
|
||||
const secret = generateSecret()
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(SECRET_PATH), { recursive: true })
|
||||
fs.writeFileSync(SECRET_PATH, secret, { encoding: 'utf-8', mode: 0o600 })
|
||||
console.log('[auth] JWT secret généré et sauvegardé dans data/secret.key')
|
||||
} catch (err) {
|
||||
console.error('[auth] Impossible de sauvegarder le secret:', err)
|
||||
}
|
||||
|
||||
return secret
|
||||
}
|
||||
|
||||
export const JWT_SECRET: string = loadOrCreateSecret()
|
||||
+9
-6
@@ -1,20 +1,23 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from './config.js'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
|
||||
const DATA_PATH = path.join(DATA_DIR, 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string // dossier Jellyfin destination ex: /media/Kai
|
||||
completePath : string // dossier qB complétion ex: /downloads/complete
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string // catégorie qBittorrent
|
||||
category : string
|
||||
nfoSupport : boolean // téléchargement NFO/images depuis GitLab Fankai Pack
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
mediaPath : '/media/Kai',
|
||||
completePath : '/downloads/complete',
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -168,7 +169,9 @@ export async function dispatchList(category?: string): Promise<(TorrentInfo & {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) continue
|
||||
try {
|
||||
const torrents = await driver.list(client.config, category)
|
||||
// La catégorie du client est prioritaire sur la catégorie globale
|
||||
const clientCategory = (client.config.category as string) || category
|
||||
const torrents = await driver.list(client.config, clientCategory)
|
||||
for (const t of torrents) {
|
||||
results.push({ ...t, client_uuid: client.uuid, client_name: client.name })
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive include="CatalogView">
|
||||
<component :is="Component" :key="route.path" />
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<!-- Overlay -->
|
||||
<div class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-lg flex flex-col" style="max-height: 70vh">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-[#1e2d3d] shrink-0">
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-0.5">CHOISIR UN DOSSIER</div>
|
||||
<div class="text-xs text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</div>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-[#1e2d3d] shrink-0 flex gap-2">
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="flex-1 bg-[#121920] border border-[#1e2d3d] text-xs text-white px-3 py-2 font-mono outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-3 py-2 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation retour -->
|
||||
<div v-if="parent !== null" class="px-5 shrink-0">
|
||||
<button @click="navigateTo(parent)"
|
||||
class="flex items-center gap-2 w-full py-2.5 text-xs text-[#5a7a94] hover:text-white transition-colors cursor-pointer border-b border-[#1e2d3d]">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
..
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste dossiers -->
|
||||
<div class="overflow-y-auto flex-1">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10 text-[#5a7a94] text-xs">
|
||||
<div class="w-4 h-4 border border-white/10 border-t-[#e8513a] rounded-full animate-spin mr-2"/>
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="error" class="px-5 py-4 text-xs text-red-400">{{ error }}</div>
|
||||
<div v-else-if="dirs.length === 0" class="px-5 py-4 text-xs text-[#5a7a94]">Aucun sous-dossier</div>
|
||||
<button
|
||||
v-for="dir in dirs"
|
||||
:key="dir"
|
||||
@click="navigateTo(joinPath(current, dir))"
|
||||
class="flex items-center gap-3 w-full px-5 py-2.5 text-xs text-[#c8d8e8] hover:bg-white/5 transition-colors cursor-pointer border-b border-[#1e2d3d]/50 text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||
</svg>
|
||||
{{ dir }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-t border-[#1e2d3d] shrink-0">
|
||||
<span class="text-[10px] text-[#5a7a94] font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<button @click="$emit('select', current)"
|
||||
class="bg-[#e8513a] text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
|
||||
CHOISIR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ initialPath?: string }>()
|
||||
const emit = defineEmits<{ select: [path: string]; cancel: [] }>()
|
||||
|
||||
const current = ref(props.initialPath || '/')
|
||||
const inputPath = ref(current.value)
|
||||
const dirs = ref<string[]>([])
|
||||
const parent = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
return base === '/' ? `/${name}` : `${base}/${name}`
|
||||
}
|
||||
|
||||
async function navigateTo(p: string) {
|
||||
if (!p) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await fetch(`/api/browse?path=${encodeURIComponent(p)}`, { credentials: 'include' })
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.error ?? 'Erreur'
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
current.value = data.path
|
||||
inputPath.value = data.path
|
||||
parent.value = data.parent
|
||||
dirs.value = data.dirs
|
||||
} catch {
|
||||
error.value = 'Impossible de lire ce dossier'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.initialPath, v => {
|
||||
if (v) navigateTo(v)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
@@ -25,6 +25,10 @@ const router = createRouter({
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
component: () => import('@/views/LogsView.vue')
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
|
||||
@@ -61,6 +61,8 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
const loadingDetail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const _imageCache = new Set<string>()
|
||||
|
||||
async function fetchSeries() {
|
||||
loadingSeries.value = true
|
||||
error.value = null
|
||||
@@ -69,6 +71,13 @@ export const useSeriesStore = defineStore('series', () => {
|
||||
if (!res.ok) throw new Error(`Erreur ${res.status}`)
|
||||
const data = await res.json()
|
||||
series.value = data.series
|
||||
// Précache des posters en arrière-plan
|
||||
for (const s of data.series as Serie[]) {
|
||||
if (s.poster_image && !_imageCache.has(s.poster_image)) {
|
||||
_imageCache.add(s.poster_image)
|
||||
new Image().src = s.poster_image
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
} finally {
|
||||
|
||||
+133
-23
@@ -4,18 +4,35 @@
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-50 bg-zinc-950/90 backdrop-blur border-b border-white/5">
|
||||
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<span class="text-2xl font-serif tracking-tight">
|
||||
<router-link to="/" class="text-2xl font-serif tracking-tight hover:opacity-80 transition">
|
||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<div class="flex items-center gap-2">
|
||||
<router-link to="/downloads"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition relative">
|
||||
<!-- Icône logs -->
|
||||
<router-link to="/logs"
|
||||
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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>
|
||||
<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"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<!-- Badge si téléchargements actifs -->
|
||||
</router-link>
|
||||
<router-link to="/downloads"
|
||||
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-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/>
|
||||
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"
|
||||
@@ -50,9 +67,10 @@
|
||||
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 gap-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Groupe 1 : état de la série -->
|
||||
<button
|
||||
v-for="f in filters"
|
||||
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
|
||||
@@ -60,6 +78,20 @@
|
||||
: '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 -->
|
||||
<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>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
@@ -115,9 +147,28 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Badge torrents dispo -->
|
||||
<div v-if="serie.has_torrents" class="absolute top-2 right-2 bg-orange-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none">
|
||||
{{ serie.torrent_count }}
|
||||
|
||||
|
||||
|
||||
<!-- Badge état téléchargement -->
|
||||
<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>
|
||||
<svg v-else-if="serie.download_state === 'downloading'" width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" class="animate-pulse">
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
<svg v-else width="9" height="9" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<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' }}
|
||||
</div>
|
||||
|
||||
<!-- Overlay hover -->
|
||||
@@ -148,30 +199,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineOptions({ name: 'CatalogView' })
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const activeDownloads = ref(0)
|
||||
|
||||
const filters = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
const filtersGroupe1 = [
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
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 === '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))
|
||||
@@ -184,7 +243,58 @@ async function handleLogout() {
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.series.length === 0) store.fetchSeries()
|
||||
// ── Polling downloads actifs + notifications organisation ──────
|
||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||
const seenNotifs = new Set<string>()
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const torrents = await res.json()
|
||||
activeDownloads.value = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchOrganizeNotifs() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const notifs: any[] = await res.json()
|
||||
for (const n of notifs) {
|
||||
const key = `${n.hash}-${n.at}`
|
||||
if (seenNotifs.has(key)) continue
|
||||
seenNotifs.add(key)
|
||||
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) ✓`
|
||||
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Cache images ───────────────────────────────────────────────
|
||||
// Géré directement dans le store series.ts
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
fetchActiveDownloads()
|
||||
fetchOrganizeNotifs()
|
||||
dlInterval = setInterval(() => {
|
||||
fetchActiveDownloads()
|
||||
fetchOrganizeNotifs()
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
// Refresh au retour sur le catalogue (après update ou scan)
|
||||
onActivated(async () => {
|
||||
await store.fetchSeries()
|
||||
fetchActiveDownloads()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dlInterval) clearInterval(dlInterval)
|
||||
})
|
||||
</script>
|
||||
+109
-34
@@ -10,7 +10,6 @@
|
||||
<h1 class="text-xl font-black text-white font-sans">Téléchargements</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Indicateur de polling -->
|
||||
<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' }}
|
||||
@@ -30,6 +29,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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'">
|
||||
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">
|
||||
{{ 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'">
|
||||
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">
|
||||
{{ 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"
|
||||
class="relative shrink-0 w-8 h-4 rounded-full transition-colors cursor-pointer"
|
||||
:class="hideOrganized ? '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'" />
|
||||
</button>
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">MASQUER ORGANISÉ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>
|
||||
@@ -46,14 +86,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && torrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun téléchargement en cours</p>
|
||||
<p class="text-[11px]">Les torrents lancés depuis Fankarr apparaîtront ici</p>
|
||||
<div v-else-if="!loading && visibleTorrents.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">
|
||||
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
|
||||
</p>
|
||||
<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' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in torrents" :key="t.hash"
|
||||
<div v-for="t in visibleTorrents" :key="t.hash"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
|
||||
<!-- Ligne principale -->
|
||||
@@ -61,17 +107,14 @@
|
||||
<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">
|
||||
<!-- Badge état -->
|
||||
<span class="text-[10px] tracking-widest px-2 py-0.5 border"
|
||||
:class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<!-- Client -->
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Infos droite -->
|
||||
<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">
|
||||
@@ -92,27 +135,44 @@
|
||||
<!-- 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>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">
|
||||
ETA {{ formatEta(t.eta) }}
|
||||
</span>
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">
|
||||
✓ Complété
|
||||
</span>
|
||||
<span v-if="t.state === 'downloading'">↓ {{ formatSpeed(t.speed) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0">ETA {{ formatEta(t.eta) }}</span>
|
||||
<span v-if="t.state === 'seeding'" class="text-green-500">✓ Complété</span>
|
||||
</div>
|
||||
|
||||
<!-- Bouton organiser si complété -->
|
||||
<button v-if="t.state === 'seeding'"
|
||||
@click="organize(t)"
|
||||
:disabled="organizing[t.hash]"
|
||||
class="text-[10px] tracking-widest px-3 py-1 border transition-colors cursor-pointer"
|
||||
:class="organized[t.hash]
|
||||
? 'border-green-500/40 text-green-500'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#e8513a] hover:text-[#e8513a]'">
|
||||
{{ organized[t.hash] ? 'ORGANISÉ ✓' : organizing[t.hash] ? '...' : 'ORGANISER' }}
|
||||
</button>
|
||||
<!-- 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">
|
||||
{{ t.errorFiles.length }} ERREUR{{ t.errorFiles.length > 1 ? 'S' : '' }}
|
||||
</span>
|
||||
<div class="absolute bottom-full right-0 mb-2 hidden group-hover/err:block z-10 w-80">
|
||||
<div class="bg-[#0d1219] border border-red-500/30 p-3 text-[10px] shadow-xl">
|
||||
<div class="text-red-400 tracking-widest mb-2">FICHIERS EN ERREUR</div>
|
||||
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-white truncate">{{ e.file }}</p>
|
||||
<p class="text-[#5a7a94]">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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})` :
|
||||
t.organizeState === 'partial' ? `EN COURS (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'NON ORGANISÉ' }}
|
||||
</span>
|
||||
|
||||
<button v-if="t.organizeState !== 'done'"
|
||||
@click="organize(t)"
|
||||
:disabled="organizing[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' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,15 +185,31 @@ 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 organized = ref<Record<string, boolean>>({})
|
||||
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)
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Filtres onglets ────────────────────────────────────────────
|
||||
const activeTorrents = computed(() =>
|
||||
torrents.value.filter(t => ['downloading', 'paused', 'checking', 'error', 'unknown'].includes(t.state))
|
||||
)
|
||||
|
||||
const doneTorrents = computed(() =>
|
||||
torrents.value.filter(t => t.state === 'seeding')
|
||||
)
|
||||
|
||||
const visibleTorrents = computed(() => {
|
||||
if (activeTab.value === 'active') return activeTorrents.value
|
||||
if (hideOrganized.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 },
|
||||
@@ -186,7 +262,6 @@ async function organize(torrent: any) {
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||
})
|
||||
if (res.ok) {
|
||||
organized.value[torrent.hash] = true
|
||||
toast(`${torrent.name} organisé ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-8 py-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<button @click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Logs</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ formatSize(fileSize) }}</span>
|
||||
<button @click="confirmClear"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-red-500 hover:text-red-500 transition-colors cursor-pointer">
|
||||
EFFACER
|
||||
</button>
|
||||
<button @click="load"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
↺ RAFRAÎCHIR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap items-center gap-3 mb-5">
|
||||
|
||||
<!-- Niveau -->
|
||||
<div class="flex gap-1">
|
||||
<button v-for="l in levels" :key="l.value" @click="filterLevel = l.value; load()"
|
||||
class="px-3 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
|
||||
:class="filterLevel === l.value
|
||||
? 'border-[#e8513a] text-[#e8513a]'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
{{ l.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<select v-model="filterSource" @change="load()"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] text-[#5a7a94] px-3 py-1 text-[10px] tracking-widest outline-none cursor-pointer">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option value="organize">organize</option>
|
||||
<option value="api">api</option>
|
||||
<option value="auth">auth</option>
|
||||
</select>
|
||||
|
||||
<!-- Limite / tout afficher -->
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">AFFICHER</span>
|
||||
<button v-for="n in [100, 500, 2000]" :key="n" @click="limit = n; load()"
|
||||
class="px-2 py-1 text-[10px] tracking-widest border transition-colors cursor-pointer"
|
||||
:class="limit === n
|
||||
? 'border-[#e8513a] text-[#e8513a]'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
{{ n === 2000 ? 'TOUT' : n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-3 min-h-48 text-[#5a7a94]">
|
||||
<div class="w-4 h-4 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
|
||||
<span class="text-sm">Chargement...</span>
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center min-h-48 text-[#5a7a94] text-sm">
|
||||
Aucun log
|
||||
</div>
|
||||
|
||||
<!-- Table logs -->
|
||||
<div v-else class="bg-[#0d1219] border border-[#1e2d3d] overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr class="border-b border-[#1e2d3d]">
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-4 py-2.5 w-40">HORODATAGE</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-16">NIVEAU</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5 w-24">SOURCE</th>
|
||||
<th class="text-left text-[10px] text-[#5a7a94] tracking-widest px-3 py-2.5">MESSAGE</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, i) in entries" :key="i"
|
||||
class="border-b border-[#1e2d3d]/50 hover:bg-white/[0.02] transition-colors"
|
||||
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''">
|
||||
<td class="px-4 py-2 text-[#5a7a94] whitespace-nowrap">{{ formatDate(entry.at) }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="text-[10px] tracking-widest px-1.5 py-0.5 rounded"
|
||||
:class="levelClass(entry.level)">
|
||||
{{ entry.level.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-[#5a7a94] whitespace-nowrap">{{ entry.source }}</td>
|
||||
<td class="px-3 py-2 text-[#c8d8e8] break-all">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-[#5a7a94] ml-2">
|
||||
{{ JSON.stringify(entry.meta) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer count -->
|
||||
<div class="px-4 py-2.5 border-t border-[#1e2d3d] flex items-center justify-between">
|
||||
<span class="text-[10px] text-[#5a7a94] tracking-widest">{{ entries.length }} entrées affichées</span>
|
||||
<span class="text-[10px] text-[#5a7a94]">{{ formatSize(fileSize) }} sur disque</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm clear -->
|
||||
<div v-if="showConfirm" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 max-w-sm w-full">
|
||||
<h3 class="text-sm font-bold text-white mb-2">Effacer les logs ?</h3>
|
||||
<p class="text-[11px] text-[#5a7a94] mb-6">Cette action est irréversible.</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="doClear"
|
||||
class="bg-red-500 text-white px-5 py-2 text-xs tracking-widest hover:opacity-85 transition-opacity cursor-pointer">
|
||||
EFFACER
|
||||
</button>
|
||||
<button @click="showConfirm = false"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-5 py-2 text-xs tracking-widest hover:border-white/30 transition-colors cursor-pointer">
|
||||
ANNULER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const entries = ref<any[]>([])
|
||||
const fileSize = ref(0)
|
||||
const loading = ref(true)
|
||||
const showConfirm = ref(false)
|
||||
const filterLevel = ref('all')
|
||||
const filterSource = ref('')
|
||||
const limit = ref(100)
|
||||
|
||||
const levels = [
|
||||
{ label: 'TOUS', value: 'all' },
|
||||
{ label: 'INFO', value: 'info' },
|
||||
{ label: 'WARN', value: 'warn' },
|
||||
{ label: 'ERROR', value: 'error' },
|
||||
{ label: 'DEBUG', value: 'debug' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit : String(limit.value),
|
||||
level : filterLevel.value,
|
||||
...(filterSource.value ? { source: filterSource.value } : {})
|
||||
})
|
||||
const res = await fetch(`/api/logs?${params}`, { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
entries.value = data.entries
|
||||
fileSize.value = data.size
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClear() { showConfirm.value = true }
|
||||
|
||||
async function doClear() {
|
||||
showConfirm.value = false
|
||||
await fetch('/api/logs/clear', { method: 'POST', credentials: 'include' })
|
||||
toast('Logs effacés', 'success')
|
||||
await load()
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' })
|
||||
+ ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function levelClass(level: string): string {
|
||||
return {
|
||||
debug: 'bg-[#1e2d3d] text-[#5a7a94]',
|
||||
info : 'bg-blue-500/10 text-blue-400',
|
||||
warn : 'bg-yellow-500/10 text-yellow-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
}[level] ?? 'bg-[#1e2d3d] text-[#5a7a94]'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
+211
-63
@@ -27,14 +27,15 @@
|
||||
<!-- 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" alt="fanart image"/>
|
||||
<img :src="data.serie.fanart_image" class="w-full h-full object-cover opacity-20"
|
||||
@error="e => ((e.target as HTMLElement).closest('div') as HTMLElement).style.display = 'none'" />
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
|
||||
<!-- Poster -->
|
||||
<div class="shrink-0 w-36 rounded-xl overflow-hidden border border-white/10 shadow-2xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" alt="poster image"/>
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
|
||||
@@ -64,17 +65,40 @@
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
:title="t.raw"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="t.label === 'Intégrale'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20'"
|
||||
:disabled="downloading === `integrale-${i}`"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20',
|
||||
(downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)) ? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
:disabled="downloading.has(`integrale-${i}`) || downloaded.has(`integrale-${i}`) || isAlreadyQueued(t)"
|
||||
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `integrale-${i}`" />
|
||||
{{ t.label }}
|
||||
<DownloadIcon :spinning="downloading.has(`integrale-${i}`)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : downloaded.has(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Barre progression intégrale -->
|
||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||
<template v-if="torrentProgress(extractHash(t))">
|
||||
<span v-if="torrentProgress(extractHash(t))!.progress === 100"
|
||||
class="mt-2 inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
<div v-else class="mt-2 w-full max-w-sm">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement</span>
|
||||
<span>{{ torrentProgress(extractHash(t))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(t))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,6 +132,15 @@
|
||||
<p class="text-xs text-zinc-600 mt-0.5">
|
||||
{{ season.episodes.length }} épisode{{ season.episodes.length > 1 ? 's' : '' }}
|
||||
· {{ seasonAvailableCount(season) }}/{{ season.episodes.length }} dispo
|
||||
<span v-if="season.organized_state !== 'none'"
|
||||
class="ml-1.5 px-1.5 py-0.5 rounded text-[10px] font-medium"
|
||||
:class="season.organized_state === 'complete'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-yellow-500/10 text-yellow-500'">
|
||||
{{ season.organized_state === 'complete'
|
||||
? `✓ ${season.organized_count} organisé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} organisés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,70 +148,132 @@
|
||||
<!-- Bouton pack_saison sur le header -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="season.torrent
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'"
|
||||
:disabled="!season.torrent || downloading === `season-${season.id}`"
|
||||
:class="[
|
||||
season.torrent && !downloaded.has(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!season.torrent || downloading.has(`season-${season.id}`) || downloaded.has(`season-${season.id}`) || isAlreadyQueued(season.torrent) || season.organized_state === 'complete'"
|
||||
@click="season.torrent && download(`season-${season.id}`, season.torrent.torrent_url, season.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `season-${season.id}`" :size="12" />
|
||||
Saison entière
|
||||
<DownloadIcon :spinning="downloading.has(`season-${season.id}`)" :size="12" />
|
||||
{{ season.organized_state === 'complete' ? 'Organisé ✓' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : downloaded.has(`season-${season.id}`) ? 'Envoyé ✓' : 'Saison entière' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Barre progression saison -->
|
||||
<template v-if="torrentProgress(extractHash(season.torrent))">
|
||||
<div v-if="torrentProgress(extractHash(season.torrent))!.progress === 100"
|
||||
class="px-5 py-2 border-b border-white/5">
|
||||
<span class="inline-flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ Téléchargé
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="px-5 py-2 border-b border-white/5">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement en cours</span>
|
||||
<span>{{ torrentProgress(extractHash(season.torrent))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Episodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
|
||||
<div
|
||||
v-for="ep in season.episodes"
|
||||
:key="ep.id"
|
||||
class="flex items-center gap-4 px-5 py-3 hover:bg-white/[0.02] transition"
|
||||
class="px-5 py-3 hover:bg-white/[0.02] transition"
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" alt="thumb image"/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
|
||||
<!-- Ligne principale -->
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-24 aspect-video rounded-lg overflow-hidden bg-zinc-800 hidden sm:block">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700 text-xs font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${ep.episode_number}` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-zinc-600 shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2,'0')}` }}
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badges + bouton download -->
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
|
||||
<!-- Badge Hors Fankai (torrent manuel) -->
|
||||
<span
|
||||
v-if="ep.torrent?.manual"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
>
|
||||
Hors Fankai
|
||||
</span>
|
||||
<span class="text-sm font-medium truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-zinc-600">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-zinc-600">{{ ep.duration }} min</span>
|
||||
|
||||
<!-- Badge organisé -->
|
||||
<span v-if="ep.organized"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ organisé
|
||||
</span>
|
||||
|
||||
<!-- Badge téléchargé à 100% -->
|
||||
<span v-if="torrentProgress(extractHash(ep.torrent))?.progress === 100"
|
||||
class="text-[10px] px-2 py-0.5 rounded-full border bg-green-500/10 text-green-400 border-green-500/20">
|
||||
✓ téléchargé
|
||||
</span>
|
||||
|
||||
<!-- Badge dispo/indispo -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border"
|
||||
:class="ep.available
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton download -->
|
||||
<button
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition"
|
||||
:class="[
|
||||
ep.torrent && !downloaded.has(`ep-${ep.id}`) && !isAlreadyQueued(ep.torrent) && !ep.organized
|
||||
? 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border-orange-500/20'
|
||||
: 'bg-white/[0.03] text-zinc-600 border-white/8 cursor-not-allowed'
|
||||
]"
|
||||
:disabled="!ep.torrent || downloading.has(`ep-${ep.id}`) || downloaded.has(`ep-${ep.id}`) || isAlreadyQueued(ep.torrent) || ep.organized"
|
||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading.has(`ep-${ep.id}`)" :size="12" />
|
||||
{{ ep.organized ? '✓' : isAlreadyQueued(ep.torrent) ? '✓' : downloaded.has(`ep-${ep.id}`) ? '✓' : 'Ep.' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton épisode (type episode uniquement) -->
|
||||
<div class="shrink-0">
|
||||
<!-- Badge dispo/indispo basé sur available (episode résolu dans un torrent) -->
|
||||
<span
|
||||
class="inline-block text-[10px] px-2 py-0.5 rounded-full border mr-2"
|
||||
:class="ep.available
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-600 border-white/8'"
|
||||
>
|
||||
{{ ep.available ? 'dispo' : 'indispo' }}
|
||||
</span>
|
||||
|
||||
<!-- Bouton download — visible seulement si type=episode, sinon grisé -->
|
||||
<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
|
||||
? '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 === `ep-${ep.id}`"
|
||||
@click="ep.torrent && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `ep-${ep.id}`" :size="12" />
|
||||
Ep.
|
||||
</button>
|
||||
<!-- Barre progression épisode (seulement si < 100%) -->
|
||||
<div v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
|
||||
class="mt-2 sm:pl-28">
|
||||
<div class="flex items-center justify-between text-[10px] text-zinc-500 mb-1">
|
||||
<span>Téléchargement</span>
|
||||
<span>{{ torrentProgress(extractHash(ep.torrent))!.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-orange-500 rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -208,7 +303,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, defineComponent, h } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import type { Season } from '@/stores/series'
|
||||
@@ -230,9 +325,42 @@ const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
|
||||
const collapsedSeasons = ref<Set<number>>(new Set())
|
||||
const downloading = ref<string | null>(null)
|
||||
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
const downloading = ref<Set<string>>(new Set())
|
||||
const downloaded = ref<Set<string>>(new Set())
|
||||
const toast = ref<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
|
||||
// ── Progression downloads ──────────────────────────────────────
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string; name: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchActiveDownloads() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const list: any[] = await res.json()
|
||||
activeTorrents.value = list.map(t => ({
|
||||
hash : t.hash,
|
||||
progress: t.progress ?? 0,
|
||||
state : t.state,
|
||||
name : t.name,
|
||||
}))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function torrentProgress(hash: string | null | undefined): ActiveTorrent | null {
|
||||
if (!hash) return null
|
||||
return activeTorrents.value.find(t => t.hash.toLowerCase() === hash.toLowerCase()) ?? null
|
||||
}
|
||||
|
||||
function extractHash(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): string | null {
|
||||
if (!torrent) return null
|
||||
const magnet = torrent.magnet ?? ''
|
||||
const m = magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
// ── Data ───────────────────────────────────────────────────────
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
function seasonAvailableCount(season: Season) {
|
||||
@@ -254,19 +382,39 @@ function showToast(type: 'success' | 'error', message: string) {
|
||||
setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
function isAlreadyQueued(torrent: { magnet?: string | null; torrent_url?: string | null } | null | undefined): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null) {
|
||||
downloading.value = key
|
||||
if (downloading.value.has(key) || downloaded.value.has(key)) return
|
||||
downloading.value = new Set([...downloading.value, key])
|
||||
const result = await store.download(torrent_url, magnet)
|
||||
downloading.value = null
|
||||
if (result.success) showToast('success', 'Ajouté à qBittorrent !')
|
||||
else showToast('error', result.error ?? 'Erreur inconnue')
|
||||
downloading.value = new Set([...downloading.value].filter(k => k !== key))
|
||||
if (result.success) {
|
||||
downloaded.value = new Set([...downloaded.value, key])
|
||||
showToast('success', 'Téléchargement lancé')
|
||||
fetchActiveDownloads()
|
||||
} else {
|
||||
showToast('error', result.error ?? 'Erreur inconnue')
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
store.fetchSerieDetail(Number(route.params.id))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
onMounted(() => {
|
||||
load()
|
||||
fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+109
-15
@@ -87,19 +87,29 @@
|
||||
|
||||
<!-- Dossier complétion -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER COMPLÉTION</label>
|
||||
<input v-model="form.completePath"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/downloads/complete"/>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent (monté dans Docker)</p>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER COMPLÉTION</label>
|
||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
|
||||
:class="form.completePath ? 'text-white' : 'text-[#5a7a94]'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier de complétion de qBittorrent</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossier Jellyfin -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">DOSSIER JELLYFIN</label>
|
||||
<input v-model="form.mediaPath"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
placeholder="/media/Kai"/>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest">DOSSIER JELLYFIN</label>
|
||||
<span v-if="isDocker" class="text-[9px] tracking-widest text-[#3a8fe8] border border-[#3a8fe8]/40 px-1.5 py-0.5">DOCKER</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-sm px-3 py-2.5 text-left hover:border-[#e8513a] transition-colors cursor-pointer font-mono truncate"
|
||||
:class="form.mediaPath ? 'text-white' : 'text-[#5a7a94]'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
|
||||
</div>
|
||||
|
||||
@@ -127,11 +137,37 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<!-- Support NFO -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">NFO / MÉTADONNÉES</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="form.nfoSupport = !form.nfoSupport"
|
||||
class="relative shrink-0 w-11 h-6 rounded-full transition-colors cursor-pointer"
|
||||
:class="form.nfoSupport ? 'bg-[#e8513a]' : 'bg-[#1e2d3d]'">
|
||||
<span class="absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="form.nfoSupport ? 'translate-x-5' : 'translate-x-0'" />
|
||||
</button>
|
||||
<span class="text-xs text-[#c8d8e8]">Télécharger les NFO et images depuis GitLab</span>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
Pour les lecteurs sans agent Fankai (Kodi, Infuse, etc.) — télécharge les métadonnées depuis
|
||||
<span class="text-white">fankai_pack</span> au moment de l'organisation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-1 flex items-center gap-3">
|
||||
<button type="button" @click="save" :disabled="saving"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ saving ? '...' : 'SAUVEGARDER' }}
|
||||
</button>
|
||||
<button type="button" @click="scan" :disabled="scanning"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{{ scanning ? 'SCAN...' : 'ANALYSER BIBLIOTHÈQUE' }}
|
||||
</button>
|
||||
<span v-if="scanResult" class="text-[10px]"
|
||||
:class="scanResult.added > 0 ? 'text-green-400' : 'text-[#5a7a94]'">
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,6 +182,14 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
|
||||
<!-- Modal ajout client -->
|
||||
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
|
||||
@@ -203,6 +247,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -210,13 +255,35 @@ const { add: toast } = useToast()
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const form = ref({
|
||||
mediaPath : '/media/Kai',
|
||||
completePath: '/downloads/complete',
|
||||
mediaPath : '',
|
||||
completePath: '',
|
||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const updating = 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 ──────────────────────────────────────────────
|
||||
const picker = ref<{ open: boolean; field: 'completePath' | 'mediaPath'; currentPath: string }>({
|
||||
open: false, field: 'completePath', currentPath: '/'
|
||||
})
|
||||
|
||||
function openPicker(field: 'completePath' | 'mediaPath') {
|
||||
picker.value = {
|
||||
open : true,
|
||||
field,
|
||||
currentPath: (form.value as any)[field] || '/',
|
||||
}
|
||||
}
|
||||
|
||||
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[]>([])
|
||||
@@ -244,16 +311,18 @@ function clientLabel(type: string): string {
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const [settingsRes, statusRes, clientsRes, availRes] = await Promise.all([
|
||||
const [settingsRes, statusRes, clientsRes, availRes, systemRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients/available', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (statusRes.ok) torrentsStatus.value = await statusRes.json()
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (availRes.ok) availableClients.value = await availRes.json()
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
})
|
||||
@@ -377,6 +446,31 @@ async function update() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scan bibliothèque ──────────────────────────────────────────
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
scanResult.value = null
|
||||
try {
|
||||
const res = await fetch('/api/scan', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
scanResult.value = { found: data.found, added: data.added }
|
||||
toast(
|
||||
data.added > 0
|
||||
? `${data.found} fichiers scannés — ${data.added} ajoutés`
|
||||
: `${data.found} fichiers scannés — rien de nouveau`,
|
||||
'success'
|
||||
)
|
||||
} else {
|
||||
toast('Erreur lors du scan', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarder settings ───────────────────────────────────────
|
||||
async function save() {
|
||||
saving.value = true
|
||||
|
||||
+5
-2
@@ -10,10 +10,13 @@ export default defineConfig({
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist/client'
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3000, // ou autre port fixe
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001'
|
||||
'/api': 'http://localhost:9898'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user