Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a39a98615b | ||
|
|
2a3defdb34 | ||
|
|
733f8790ca | ||
|
|
4a9bbd9402 | ||
|
|
1562ca32f9 | ||
|
|
7b780e4fa1 | ||
|
|
444b30ef3d | ||
|
|
42b46496d1 | ||
|
|
f092da4108 | ||
|
|
8e635c1d28 | ||
|
|
3b8b7a0454 | ||
|
|
9ef1516b0b | ||
|
|
1af210058c | ||
|
|
6caad83081 | ||
|
|
520bfcfae1 | ||
|
|
2e5bc8953a | ||
|
|
f304562f01 | ||
|
|
c9690d9a1b | ||
|
|
5daface279 | ||
|
|
5687aaa5d9 | ||
|
|
ba67817ea0 | ||
|
|
e0452dbd0c | ||
|
|
c376061333 | ||
|
|
bb02ea6cf8 | ||
|
|
887beae614 | ||
|
|
1290d5c925 | ||
|
|
dffb340250 | ||
|
|
059298f537 | ||
|
|
0ba2144f2f | ||
|
|
83a2fb9860 | ||
|
|
5445fadf94 | ||
|
|
6d110b86dd | ||
|
|
2ef6d4738a | ||
|
|
65f7d4e109 | ||
|
|
6166c370ef | ||
|
|
ad1cade6bf | ||
|
|
803519062a | ||
|
|
6246c44994 | ||
|
|
9780aee40d | ||
|
|
e5e8baf1fd | ||
|
|
79e73b603f | ||
|
|
49c3a09129 | ||
|
|
4a24b7fa0f | ||
|
|
80aef10230 | ||
|
|
92055caf4d | ||
|
|
d8e284b0de | ||
|
|
0b98269695 | ||
|
|
402946b462 | ||
|
|
c374ceb0a7 | ||
|
|
6f21b0a5cd | ||
|
|
1c36bb7688 | ||
|
|
82a9d51731 | ||
|
|
ac03aa3c11 | ||
|
|
d8173a6d72 | ||
|
|
60c9ffa14c | ||
|
|
0751c2edcc | ||
|
|
7e050a699d | ||
|
|
9badada4a3 | ||
|
|
5e881272a1 | ||
|
|
a229a1eb3b | ||
|
|
b621adc6f6 | ||
|
|
7d3bc413ef | ||
|
|
6f9b7892b1 | ||
|
|
2332b544e6 | ||
|
|
3b091b7aa8 | ||
|
|
e1e430a968 | ||
|
|
2d49125e09 | ||
|
|
721047c95f | ||
|
|
74662de2b6 | ||
|
|
84ef0333f3 | ||
|
|
c6624843b9 | ||
|
|
8bd30913cd | ||
|
|
d7eb932ed3 | ||
|
|
4594dc5189 | ||
|
|
894a621e85 | ||
|
|
23dbf86e70 | ||
|
|
d86138ef1c | ||
|
|
4aa6f99676 | ||
|
|
10432e0f99 | ||
|
|
4f69f8bf92 | ||
|
|
1511dd3ca3 | ||
|
|
23967a81d6 | ||
|
|
21ff025376 | ||
|
|
9ba19f9539 | ||
|
|
215d802b12 | ||
|
|
21dccbe5d9 | ||
|
|
329150f1c4 | ||
|
|
17f7234b49 | ||
|
|
8e529d3845 | ||
|
|
a3f3539a27 | ||
|
|
d5c37e8f2c | ||
|
|
a5f5dfb0da | ||
|
|
08531c80f3 | ||
|
|
96ce207027 | ||
|
|
13f9a27af8 | ||
|
|
d0d40c02b5 | ||
|
|
0e541a19ba | ||
|
|
1d71c40a2b | ||
|
|
adc3e91122 | ||
|
|
b260208701 | ||
|
|
3dd517c37f | ||
|
|
93397bee77 | ||
|
|
3f72c01911 | ||
|
|
77e6a2e38c | ||
|
|
bef99183aa | ||
|
|
7b84229510 | ||
|
|
23c46a621e | ||
|
|
4dc7255bb0 | ||
|
|
180513c98a | ||
|
|
00894bc069 | ||
|
|
a1b128640d | ||
|
|
8bec4c62fa | ||
|
|
a7ba6a54a3 | ||
|
|
33d0b9a52d | ||
|
|
46546d0178 | ||
|
|
756ced0001 | ||
|
|
2addb39ba3 | ||
|
|
232ede4292 | ||
|
|
f161e84a1d | ||
|
|
4d4a8029f6 | ||
|
|
2a1dcddd90 | ||
|
|
4d319f97bc | ||
|
|
3372d4cb40 | ||
|
|
eb22bb669f | ||
|
|
0d64a66977 | ||
|
|
b0e81166e1 | ||
|
|
79d7aa7485 | ||
|
|
af995a84cb | ||
|
|
5a71bdca3c | ||
|
|
77d918fbe0 | ||
|
|
a0b255338d | ||
|
|
e0b09c144f | ||
|
|
418d09b07f | ||
|
|
4c7936b019 | ||
|
|
77f42b5b3e | ||
|
|
bd69f7a730 | ||
|
|
3ce6523248 | ||
|
|
2efed9dcfc | ||
|
|
88e2a0bc38 | ||
|
|
338e3f8547 | ||
|
|
d1ccc902da | ||
|
|
5e54e24b60 | ||
|
|
aae2f592b5 | ||
|
|
bd94e6a460 | ||
|
|
cbe3e1a786 | ||
|
|
7a0917e053 | ||
|
|
8ac4f7a65b | ||
|
|
453cd47e85 | ||
|
|
d5679ec529 | ||
|
|
2f0be502c3 | ||
|
|
ec971915b5 | ||
|
|
bb71b241d1 | ||
|
|
7aa01755ec | ||
|
|
8ce0ca1f24 | ||
|
|
620aac6b38 | ||
|
|
e99e413e4e | ||
|
|
fb578cf9f9 | ||
|
|
78e3055344 | ||
|
|
15f5847c97 | ||
|
|
8736b58d8c | ||
|
|
12e8e2f93a | ||
|
|
ed2306bb70 | ||
|
|
b3fd57c2c8 | ||
|
|
c2da12409a | ||
|
|
178a57c444 | ||
|
|
1afe13c555 | ||
|
|
c8963cc4cf | ||
|
|
391b793e39 | ||
|
|
9a1519e6cd | ||
|
|
a12237af68 | ||
|
|
d304aa6a26 | ||
|
|
27092b3968 | ||
|
|
c1c36018b1 | ||
|
|
b462ec717b | ||
|
|
65518861ac | ||
|
|
ed75280e70 | ||
|
|
9f53a9fef5 | ||
|
|
3e5dc8d1d4 | ||
|
|
bcb7f656a1 | ||
|
|
372359d040 | ||
|
|
63847c583a | ||
|
|
e90f85af70 | ||
|
|
0c683d4e37 | ||
|
|
16b28004cf | ||
|
|
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,124 @@
|
||||
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: linux-arm64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-arm64-termux.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 }}
|
||||
@@ -7,10 +7,12 @@ yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
config
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
binaries
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
+632
@@ -1,5 +1,637 @@
|
||||
# Changelog
|
||||
|
||||
## [3.3.3](https://github.com/Masutayunikon/FanKarr/compare/v3.3.2...v3.3.3) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* MediaManagementView.vue api call is now in mounted ([8c5b650](https://github.com/Masutayunikon/FanKarr/commit/8c5b650ca6eeac75f33e8c5423f6d4fd24f9871b))
|
||||
* path ([58ae88a](https://github.com/Masutayunikon/FanKarr/commit/58ae88a01c063318407134cd7351572220acd892))
|
||||
|
||||
## [3.3.2](https://github.com/Masutayunikon/FanKarr/compare/v3.3.1...v3.3.2) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use fetch instead of useFetch ([af550ec](https://github.com/Masutayunikon/FanKarr/commit/af550eccfbeeb116491e8ea4973eac493ed962d3))
|
||||
|
||||
## [3.3.1](https://github.com/Masutayunikon/FanKarr/compare/v3.3.0...v3.3.1) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add driver register for transmission ([7d2096e](https://github.com/Masutayunikon/FanKarr/commit/7d2096ec1972079a2e0e5bdfe01c4cc702e6b185))
|
||||
|
||||
## [3.3.0](https://github.com/Masutayunikon/FanKarr/compare/v3.2.1...v3.3.0) (2026-04-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add transmission client ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||
* set defaultPath for any system ([a2b88de](https://github.com/Masutayunikon/FanKarr/commit/a2b88de3a123aeb9c140e83605901dd5cdad041a))
|
||||
|
||||
## [3.2.1](https://github.com/Masutayunikon/FanKarr/compare/v3.2.0...v3.2.1) (2026-04-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bun target android ([a167a87](https://github.com/Masutayunikon/FanKarr/commit/a167a870188bdcd6b26644e304c2059ad5f05e96))
|
||||
|
||||
## [3.2.0](https://github.com/Masutayunikon/FanKarr/compare/v3.1.1...v3.2.0) (2026-04-07)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add build for arm64 ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||
* add developer settings on settings and dashboard debug ([96acc13](https://github.com/Masutayunikon/FanKarr/commit/96acc134698ca09842d57d906e4a28ab8e94ba56))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* android build name ([0cfe6eb](https://github.com/Masutayunikon/FanKarr/commit/0cfe6eb81055b841a67c08e02129709c0214d87d))
|
||||
|
||||
## [3.1.1](https://github.com/Masutayunikon/FanKarr/compare/v3.1.0...v3.1.1) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove plex settings ([8744ade](https://github.com/Masutayunikon/FanKarr/commit/8744adeff5f2db1ab648412d50495690d9e68d0b))
|
||||
* torrent for multiple seasons is now working ([2514008](https://github.com/Masutayunikon/FanKarr/commit/25140086c13ce5a497500659bdd6cea2aeff96ed))
|
||||
|
||||
## [3.1.0](https://github.com/Masutayunikon/FanKarr/compare/v3.0.0...v3.1.0) (2026-03-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* use original_name and formated_name from scraper to bypass timeout ([1b693fc](https://github.com/Masutayunikon/FanKarr/commit/1b693fc53b9ca3a830c952c3f2c101bf1da7d27e))
|
||||
|
||||
## [3.0.0](https://github.com/Masutayunikon/FanKarr/compare/v2.4.1...v3.0.0) (2026-03-27)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* nouvelle architecture frontend avec layout sidebar, système de thèmes et routes restructurées
|
||||
|
||||
### Features
|
||||
|
||||
* hors-fankai badge and fix series datetime and typo ([9d4500a](https://github.com/Masutayunikon/FanKarr/commit/9d4500a3834455b5dd72663c8eeb2d1c54b240e2))
|
||||
* refonte complète du frontend et amélioration des logs serveur ([ed38a2d](https://github.com/Masutayunikon/FanKarr/commit/ed38a2d07d977bd3d3bb41da6f2aad8b70e7f359))
|
||||
|
||||
## [2.4.1](https://github.com/Masutayunikon/FanKarr/compare/v2.4.0...v2.4.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add usePlexTitles to return function ([381e327](https://github.com/Masutayunikon/FanKarr/commit/381e3274344c59cc80ba6230d30c96e5c5637ea5))
|
||||
|
||||
## [2.4.0](https://github.com/Masutayunikon/FanKarr/compare/v2.3.3...v2.4.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **settings:** option titres optimisés Plex (title_for_plex) ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** activeTorrents filtre désormais uniquement state=downloading ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
* **organize:** retry sur les erreurs réseau dans enrichSeriesDataWithOriginalFilenames ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
* **organize:** sanitisation des caractères spéciaux dans les noms de dossiers ([54dfe6b](https://github.com/Masutayunikon/FanKarr/commit/54dfe6b595c65a77b50dc3b4853182b594df0d9b))
|
||||
|
||||
## [2.3.3](https://github.com/Masutayunikon/FanKarr/compare/v2.3.2...v2.3.3) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* recuperation des episodes avec un delai ([dfc6c13](https://github.com/Masutayunikon/FanKarr/commit/dfc6c1300e3dd8323e422ce3e71465bac77f94cf))
|
||||
|
||||
## [2.3.2](https://github.com/Masutayunikon/FanKarr/compare/v2.3.1...v2.3.2) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* badge catalogue sur les series importé ([f070f17](https://github.com/Masutayunikon/FanKarr/commit/f070f1754ef616874cfb081eb2fefadad602c221))
|
||||
|
||||
## [2.3.1](https://github.com/Masutayunikon/FanKarr/compare/v2.3.0...v2.3.1) (2026-03-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add readsettings import ([ceb8e64](https://github.com/Masutayunikon/FanKarr/commit/ceb8e64a55937e20e27c1fb64865aba59516d518))
|
||||
|
||||
## [2.3.0](https://github.com/Masutayunikon/FanKarr/compare/v2.2.0...v2.3.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **catalogue:** badge IMPORTÉ et refresh automatique post-import ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **catalogue:** tri du catalogue avec 4 options ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **downloads:** bouton Organiser tout renommé Importer tout ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **organize:** nommage des fichiers selon original_filename ou formatted_name ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **settings:** ajout du toggle import automatique (activé par défaut) ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
* **settings:** textes génériques sans mention de logiciels spécifiques ([0cf3446](https://github.com/Masutayunikon/FanKarr/commit/0cf344667baccd44c946cee507c4343286783d3f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config directory to .gitignore ([2d8749a](https://github.com/Masutayunikon/FanKarr/commit/2d8749ae7bc2986871fb402c2a4242bcb87d3a77))
|
||||
|
||||
## [2.2.0](https://github.com/Masutayunikon/FanKarr/compare/v2.1.3...v2.2.0) (2026-03-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add organize all button ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* organizer reuse the wrong name for gitlab path ([bbaccfd](https://github.com/Masutayunikon/FanKarr/commit/bbaccfd97b51724932f2ebf22cad81be1cde61c0))
|
||||
|
||||
## [2.1.3](https://github.com/Masutayunikon/FanKarr/compare/v2.1.2...v2.1.3) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use magnet before torrent ([ed4665a](https://github.com/Masutayunikon/FanKarr/commit/ed4665a378d15c0b45a2be32d612e41082a43df1))
|
||||
|
||||
## [2.1.2](https://github.com/Masutayunikon/FanKarr/compare/v2.1.1...v2.1.2) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path now work on every device ([cfc3440](https://github.com/Masutayunikon/FanKarr/commit/cfc3440a95cb73802778e01d5f256f473e0cc952))
|
||||
|
||||
## [2.1.1](https://github.com/Masutayunikon/FanKarr/compare/v2.1.0...v2.1.1) (2026-03-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* torrent path to hash ([d9e453b](https://github.com/Masutayunikon/FanKarr/commit/d9e453b486b9fd85b69ee134bee2bd6a1a6d3702))
|
||||
|
||||
## [2.1.0](https://github.com/Masutayunikon/FanKarr/compare/v2.0.0...v2.1.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **organize:** le format paths[] dans series/{id}.json passe de string[] à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **organize:** buildFileMap matche par infohash au lieu d'index ou titre ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** migration vers paths { infohash, path } pour le matching fichiers ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** scanMediaPath adapté au nouveau format paths[] ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* **organize:** source fichier via save_path qBittorrent + fullPath relatif ([43ac2ed](https://github.com/Masutayunikon/FanKarr/commit/43ac2ed2e85d9e769735547817f19100d3d7f980))
|
||||
* remove logo ([48416c4](https://github.com/Masutayunikon/FanKarr/commit/48416c4fef3a964b05aa135ffd9e80d5dec9a81a))
|
||||
|
||||
## [2.0.0](https://github.com/Masutayunikon/FanKarr/compare/v1.14.0...v2.0.0) (2026-03-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* suppression de torrent_final.json au profit d'un système de fichiers par série (series/{id}.json) avec cache mémoire GitHub 6h. Version majeure — nécessite un nouveau scraper et une mise à jour complète.
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** adaptation complète à la nouvelle structure API Fankai ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** cache mémoire GitHub TTL 6h avec invalidation via /api/update ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **api:** matching torrents par hiérarchie série/saison/épisode ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* migration vers nouvelle architecture API et scraper par série ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **organize:** refonte complète sans torrent_final.json ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **scraper:** extraction nom bencode dans raw pour torrents locaux ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** badge "Hors Fankai" sur épisodes de torrents manuels ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series-view:** tooltip raw name sur boutons intégrale via :title ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état download basé sur épisodes organisés vs torrents bruts ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
* **series:** boutons saison Yu-Gi-Oh via fallback season_number ([b3fd7dd](https://github.com/Masutayunikon/FanKarr/commit/b3fd7dded40bf067acc8552cbf5f34a57d27000c))
|
||||
|
||||
## [1.14.0](https://github.com/Masutayunikon/FanKarr/compare/v1.13.0...v1.14.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **scraper:** extraction du nom bencode pour les torrents locaux ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** badge "Hors Fankai" sur les épisodes de torrents manuels ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series-view:** tooltip avec le nom du torrent sur les boutons intégrale ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalogue:** état "partiel" incorrect quand l'intégrale est organisée ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
* **series:** résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires ([67237e9](https://github.com/Masutayunikon/FanKarr/commit/67237e977e34edda4d92a9375de4c4e470725eb1))
|
||||
|
||||
## [1.13.0](https://github.com/Masutayunikon/FanKarr/compare/v1.12.2...v1.13.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.12.2](https://github.com/Masutayunikon/FanKarr/compare/v1.12.1...v1.12.2) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use season number from resolved episodes in priority ([dc48634](https://github.com/Masutayunikon/FanKarr/commit/dc486347193a8066a32d8f0106518abc2b04b7e2))
|
||||
|
||||
## [1.12.1](https://github.com/Masutayunikon/FanKarr/compare/v1.12.0...v1.12.1) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([7ddd9ca](https://github.com/Masutayunikon/FanKarr/commit/7ddd9cad9271d79a258f45245bf405f13b9d764f))
|
||||
|
||||
## [1.12.0](https://github.com/Masutayunikon/FanKarr/compare/v1.11.10...v1.12.0) (2026-03-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* now rename episodes with original_filename from api ([9bdb21a](https://github.com/Masutayunikon/FanKarr/commit/9bdb21a673ebb9871548aea6691bafbd049554ce))
|
||||
|
||||
## [1.11.10](https://github.com/Masutayunikon/FanKarr/compare/v1.11.9...v1.11.10) (2026-03-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve torrent organization and cleanup logic ([1908cb3](https://github.com/Masutayunikon/FanKarr/commit/1908cb3e8c5b99e91e26735f7cfa3dcd23709df7))
|
||||
* season 0 not clonficting anymore ([635082d](https://github.com/Masutayunikon/FanKarr/commit/635082de00bb42bdeaf20b1654f742ad1c4addbe))
|
||||
|
||||
## [1.11.9](https://github.com/Masutayunikon/FanKarr/compare/v1.11.8...v1.11.9) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* season 0 resolved is no longer skipped in the worker ([c48ec76](https://github.com/Masutayunikon/FanKarr/commit/c48ec769ba7725327e657366e882498846d0c63f))
|
||||
|
||||
## [1.11.8](https://github.com/Masutayunikon/FanKarr/compare/v1.11.7...v1.11.8) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* config path /config/config to /config ([7518191](https://github.com/Masutayunikon/FanKarr/commit/75181910244621a8eca989067fd4777922914d8e))
|
||||
|
||||
## [1.11.7](https://github.com/Masutayunikon/FanKarr/compare/v1.11.6...v1.11.7) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([3df8def](https://github.com/Masutayunikon/FanKarr/commit/3df8defa5e46b09722a345c7c3a94e91aa16e4d8))
|
||||
|
||||
## [1.11.6](https://github.com/Masutayunikon/FanKarr/compare/v1.11.5...v1.11.6) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* path ([0329ee2](https://github.com/Masutayunikon/FanKarr/commit/0329ee27a34038456eb297f07b808de3157d88f4))
|
||||
|
||||
## [1.11.5](https://github.com/Masutayunikon/FanKarr/compare/v1.11.4...v1.11.5) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export _isBunBinary ([3d43de3](https://github.com/Masutayunikon/FanKarr/commit/3d43de3eb223ec0338e0b9aebffff35e116e00a0))
|
||||
|
||||
## [1.11.4](https://github.com/Masutayunikon/FanKarr/compare/v1.11.3...v1.11.4) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add config.ts with config variable (BASE_DIR...) ([a3eb67c](https://github.com/Masutayunikon/FanKarr/commit/a3eb67c9a0944500142a6636d57cae0acfd7543d))
|
||||
|
||||
## [1.11.3](https://github.com/Masutayunikon/FanKarr/compare/v1.11.2...v1.11.3) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* entrypoint.sh config directory ([7e4b2fa](https://github.com/Masutayunikon/FanKarr/commit/7e4b2fa60a5946d3220618f13420a07fa57e8e1b))
|
||||
|
||||
## [1.11.2](https://github.com/Masutayunikon/FanKarr/compare/v1.11.1...v1.11.2) (2026-03-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve ([3a278ba](https://github.com/Masutayunikon/FanKarr/commit/3a278ba33388d6ff6f33413d7696d58d0172c953))
|
||||
|
||||
## [1.11.1](https://github.com/Masutayunikon/FanKarr/compare/v1.11.0...v1.11.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker now work on multiple github files pages ([71784cd](https://github.com/Masutayunikon/FanKarr/commit/71784cd8bd8f3ea5dc7423687b080c1588d70854))
|
||||
|
||||
## [1.11.0](https://github.com/Masutayunikon/FanKarr/compare/v1.10.3...v1.11.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* fix worker for multiple series in integral (one piece fix ?) ([2c773f8](https://github.com/Masutayunikon/FanKarr/commit/2c773f83ffd76d82c77a50a9c5729f20ea22b450))
|
||||
|
||||
## [1.10.3](https://github.com/Masutayunikon/FanKarr/compare/v1.10.2...v1.10.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker path ([360572b](https://github.com/Masutayunikon/FanKarr/commit/360572b8be64dafbb452dfcb849a40305bf1b8cf))
|
||||
|
||||
## [1.10.2](https://github.com/Masutayunikon/FanKarr/compare/v1.10.1...v1.10.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([58b09f2](https://github.com/Masutayunikon/FanKarr/commit/58b09f2267345c60ed589c160f7e47698e9be9a4))
|
||||
|
||||
## [1.10.1](https://github.com/Masutayunikon/FanKarr/compare/v1.10.0...v1.10.1) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add target node ([6c629e0](https://github.com/Masutayunikon/FanKarr/commit/6c629e0f6b4d38453cfb22fd315e02d9e8750c45))
|
||||
|
||||
## [1.10.0](https://github.com/Masutayunikon/FanKarr/compare/v1.9.5...v1.10.0) (2026-03-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add favicon ([dc4a2d4](https://github.com/Masutayunikon/FanKarr/commit/dc4a2d48f5c0cddd8e431ce33a69fc9146143fb6))
|
||||
* add README.md ([4eb487a](https://github.com/Masutayunikon/FanKarr/commit/4eb487a96beed8c82cb19d9a9b63c3b69d841386))
|
||||
* add windows / linux / macos binaries on build ([f17a73b](https://github.com/Masutayunikon/FanKarr/commit/f17a73b9591fb49a75bd5fcdadf1970585877dab))
|
||||
* **auth:** génération automatique du JWT secret au démarrage ([eaa52e9](https://github.com/Masutayunikon/FanKarr/commit/eaa52e90900ff0e198beae190d7ad0298a702482))
|
||||
* cache images ([da460de](https://github.com/Masutayunikon/FanKarr/commit/da460de60c4eec6851ea58ce0ae646a1208f6018))
|
||||
* initial project ([eaf01cf](https://github.com/Masutayunikon/FanKarr/commit/eaf01cfc591dd72f9b093b533ced6c12a6c490a1))
|
||||
* make the organizer threaded to not consumme api main thread ([14c0e56](https://github.com/Masutayunikon/FanKarr/commit/14c0e567525e179da82c6b58d9886cc3d544a98d))
|
||||
* **organize:** scan initial mediaPath + logs fichier centralisés ([807f786](https://github.com/Masutayunikon/FanKarr/commit/807f786b038579ca9d7cd136998f759cecae2255))
|
||||
* **organize:** worker thread + badges statut + notifications ([b7242bc](https://github.com/Masutayunikon/FanKarr/commit/b7242bc95f59d259ab1a636aa304bf149f895041))
|
||||
* **serie:** badges état organisation sur épisodes et saisons ([4d3a6bd](https://github.com/Masutayunikon/FanKarr/commit/4d3a6bd276907f793452429d62693fafc1d804db))
|
||||
* **ui:** états catalogue + logs + badges erreurs downloads ([5326828](https://github.com/Masutayunikon/FanKarr/commit/5326828d89aa10a53bbe1ac4016a5eee4d5dbc97))
|
||||
* UX improvements, folder picker, download state & NFO support ([20771fc](https://github.com/Masutayunikon/FanKarr/commit/20771fc31c187fd1fea8aa9b07093b15621a5b85))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add log for debug ([6876c5e](https://github.com/Masutayunikon/FanKarr/commit/6876c5ee6c7734e664a6da4e4bc721cc7c5abdf7))
|
||||
* add log for debug ([d8cdeae](https://github.com/Masutayunikon/FanKarr/commit/d8cdeae675bc73b5842d18d01eff9d5bb0e0a7e8))
|
||||
* add user permissions when the scripts is launched ([501bbfc](https://github.com/Masutayunikon/FanKarr/commit/501bbfcf18c236f7ac382c50d90482b1f51244c1))
|
||||
* CatalogView.vue badge deleted ([d897192](https://github.com/Masutayunikon/FanKarr/commit/d8971920462efd8d1d99eb867016c96713dfef58))
|
||||
* docker errors ([2702764](https://github.com/Masutayunikon/FanKarr/commit/27027642eb55b75adb04446a88e5007e88f75948))
|
||||
* docker is able to change directory ([af08f7d](https://github.com/Masutayunikon/FanKarr/commit/af08f7d9d4fb2e23c5feac6d33b2ef495f9d91b2))
|
||||
* empty docker-compose.yml ([303dfd8](https://github.com/Masutayunikon/FanKarr/commit/303dfd85b5d033e2b247fd064ae1ae612fdb49e1))
|
||||
* logger third args ([5abac1e](https://github.com/Masutayunikon/FanKarr/commit/5abac1e11236ed1e8f7f0de7f0cff881d1b60f10))
|
||||
* nfo settings and add json file when not existing ([0b6bfa6](https://github.com/Masutayunikon/FanKarr/commit/0b6bfa6a81d99353be877ed6d99687ad3d55adbc))
|
||||
* organize file ([d6550e9](https://github.com/Masutayunikon/FanKarr/commit/d6550e9961789073be1583e03d599afb6d295004))
|
||||
* organize use torrent hash and not name ([61901f9](https://github.com/Masutayunikon/FanKarr/commit/61901f9014a759e2950311cd96266ea8201fa902))
|
||||
* path ([314f817](https://github.com/Masutayunikon/FanKarr/commit/314f817280f39d10eb21c4784f2aea41b9a6d32c))
|
||||
* remove user creation in entrypoint.sh ([92d9fa6](https://github.com/Masutayunikon/FanKarr/commit/92d9fa6b01f1e2de1ceb13528afd309b21f9d4cb))
|
||||
* rename if the torrent name is not the same as the api ([b8fcf71](https://github.com/Masutayunikon/FanKarr/commit/b8fcf71f54ab14e5572a7553d8554b617b4981b7))
|
||||
* season fallback ([e9b5091](https://github.com/Masutayunikon/FanKarr/commit/e9b5091242d73be77bf6fb525ce2865554e336cc))
|
||||
* skip excluded folders ([edf806d](https://github.com/Masutayunikon/FanKarr/commit/edf806d9bc48d4de1c3609a4b0405442305d546e))
|
||||
* title readme align ([8ba3b37](https://github.com/Masutayunikon/FanKarr/commit/8ba3b370d1e2cd1565f9c2453be8706e3c198243))
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
* workflow directory ([d8405c1](https://github.com/Masutayunikon/FanKarr/commit/d8405c1589f277cd8fdd3e1fd15cc02d98834aa6))
|
||||
* workflow permmisions ([3620bfb](https://github.com/Masutayunikon/FanKarr/commit/3620bfb71d8c2774b23c097e5e01326099c2f180))
|
||||
|
||||
## [1.9.5](https://github.com/Masutayunikon/FanKarr/compare/v1.9.4...v1.9.5) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([c2ab58e](https://github.com/Masutayunikon/FanKarr/commit/c2ab58e7b2c34aae09b50dbdfde3277da4c276ff))
|
||||
|
||||
## [1.9.4](https://github.com/Masutayunikon/FanKarr/compare/v1.9.3...v1.9.4) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* worker outside the exe ([a47c3a4](https://github.com/Masutayunikon/FanKarr/commit/a47c3a43fb5dbbedd87c9ac2d584c3290fcc6097))
|
||||
|
||||
## [1.9.3](https://github.com/Masutayunikon/FanKarr/compare/v1.9.2...v1.9.3) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use bcryptjs for compilation ([6a6981e](https://github.com/Masutayunikon/FanKarr/commit/6a6981e10a3a9019f49982d7680e562872ee95a5))
|
||||
|
||||
## [1.9.2](https://github.com/Masutayunikon/FanKarr/compare/v1.9.1...v1.9.2) (2026-03-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* workflow directory ([baebd8f](https://github.com/Masutayunikon/FanKarr/commit/baebd8fae146db70ee1cce0c876d2d3bc0f9bf4e))
|
||||
|
||||
## [1.9.1](https://github.com/Masutayunikon/FanKarr/compare/v1.9.0...v1.9.1) (2026-03-13)
|
||||
|
||||
|
||||
### 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
|
||||
+9
-2
@@ -7,15 +7,22 @@
|
||||
"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",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
Generated
+53
-22
@@ -11,9 +11,12 @@ 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
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.1
|
||||
version: 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
lucide-vue-next:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(vue@3.5.29(typescript@5.9.3))
|
||||
node-fetch:
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2
|
||||
@@ -536,6 +542,9 @@ packages:
|
||||
'@types/serve-static@2.2.0':
|
||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||
|
||||
'@types/web-bluetooth@0.0.21':
|
||||
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4':
|
||||
resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -622,6 +631,19 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vueuse/core@14.2.1':
|
||||
resolution: {integrity: sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
'@vueuse/metadata@14.2.1':
|
||||
resolution: {integrity: sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==}
|
||||
|
||||
'@vueuse/shared@14.2.1':
|
||||
resolution: {integrity: sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -650,9 +672,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==}
|
||||
@@ -1039,6 +1061,11 @@ packages:
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
lucide-vue-next@1.0.0:
|
||||
resolution: {integrity: sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
@@ -1087,10 +1114,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 +1123,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'}
|
||||
@@ -1766,6 +1785,8 @@ snapshots:
|
||||
'@types/http-errors': 2.0.5
|
||||
'@types/node': 24.12.0
|
||||
|
||||
'@types/web-bluetooth@0.0.21': {}
|
||||
|
||||
'@vitejs/plugin-vue@6.0.4(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))(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-rc.2
|
||||
@@ -1894,6 +1915,19 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@types/web-bluetooth': 0.0.21
|
||||
'@vueuse/metadata': 14.2.1
|
||||
'@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3))
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
'@vueuse/metadata@14.2.1': {}
|
||||
|
||||
'@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))':
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
@@ -1919,10 +1953,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: {}
|
||||
|
||||
@@ -2314,6 +2345,10 @@ snapshots:
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
lucide-vue-next@1.0.0(vue@3.5.29(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.29(typescript@5.9.3)
|
||||
|
||||
magic-string-ast@1.0.3:
|
||||
dependencies:
|
||||
magic-string: 0.30.21
|
||||
@@ -2351,8 +2386,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
@@ -2361,8 +2394,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 |
+27
-13
@@ -1,15 +1,17 @@
|
||||
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'
|
||||
import { logger } from './logger.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 {
|
||||
username: string
|
||||
username : string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
@@ -31,25 +33,25 @@ function writeAuth(data: AuthData): void {
|
||||
// GET /api/auth/status
|
||||
export function authStatus(req: Request, res: Response): void {
|
||||
const existing = readAuth()
|
||||
const token = req.cookies?.fankarr_token
|
||||
const token = req.cookies?.fankarr_token
|
||||
|
||||
let loggedIn = false
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
loggedIn = true
|
||||
} catch {}
|
||||
} catch {
|
||||
logger.debug('auth', 'Token invalide ou expiré')
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
setup: !!existing,
|
||||
loggedIn
|
||||
})
|
||||
res.json({ setup: !!existing, loggedIn })
|
||||
}
|
||||
|
||||
// POST /api/auth/setup
|
||||
export function authSetup(req: Request, res: Response): void {
|
||||
if (readAuth()) {
|
||||
logger.warn('auth', 'Tentative de setup alors qu\'un compte existe déjà')
|
||||
res.status(400).json({ error: 'Un compte existe déjà' })
|
||||
return
|
||||
}
|
||||
@@ -62,8 +64,9 @@ export function authSetup(req: Request, res: Response): void {
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
|
||||
writeAuth({ username, passwordHash })
|
||||
logger.info('auth', `Compte créé pour "${username}"`)
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -83,17 +86,26 @@ export function authLogin(req: Request, res: Response): void {
|
||||
}
|
||||
|
||||
if (username !== auth.username || !bcrypt.compareSync(password, auth.passwordHash)) {
|
||||
logger.warn('auth', `Échec de connexion pour "${username}"`)
|
||||
res.status(401).json({ error: 'Identifiants incorrects' })
|
||||
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' })
|
||||
logger.info('auth', `Connexion réussie pour "${username}"`)
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// POST /api/auth/logout
|
||||
export function authLogout(_req: Request, res: Response): void {
|
||||
export function authLogout(req: Request, res: Response): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (token) {
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET) as any
|
||||
logger.info('auth', `Déconnexion de "${payload.username}"`)
|
||||
} catch {}
|
||||
}
|
||||
res.clearCookie('fankarr_token')
|
||||
res.json({ success: true })
|
||||
}
|
||||
@@ -102,6 +114,7 @@ export function authLogout(_req: Request, res: Response): void {
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
const token = req.cookies?.fankarr_token
|
||||
if (!token) {
|
||||
logger.debug('auth', `Accès refusé — non authentifié (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Non authentifié' })
|
||||
return
|
||||
}
|
||||
@@ -109,6 +122,7 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
|
||||
jwt.verify(token, JWT_SECRET)
|
||||
next()
|
||||
} catch {
|
||||
logger.warn('auth', `Token invalide sur (${req.method} ${req.path})`)
|
||||
res.status(401).json({ error: 'Token invalide' })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
+417
-177
@@ -10,22 +10,25 @@ import {
|
||||
sanitizeClient, dispatchDownload, dispatchList
|
||||
} from './torrent-clients/index.js'
|
||||
import qbittorrentDriver from './torrent-clients/qbittorrent.js'
|
||||
import { organizeTorrent, autoOrganizeAll } from './organize.js'
|
||||
import transmissionDriver from './torrent-clients/transmission.js'
|
||||
import { organizeTorrent, autoOrganizeAll, scanMediaPath, workerRunning } from './organize.js'
|
||||
import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
import { DATA_DIR, BASE_DIR } from './config.js'
|
||||
import { systemInfo } from './system.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
registerDriver(transmissionDriver)
|
||||
|
||||
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 app = express()
|
||||
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))
|
||||
}
|
||||
@@ -36,13 +39,18 @@ app.post('/api/auth/setup', authSetup)
|
||||
app.post('/api/auth/login', authLogin)
|
||||
app.post('/api/auth/logout', authLogout)
|
||||
|
||||
// system info
|
||||
app.get('/api/system/info', systemInfo)
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────
|
||||
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, autoImport, devMode } = req.body
|
||||
const updated = writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport, autoImport, devMode })
|
||||
logger.info('api', 'Paramètres mis à jour')
|
||||
res.json(updated)
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -55,13 +63,13 @@ app.get('/api/torrent-clients', requireAuth, (_req, res) => {
|
||||
app.post('/api/torrent-clients', requireAuth, (req, res) => {
|
||||
const { name, type, config } = req.body
|
||||
if (!name || !type || !config) { res.status(400).json({ error: 'name, type et config requis' }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
if (!getDriver(type)) { res.status(400).json({ error: `Type inconnu : ${type}` }); return }
|
||||
res.json(sanitizeClient(addClient(name, type, config)))
|
||||
})
|
||||
app.delete('/api/torrent-clients/:uuid', requireAuth, (req, res) => {
|
||||
const ok = removeClient(String(req.params.uuid))
|
||||
if (!ok) res.status(404).json({ error: 'Client introuvable' })
|
||||
else res.json({ ok: true })
|
||||
if (!ok) { res.status(404).json({ error: 'Client introuvable' }); return }
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/torrent-clients/test-config', requireAuth, async (req, res) => {
|
||||
const { type, config } = req.body
|
||||
@@ -85,40 +93,157 @@ app.get('/api/torrent-clients/:uuid/healthcheck', requireAuth, async (req, res)
|
||||
res.json(await driver.healthcheck(client.config))
|
||||
})
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
function readTorrents(): any[] {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) return []
|
||||
return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
} catch { return [] }
|
||||
// ── Cache GitHub ───────────────────────────────────────────────
|
||||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
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 cached = cacheGet(urlPath)
|
||||
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(urlPath, data)
|
||||
return data
|
||||
}
|
||||
|
||||
async function readAvailable(): Promise<number[]> {
|
||||
try { 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 }
|
||||
}
|
||||
|
||||
function extractTorrentsFromSerieData(sd: any): any[] {
|
||||
const result: any[] = []
|
||||
if (!sd) return result
|
||||
for (const t of sd.torrents ?? []) {
|
||||
result.push({ ...t, raw: t.title, type: 'pack_integrale', serie_id: sd.id, serie_title: sd.title, _serieLevel: true, _serieData: sd, _torrentIdx: (sd.torrents ?? []).indexOf(t) })
|
||||
}
|
||||
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) })
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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 ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === h) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
if (!filePath) continue
|
||||
const filename = filePath.split('/').pop() ?? filePath
|
||||
resolved.push({ episode_id: ep.id, episode_number: ep.episode_number, season_number: season.season_number, season_id: season.id, filename, original_filename: filename })
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function fankaiGet(endpoint: string): Promise<any> {
|
||||
const res = await fetch(`${FANKAI_API}${endpoint}`)
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
if (!t.serie_id) continue
|
||||
if (!map.has(t.serie_id)) map.set(t.serie_id, [])
|
||||
map.get(t.serie_id)!.push(t)
|
||||
}
|
||||
return map
|
||||
|
||||
function 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}`
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
function normalizeSerie(serie: any): any {
|
||||
return { ...serie, poster_image: imgProxy(serie.poster_image ?? serie.images?.poster ?? null, 300), fanart_image: imgProxy(serie.fanart_image ?? serie.images?.fanart ?? null, 1280, 60) }
|
||||
}
|
||||
function normalizeSeason(season: any): any {
|
||||
return { ...season, poster_image: imgProxy(season.poster_image ?? season.images?.poster ?? null, 300) }
|
||||
}
|
||||
function normalizeEpisode(ep: any): any {
|
||||
return { ...ep, thumb_image: imgProxy(ep.thumb_image ?? ep.thumbnail ?? null, 400) }
|
||||
}
|
||||
|
||||
async function loadEnrichedSeriesData(): Promise<any[]> {
|
||||
const ids = await readAvailable()
|
||||
const allSd = await Promise.all(ids.map(id => readSerieData(id)))
|
||||
return allSd.filter(Boolean)
|
||||
}
|
||||
|
||||
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'
|
||||
if (allTorrents.some(t => t.infohash && activeTorrents.has(t.infohash.toLowerCase()))) return 'downloading'
|
||||
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)
|
||||
}
|
||||
}
|
||||
for (const t of allTorrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
if (!hash) continue
|
||||
const orgFiles = organized[hash] ?? {}
|
||||
if (Object.keys(orgFiles).length === 0) continue
|
||||
for (const ep of buildResolvedEpisodes(serieData, hash, t.season_number)) {
|
||||
if (orgFiles[ep.filename]) organizedEpisodeIds.add(ep.episode_id)
|
||||
}
|
||||
}
|
||||
if (allEpisodeIds.size === 0 || organizedEpisodeIds.size === 0) return 'none'
|
||||
if (organizedEpisodeIds.size >= allEpisodeIds.size) return 'complete'
|
||||
return 'partial'
|
||||
}
|
||||
|
||||
// ── Routes série ───────────────────────────────────────────────
|
||||
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)
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const activeTorrents = new Set<string>()
|
||||
try { const { category } = readSettings(); const active = await dispatchList(category ?? 'fankai'); for (const t of active) { if (t.hash && t.state === 'downloading') activeTorrents.add(t.hash.toLowerCase()) } } catch {}
|
||||
const seriesRaw = (Array.isArray(apiData) ? apiData : (apiData.series ?? [])).map(normalizeSerie)
|
||||
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) {
|
||||
logger.error('api', `GET /api/series échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
@@ -126,187 +251,302 @@ 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 ?? [])
|
||||
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))
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try { const p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
|
||||
for (const t of torrents) {
|
||||
const ref = { torrent_url: t.torrent_url, magnet: t.magnet, type: t.type, raw: t.raw }
|
||||
for (const ep of t.resolved_episodes ?? []) {
|
||||
availableEpisodeIds.add(ep.episode_id)
|
||||
if (t.type === 'episode') episodeTorrentMap[ep.episode_id] = ref
|
||||
const episodeTorrentMap : Record<number, any> = {}
|
||||
const seasonTorrentMapBySn : Record<number, any> = {}
|
||||
const integraleTorrents : any[] = []
|
||||
const packEpisodesTorrents : any[] = []
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
if (serieData) {
|
||||
for (const t of (serieData.torrents ?? [])) {
|
||||
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) }
|
||||
}
|
||||
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 }
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
for (const t of (season.torrents ?? [])) {
|
||||
seasonTorrentMapBySn[season.season_number] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'pack_saison', raw: t.title, manual: t.manual ?? false }
|
||||
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) }
|
||||
}
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const t of (ep.torrents ?? [])) {
|
||||
episodeTorrentMap[ep.id] = { torrent_url: t.torrent_url, magnet: t.magnet, type: 'episode', raw: t.title, manual: t.manual ?? false, fankai: t.fankai ?? true }
|
||||
availableEpisodeIds.add(ep.id)
|
||||
const orgFiles = organized[t.infohash?.toLowerCase()] ?? {}
|
||||
const match = (ep.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)
|
||||
}
|
||||
}
|
||||
} else if (t.type === 'pack_integrale') {
|
||||
integraleTorrents.push(t)
|
||||
} else if (t.type === 'pack_episodes') {
|
||||
packEpisodesTorrents.push(t)
|
||||
}
|
||||
}
|
||||
|
||||
let promotedIntegrale: any = null
|
||||
if (integraleTorrents.length === 0 && allSeasonIds.size > 0) {
|
||||
for (const t of torrents.filter(t => t.type === 'pack_saison')) {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
const tIds = new Set<number>(rs.length > 0 ? rs.map((r: any) => r.season_id) : t.season_id ? [t.season_id] : [])
|
||||
if ([...allSeasonIds].every(sid => tIds.has(sid))) { promotedIntegrale = t; break }
|
||||
}
|
||||
}
|
||||
|
||||
if (integraleTorrents.length > 0 || promotedIntegrale) {
|
||||
for (const key of Object.keys(seasonTorrentMap)) delete seasonTorrentMap[Number(key)]
|
||||
if (promotedIntegrale) {
|
||||
const idx = packEpisodesTorrents.indexOf(promotedIntegrale)
|
||||
if (idx !== -1) packEpisodesTorrents.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function packEpisodesLabel(t: any): string {
|
||||
const eps: any[] = t.resolved_episodes ?? []
|
||||
if (!eps.length) return t.raw
|
||||
const bySeason = new Map<number, number[]>()
|
||||
for (const ep of eps) {
|
||||
const sn = ep.season_number ?? 0
|
||||
if (!bySeason.has(sn)) bySeason.set(sn, [])
|
||||
bySeason.get(sn)!.push(ep.episode_number)
|
||||
}
|
||||
return [...bySeason.entries()].sort(([a], [b]) => a - b).map(([sn, nums]) => {
|
||||
const s = nums.sort((a, b) => a - b)
|
||||
const label = sn === 0 ? 'S0' : `S${sn}`
|
||||
return s[0] === s[s.length-1] ? `${label} Ep ${s[0]}` : `${label} Eps ${s[0]}-${s[s.length-1]}`
|
||||
}).join(' · ')
|
||||
}
|
||||
|
||||
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 }))
|
||||
]
|
||||
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: heroBundles })
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => {
|
||||
const epTorrent = episodeTorrentMap[ep.id] ?? null
|
||||
let fankai: boolean | null = epTorrent ? (epTorrent.fankai ?? true) : null
|
||||
if (fankai === null && serieData) {
|
||||
outer: for (const sd_season of serieData.seasons ?? []) {
|
||||
for (const sdEp of sd_season.episodes ?? []) {
|
||||
if (sdEp.id !== ep.id) continue
|
||||
for (const p of sdEp.paths ?? []) {
|
||||
const ih = typeof p === 'object' ? p.infohash?.toLowerCase() : null
|
||||
if (!ih) continue
|
||||
for (const t of serieData.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } }
|
||||
for (const s of serieData.seasons ?? []) { for (const t of s.torrents ?? []) { if (t.infohash?.toLowerCase() === ih) { fankai = t.fankai ?? true; break outer } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...ep, available: availableEpisodeIds.has(ep.id), torrent: epTorrent ? { ...epTorrent, fankai: fankai ?? true } : null, fankai, organized: organizedEpisodeIds.has(ep.id) }
|
||||
})
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
const orgCount = eps.filter((e: any) => e.organized).length
|
||||
return { ...season, torrent: seasonTorrentMapBySn[season.season_number] ?? null, organized_state: orgCount === 0 ? 'none' : orgCount >= total && total > 0 ? 'complete' : 'partial', organized_count: orgCount, episodes: eps }
|
||||
})
|
||||
res.json({ serie, seasons: enrichedSeasons, torrents_integrale: integraleTorrents.map(t => ({ label: 'Intégrale', torrent_url: t.torrent_url, magnet: t.magnet, raw: t.title ?? t.raw })) })
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/series/${id} échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Download ───────────────────────────────────────────────────
|
||||
app.post('/api/download', requireAuth, async (req, res) => {
|
||||
const url = req.body.torrent_url ?? req.body.magnet
|
||||
const url = req.body.magnet ?? req.body.torrent_url
|
||||
if (!url) { res.status(400).json({ error: 'torrent_url ou magnet requis' }); return }
|
||||
logger.info('api', 'Téléchargement demandé')
|
||||
const results = await dispatchDownload(url)
|
||||
res.status(results.every((r: any) => r.ok) ? 200 : 207).json({ results })
|
||||
})
|
||||
|
||||
// ── Downloads — liste torrents Fankai ──────────────────────────
|
||||
// ── Downloads list ─────────────────────────────────────────────
|
||||
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 p = path.join(DATA_DIR, 'organized.json'); if (fs.existsSync(p)) organized = JSON.parse(fs.readFileSync(p, 'utf-8')) } catch {}
|
||||
const enriched = await Promise.all(torrents.map(async (t: any) => {
|
||||
const orgFiles = organized[t.hash] ?? {}
|
||||
let totalFiles = 1
|
||||
try {
|
||||
const availableIds = await readAvailable()
|
||||
for (const id of availableIds) {
|
||||
const sd = await readSerieData(id)
|
||||
if (!sd) continue
|
||||
const match = extractTorrentsFromSerieData(sd).find((st: any) => st.infohash?.toLowerCase() === t.hash?.toLowerCase())
|
||||
if (match) { totalFiles = buildResolvedEpisodes(sd, match.infohash, match.season_number).length || 1; break }
|
||||
}
|
||||
} catch {}
|
||||
const doneFiles = Object.keys(orgFiles).length
|
||||
const organizeState: 'none' | 'partial' | 'done' = doneFiles >= totalFiles ? 'done' : doneFiles > 0 ? 'partial' : 'none'
|
||||
const notif = recentOrganized.find(n => n.hash === t.hash)
|
||||
return { ...t, organizeState, organizeProgress: { done: doneFiles, total: totalFiles }, errorFiles: notif?.errorFiles ?? [] }
|
||||
}))
|
||||
res.json(enriched)
|
||||
} catch (err) {
|
||||
logger.error('api', `GET /api/downloads échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Organize ───────────────────────────────────────────────────
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) {
|
||||
res.status(400).json({ error: 'hash, name et save_path requis' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await organizeTorrent(hash, name, save_path)
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
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
|
||||
|
||||
// ── Torrents status ────────────────────────────────────────────
|
||||
app.get('/api/torrents/status', requireAuth, (_req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(TORRENTS_PATH)) { res.json({ exists: false, count: 0, empty: true }); return }
|
||||
const raw = fs.readFileSync(TORRENTS_PATH, 'utf-8').trim()
|
||||
if (!raw || raw === '[]') { res.json({ exists: true, count: 0, empty: true }); return }
|
||||
const data = JSON.parse(raw)
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
res.json({ exists: true, count, empty: count === 0 })
|
||||
} catch { res.json({ exists: false, count: 0, empty: true }) }
|
||||
})
|
||||
|
||||
// ── Update torrent_final.json ──────────────────────────────────
|
||||
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 })
|
||||
} 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 ─
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_PATH, 'index.html'))
|
||||
})
|
||||
function pushNotif(n: OrganizeNotif) {
|
||||
recentOrganized.unshift(n)
|
||||
if (recentOrganized.length > MAX_NOTIFS) recentOrganized.pop()
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
app.get('/api/organize/recent', requireAuth, (_req, res) => {
|
||||
res.json(recentOrganized)
|
||||
})
|
||||
app.post('/api/organize/recent/clear', requireAuth, (_req, res) => {
|
||||
recentOrganized.length = 0
|
||||
logger.info('api', 'Historique des imports effacé')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
app.post('/api/organize', requireAuth, async (req, res) => {
|
||||
const { hash, name, save_path } = req.body
|
||||
if (!hash || !name || !save_path) { res.status(400).json({ error: 'hash, name et save_path requis' }); return }
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
if (count === 0) console.warn('[fankarr] ⚠ torrent_final.json est vide')
|
||||
else console.log(`[fankarr] ${count} torrents chargés`)
|
||||
} catch { console.warn('[fankarr] ⚠ torrent_final.json introuvable ou illisible') }
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await organizeTorrent(hash, name, save_path, seriesData)
|
||||
if (result.done > 0 || result.errors.length > 0) {
|
||||
pushNotif({ hash, name, done: result.done, skipped: result.skipped, errors: result.errors.length, errorFiles: result.errors, at: new Date().toISOString() })
|
||||
}
|
||||
res.json(result)
|
||||
} catch (err) {
|
||||
logger.error('api', `Import manuel de "${name}" échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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
|
||||
res.json({ entries: readLogs({ limit, level: level as any, source }), size: logsFileSize() })
|
||||
})
|
||||
app.post('/api/logs/clear', requireAuth, (_req, res) => {
|
||||
clearLogs()
|
||||
logger.info('api', 'Logs effacés')
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ── Système ────────────────────────────────────────────────────
|
||||
app.get('/api/system', requireAuth, (_req, res) => {
|
||||
res.json({ isDocker: fs.existsSync('/.dockerenv') })
|
||||
})
|
||||
app.get('/api/browse', requireAuth, (req, res) => {
|
||||
const dirPath = (req.query.path as string) || '/'
|
||||
try {
|
||||
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))
|
||||
res.json({ path: dirPath, parent: dirPath === '/' ? null : path.dirname(dirPath), dirs })
|
||||
} catch (err) {
|
||||
logger.warn('api', `Lecture dossier "${dirPath}" impossible : ${err instanceof Error ? err.message : 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 }) }
|
||||
})
|
||||
app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
cacheClear()
|
||||
logger.info('api', 'Cache GitHub vidé — rechargement forcé')
|
||||
const availableIds = await readAvailable()
|
||||
if (!Array.isArray(availableIds)) throw new Error('available.json invalide')
|
||||
logger.info('api', `Catalogue rechargé — ${availableIds.length} séries disponibles`)
|
||||
res.json({ ok: true, count: availableIds.length })
|
||||
} catch (err) {
|
||||
logger.error('api', `Mise à jour catalogue échouée : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
logger.info('api', 'Scan médiathèque manuel lancé')
|
||||
const seriesData = await loadEnrichedSeriesData()
|
||||
const result = await scanMediaPath(mediaPath, path.join(DATA_DIR, 'organized.json'), seriesData)
|
||||
res.json({ ok: true, ...result })
|
||||
} catch (err) {
|
||||
logger.error('api', `Scan médiathèque échoué : ${err instanceof Error ? err.message : err}`)
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Erreur inconnue' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Debug (dev mode uniquement) ───────────────────────────────
|
||||
app.get('/api/debug/stats', requireAuth, (req, res) => {
|
||||
const { devMode } = readSettings()
|
||||
if (!devMode) { res.status(403).json({ error: 'Dev mode désactivé' }); return }
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
const uptimeS = Math.floor(process.uptime())
|
||||
const h = Math.floor(uptimeS / 3600)
|
||||
const m = Math.floor((uptimeS % 3600) / 60)
|
||||
const s = uptimeS % 60
|
||||
|
||||
let organizedCount = 0
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath)) {
|
||||
const org = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
organizedCount = Object.values(org).reduce((acc: number, files: any) => acc + Object.keys(files).length, 0)
|
||||
}
|
||||
} catch {}
|
||||
|
||||
res.json({
|
||||
memory: {
|
||||
heapUsed : Math.round(mem.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
|
||||
rss : Math.round(mem.rss / 1024 / 1024),
|
||||
},
|
||||
cache: {
|
||||
entries : _cache.size,
|
||||
ttlHours : 6,
|
||||
},
|
||||
uptime: `${h}h ${m}m ${s}s`,
|
||||
uptimeSeconds: uptimeS,
|
||||
worker: {
|
||||
running: workerRunning,
|
||||
},
|
||||
organized: {
|
||||
trackedFiles: organizedCount,
|
||||
},
|
||||
requests: {
|
||||
notifs: recentOrganized.length,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.get('*path', (_req, res) => { res.sendFile(path.join(PUBLIC_PATH, 'index.html')) })
|
||||
}
|
||||
|
||||
// ── Démarrage ──────────────────────────────────────────────────
|
||||
app.listen(PORT, async () => {
|
||||
logger.info('api', `Serveur démarré sur le port ${PORT}`)
|
||||
|
||||
try {
|
||||
const available = await readAvailable()
|
||||
logger.info('api', `Cache GitHub initialisé — ${available.length} séries disponibles`)
|
||||
} catch (err) {
|
||||
logger.warn('api', `Impossible de charger available.json au démarrage : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
loadEnrichedSeriesData()
|
||||
.then(seriesData => scanMediaPath(mediaPath, ORGANIZED_PATH, seriesData))
|
||||
.catch(err => logger.error('api', `Scan initial échoué : ${err instanceof Error ? err.message : err}`))
|
||||
|
||||
// Auto-organise toutes les 30s
|
||||
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)
|
||||
logger.error('api', `Auto-organise échoué : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
// Premier run 30s après démarrage, puis toutes les 30s
|
||||
|
||||
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,431 @@
|
||||
/**
|
||||
* organize-worker.ts
|
||||
* ==================
|
||||
* Worker thread dédié à l'organisation des fichiers.
|
||||
*/
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
// Les messages sont routés vers logger via organize.ts — pas besoin de préfixe
|
||||
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 ───────────────────────
|
||||
function findTorrentByHash(hash: string, seriesData: any[]): { torrent: any; serieData: any } | null {
|
||||
const h = hash.toLowerCase()
|
||||
for (const sd of seriesData) {
|
||||
// Niveau série (pack intégrale)
|
||||
for (const t of sd.torrents ?? []) {
|
||||
if (t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
// Niveau saison — vérifier si le même hash apparaît dans plusieurs saisons
|
||||
// Si oui, pas de _season pour que buildFileMap scanne toutes les saisons
|
||||
const seasonsWithHash = (sd.seasons ?? []).filter((s: any) =>
|
||||
s.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
)
|
||||
if (seasonsWithHash.length === 1) {
|
||||
// Torrent propre à une seule saison → on filtre sur cette saison
|
||||
const season = seasonsWithHash[0]
|
||||
const t = season.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
} else if (seasonsWithHash.length > 1) {
|
||||
// Même torrent dans plusieurs saisons → pas de filtre saison
|
||||
const t = seasonsWithHash[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: t, serieData: sd }
|
||||
}
|
||||
|
||||
// Niveau épisode
|
||||
for (const season of sd.seasons ?? []) {
|
||||
const matchingEpisodes = season.episodes?.filter((ep: any) =>
|
||||
ep.torrents?.some((t: any) => t.infohash?.toLowerCase() === h)
|
||||
) ?? []
|
||||
|
||||
if (matchingEpisodes.length === 1) {
|
||||
const ep = matchingEpisodes[0]
|
||||
const t = ep.torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _episode: ep, _season: season }, serieData: sd }
|
||||
} else if (matchingEpisodes.length > 1) {
|
||||
const t = matchingEpisodes[0].torrents.find((t: any) => t.infohash?.toLowerCase() === h)
|
||||
return { torrent: { ...t, _season: season }, serieData: sd }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildFileMap(
|
||||
serieData : any,
|
||||
hash : string,
|
||||
nfoSupport : boolean,
|
||||
seasonFilter?: number,
|
||||
): Map<string, { season_number: number; original_filename: string; fullPath: string }> {
|
||||
const map = new Map<string, { season_number: number; original_filename: string; fullPath: string }>()
|
||||
const h = hash.toLowerCase()
|
||||
|
||||
for (const season of serieData.seasons ?? []) {
|
||||
if (seasonFilter !== undefined && season.season_number !== seasonFilter) continue
|
||||
for (const ep of season.episodes ?? []) {
|
||||
const paths: any[] = ep.paths ?? []
|
||||
let matchedPath: string | null = null
|
||||
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') {
|
||||
if (!matchedPath) matchedPath = p.replace(/\\/g, '/')
|
||||
} else if (p?.infohash?.toLowerCase() === h) {
|
||||
matchedPath = p.path.replace(/\\/g, '/')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedPath) continue
|
||||
const filename = matchedPath.split('/').pop() ?? matchedPath
|
||||
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
|
||||
if (!nfoSupport && !fmtName) {
|
||||
warn(`formatted_name manquant pour épisode ${ep.id} (S${season.season_number}E${ep.episode_number}) — fallback original_filename`)
|
||||
}
|
||||
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
|
||||
map.set(filename, {
|
||||
season_number : season.season_number,
|
||||
original_filename: destName,
|
||||
fullPath : matchedPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ─── Dossiers exclus ──────────────────────────────────────────
|
||||
const EXCLUDED_FOLDERS = new Set([
|
||||
'endings', 'ending', 'openings', 'opening', 'ost', 'artworks', 'artwork',
|
||||
'bonus', 'extras', 'extra', 'specials', 'special', 'ncop', 'nced',
|
||||
'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
|
||||
}
|
||||
|
||||
// ─── Map titres API → GitLab ──────────────────────────────────
|
||||
const SERIE_TITLE_GITLAB_MAP: Record<string, string> = {
|
||||
'Enfer Et Paradis Henshū' : 'Enfer et Paradis Henshū',
|
||||
'Hajime No Ippo Henshū' : 'Hajime no Ippo Henshū',
|
||||
'Hikaru No Go Henshū' : 'Hikaru no Go Henshū',
|
||||
'Hokuto No Ken Kaï' : 'Hokuto no Ken Kaï',
|
||||
'Kaguya-sama : Love is War Henshū' : 'Kaguya-sama - Love is War Henshū',
|
||||
'Kenshin le Vagabond Henshū' : 'Kenshin le vagabond Henshū',
|
||||
'Kuroko No Basket Henshū' : 'Kuroko no Basket Henshū',
|
||||
'Shingeki No Kyojin Henshū' : 'Shingeki no Kyojin Henshū',
|
||||
'Tower Of God Henshū' : 'Tower of God Henshū',
|
||||
}
|
||||
|
||||
function getGitlabSerieTitle(serieTitle: string): string {
|
||||
return SERIE_TITLE_GITLAB_MAP[serieTitle] ?? serieTitle
|
||||
}
|
||||
|
||||
// ─── GitLab NFO downloader ────────────────────────────────────
|
||||
const GITLAB_API = 'https://gitlab.com/api/v4/projects/ElPouki%2Ffankai_pack/repository'
|
||||
const GITLAB_RAW_BASE = 'https://gitlab.com/ElPouki/fankai_pack/-/raw/main/pack'
|
||||
|
||||
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}`)
|
||||
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}`)
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
}
|
||||
|
||||
async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promise<void> {
|
||||
const gitlabTitle = getGitlabSerieTitle(serieTitle)
|
||||
const folderPath = `pack/${gitlabTitle}`
|
||||
log(`Téléchargement NFO/images depuis GitLab pour "${gitlabTitle}"`)
|
||||
|
||||
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(`Dossier GitLab introuvable pour "${gitlabTitle}" : ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (files.length === 0) { warn(`Aucun fichier NFO trouvé pour "${gitlabTitle}"`); return }
|
||||
|
||||
const fileEntries = files.filter((f: any) => f.type === 'blob')
|
||||
log(`${fileEntries.length} fichiers NFO/images à télécharger pour "${gitlabTitle}"`)
|
||||
|
||||
let downloaded = 0, skipped = 0, failed = 0
|
||||
for (const entry of fileEntries) {
|
||||
const relativePath = entry.path.replace(`pack/${gitlabTitle}/`, '')
|
||||
const destPath = path.join(destRoot, relativePath)
|
||||
if (fs.existsSync(destPath)) { skipped++; continue }
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const rawUrl = `${GITLAB_RAW_BASE}/${encodeURIComponent(gitlabTitle)}/${relativePath.split('/').map(encodeURIComponent).join('/')}`
|
||||
const data = await fetchBinary(rawUrl)
|
||||
fs.writeFileSync(destPath, data)
|
||||
downloaded++
|
||||
} catch (err) {
|
||||
warn(`Échec téléchargement NFO "${relativePath}" : ${err instanceof Error ? err.message : err}`)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
log(`NFO "${gitlabTitle}" — ${downloaded} téléchargés, ${skipped} déjà présents${failed > 0 ? `, ${failed} échecs` : ''}`)
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
debug(`Hardlink échoué (${err instanceof Error ? err.message : err}), fallback copie`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeDirName(name: string): string {
|
||||
return name
|
||||
.replace(/:/g, ' -')
|
||||
.replace(/[<>"/\\|?*]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// ─── 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 }[] }
|
||||
|
||||
const found = findTorrentByHash(hash, seriesData)
|
||||
if (!found) {
|
||||
error(`Hash ${hash} introuvable dans les données série`)
|
||||
throw new Error(`Torrent introuvable : ${hash}`)
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const rawTitle = serieData.title ?? serieData.show_title ?? name
|
||||
const serieTitle = sanitizeDirName(rawTitle)
|
||||
|
||||
debug(`Série identifiée : "${serieTitle}" (mode: ${organizeMode}${nfoSupport ? ', NFO' : ''})`)
|
||||
|
||||
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 paths: any[] = ep.paths ?? []
|
||||
let filePath: string | null = null
|
||||
for (const p of paths) {
|
||||
if (typeof p === 'string') { if (!filePath) filePath = p.replace(/\\/g, '/') }
|
||||
else if (p?.infohash?.toLowerCase() === hash.toLowerCase()) { filePath = p.path.replace(/\\/g, '/'); break }
|
||||
}
|
||||
const filename = filePath?.split('/').pop() ?? name
|
||||
const fmtName = ep.formatted_name?.trim()
|
||||
? ep.formatted_name.replace(/[<>:"/\\|?*]/g, '') + '.mkv'
|
||||
: null
|
||||
const destName = nfoSupport
|
||||
? (ep.original_filename ?? filename)
|
||||
: (fmtName ?? ep.original_filename ?? filename)
|
||||
const destDir = path.join(mediaPath, serieTitle, seasonFolder(season.season_number ?? 1))
|
||||
const dest = path.join(destDir, destName)
|
||||
|
||||
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(`Fichier source introuvable : ${filename}`)
|
||||
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(`${destName} → 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, nfoSupport, seasonFilter)
|
||||
|
||||
if (fileMap.size === 0) {
|
||||
warn(`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 }
|
||||
|
||||
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(`Fichier source introuvable : ${filename}`)
|
||||
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++
|
||||
debug(`${original_filename} → Saison ${season_number}`)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
error(`Échec import "${filename}" : ${msg}`)
|
||||
result.errors.push({ file: filename, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Boucle principale ────────────────────────────────────────
|
||||
parentPort?.on('message', async (msg: any) => {
|
||||
if (msg.type !== 'run') return
|
||||
|
||||
const torrents: any[] = msg.torrents
|
||||
const seriesData: any[] = msg.seriesData ?? []
|
||||
const organized = loadOrganized()
|
||||
const { nfoSupport } = readSettings()
|
||||
|
||||
for (const t of torrents) {
|
||||
if (t.state !== 'seeding') continue
|
||||
|
||||
const found = findTorrentByHash(t.hash, seriesData)
|
||||
if (!found) {
|
||||
debug(`Torrent "${t.name}" (${t.hash.slice(0, 8)}…) non trouvé dans les données série — catalogue peut-être pas à jour`)
|
||||
continue
|
||||
}
|
||||
|
||||
const { torrent, serieData } = found
|
||||
const seasonFilter = torrent._season?.season_number
|
||||
const fileMap = buildFileMap(serieData, t.hash, nfoSupport, seasonFilter)
|
||||
const allDone = fileMap.size > 0
|
||||
? [...fileMap.keys()].every(f => organized[t.hash]?.[f])
|
||||
: !!organized[t.hash]?.[t.name]
|
||||
|
||||
if (allDone) {
|
||||
debug(`"${t.name}" déjà entièrement importé — skip`)
|
||||
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(`Erreur lors de l'import de "${t.name}" : ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
parentPort?.postMessage({ type: 'done' })
|
||||
})
|
||||
+230
-208
@@ -1,84 +1,28 @@
|
||||
/**
|
||||
* 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 { Worker } from 'worker_threads'
|
||||
import { logger } from './logger.js'
|
||||
import { DATA_DIR } from './config.js'
|
||||
import { readSettings } from './settings.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 ────────────────────────────────────────────
|
||||
export let workerRunning = false
|
||||
const _prevStates = new Map<string, string>()
|
||||
|
||||
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 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 +30,241 @@ export interface OrganizeResult {
|
||||
errors : { file: string; error: string }[]
|
||||
}
|
||||
|
||||
// ─── Organise un torrent ──────────────────────────────────────
|
||||
// ── Scan initial ──────────────────────────────────────────────
|
||||
|
||||
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
|
||||
export async function scanMediaPath(
|
||||
mediaPath : string,
|
||||
organizedPath: string,
|
||||
seriesData : any[] = []
|
||||
): Promise<{ found: number; added: number }> {
|
||||
const result = { found: 0, added: 0 }
|
||||
|
||||
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', `Dossier médiathèque introuvable : ${mediaPath}`)
|
||||
return result
|
||||
}
|
||||
|
||||
const serieTitle = torrent.serie_title || torrent.show_title || name
|
||||
const torrentFiles: any[] = torrent.torrent_files ?? []
|
||||
logger.info('organize', `Scan de la médiathèque : ${mediaPath}`)
|
||||
|
||||
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)
|
||||
const filenameIndex = new Map<string, string>()
|
||||
for (const sd of seriesData) {
|
||||
for (const season of sd.seasons ?? []) {
|
||||
for (const ep of season.episodes ?? []) {
|
||||
for (const p of ep.paths ?? []) {
|
||||
if (typeof p === 'string') continue
|
||||
const hash = p.infohash?.toLowerCase()
|
||||
if (!hash || !p.path) continue
|
||||
const filename = p.path.replace(/\\/g, '/').split('/').pop()
|
||||
if (filename) filenameIndex.set(filename, hash)
|
||||
}
|
||||
} 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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', `Scan terminé — ${result.found} fichiers, ${result.added} ajoutés, ${removed} orphelins supprimés`)
|
||||
} else {
|
||||
logger.info('organize', `Scan terminé — ${result.found} fichiers, 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-import ───────────────────────────────────────────────
|
||||
|
||||
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> {
|
||||
const { autoImport } = readSettings()
|
||||
if (!autoImport) {
|
||||
logger.debug('organize', 'Import automatique désactivé — skip')
|
||||
return
|
||||
}
|
||||
|
||||
if (workerRunning) {
|
||||
logger.debug('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) {
|
||||
logger.error('organize', `Impossible de récupérer la liste des torrents : ${err instanceof Error ? err.message : 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)
|
||||
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
const hasUnorganized = torrents.some(t =>
|
||||
t.state === 'seeding' && !organized[t.hash?.toLowerCase()]
|
||||
)
|
||||
|
||||
if (!isFirstRun && newlySeeding.length === 0 && !hasUnorganized) return
|
||||
if (seedingCount === 0) return
|
||||
|
||||
if (newlySeeding.length > 0 && !isFirstRun) {
|
||||
logger.info('organize', `${newlySeeding.length} torrent(s) terminé(s) → lancement de l'import automatique`)
|
||||
for (const t of newlySeeding) {
|
||||
logger.debug('organize', `Nouveau seeding : "${t.name}"`)
|
||||
}
|
||||
} else {
|
||||
logger.debug('organize', `Lancement worker (${seedingCount} torrents en seeding, vérification initiale)`)
|
||||
}
|
||||
|
||||
workerRunning = true
|
||||
const worker = new Worker(resolveWorkerPath())
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `"${msg.name}" — ${msg.done} importé(s), ${msg.skipped} skippé(s), ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
|
||||
}
|
||||
} else if (msg.done > 0) {
|
||||
logger.info('organize', `"${msg.name}" — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
} else {
|
||||
logger.debug('organize', `"${msg.name}" — rien à importer (${msg.skipped} skippé(s))`)
|
||||
}
|
||||
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') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur : ${err.message}`)
|
||||
workerRunning = false
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) logger.error('organize', `Worker exit inattendu avec code ${code}`)
|
||||
else logger.debug('organize', `Worker terminé (code ${code})`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.postMessage({ type: 'run', torrents, seriesData })
|
||||
}
|
||||
|
||||
// ── Import manuel ─────────────────────────────────────────────
|
||||
|
||||
export async function organizeTorrent(
|
||||
hash : string,
|
||||
name : string,
|
||||
savePath : string,
|
||||
seriesData: any[]
|
||||
): Promise<OrganizeResult> {
|
||||
logger.info('organize', `Import manuel lancé pour "${name}" (${hash})`)
|
||||
|
||||
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') {
|
||||
logger[msg.level as 'info' | 'warn' | 'error' | 'debug']?.('organize-worker', msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
result.done = msg.done
|
||||
result.errors = msg.errors
|
||||
if (msg.errors.length > 0) {
|
||||
logger.warn('organize', `Import "${name}" — ${msg.done} OK, ${msg.errors.length} erreur(s)`)
|
||||
for (const e of msg.errors) {
|
||||
logger.error('organize', `Erreur sur "${e.file}" : ${e.error}`)
|
||||
}
|
||||
} else {
|
||||
logger.info('organize', `Import "${name}" terminé — ${msg.done} fichier(s) importé(s), ${msg.skipped} skippé(s)`)
|
||||
}
|
||||
} else if (msg.type === 'done') {
|
||||
worker.terminate()
|
||||
resolve(result)
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur lors de l'import de "${name}" : ${err.message}`)
|
||||
worker.terminate()
|
||||
reject(err)
|
||||
})
|
||||
worker.on('exit', (code) => {
|
||||
if (code > 1) reject(new Error(`Worker exit inattendu: code ${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()
|
||||
+16
-9
@@ -1,20 +1,27 @@
|
||||
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
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string // catégorie qBittorrent
|
||||
mediaPath : string
|
||||
completePath : string
|
||||
organizeMode : 'hardlink' | 'move'
|
||||
category : string
|
||||
nfoSupport : boolean
|
||||
autoImport : boolean
|
||||
devMode : boolean
|
||||
}
|
||||
|
||||
const defaults: Settings = {
|
||||
mediaPath : '/media/Kai',
|
||||
completePath : '/downloads/complete',
|
||||
organizeMode : 'hardlink',
|
||||
category : 'fankai',
|
||||
mediaPath : '',
|
||||
completePath: '',
|
||||
organizeMode: 'hardlink',
|
||||
category : 'fankai',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
devMode : false,
|
||||
}
|
||||
|
||||
export function readSettings(): Settings {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import type { Request, Response } from 'express'
|
||||
|
||||
// GET /api/system/info
|
||||
export function systemInfo(_req: Request, res: Response): void {
|
||||
const isTermux = fs.existsSync('/data/data/com.termux')
|
||||
const platform = os.platform()
|
||||
|
||||
res.json({
|
||||
platform,
|
||||
isTermux,
|
||||
defaultPath: isTermux ? '/sdcard/' : '/',
|
||||
})
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
* Torrent Client Registry
|
||||
* =======================
|
||||
* Interface commune + registry de tous les clients disponibles.
|
||||
* Chaque client expose son schema de configuration (FieldDef[])
|
||||
* pour que le frontend génère le formulaire dynamiquement.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { DATA_DIR } from '../config.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,36 +21,32 @@ export interface FieldDef {
|
||||
default? : string | number
|
||||
}
|
||||
|
||||
/** Définition statique d'un type de client (ce qu'il expose au front) */
|
||||
export interface TorrentClientDefinition {
|
||||
id : string // "qbittorrent", "transmission", etc.
|
||||
label : string // "qBittorrent"
|
||||
fields : FieldDef[] // schema du formulaire
|
||||
id : string
|
||||
label : string
|
||||
fields : FieldDef[]
|
||||
}
|
||||
|
||||
/** Instance sauvegardée d'un client configuré par l'utilisateur */
|
||||
export interface SavedClient {
|
||||
uuid : string // identifiant unique de cette instance
|
||||
name : string // nom custom donné par l'utilisateur
|
||||
type : string // id du TorrentClientDefinition
|
||||
uuid : string
|
||||
name : string
|
||||
type : string
|
||||
config: Record<string, string | number>
|
||||
}
|
||||
|
||||
/** État normalisé d'un torrent (commun à tous les clients) */
|
||||
export interface TorrentInfo {
|
||||
hash : string
|
||||
name : string
|
||||
state : 'downloading' | 'seeding' | 'paused' | 'checking' | 'error' | 'unknown'
|
||||
progress : number // 0-100
|
||||
size : number // bytes
|
||||
downloaded: number // bytes
|
||||
speed : number // bytes/s
|
||||
eta : number // secondes, -1 si inconnu
|
||||
save_path : string // dossier de complétion
|
||||
progress : number
|
||||
size : number
|
||||
downloaded: number
|
||||
speed : number
|
||||
eta : number
|
||||
save_path : string
|
||||
category : string
|
||||
}
|
||||
|
||||
/** Interface que chaque client doit implémenter */
|
||||
export interface TorrentClientDriver {
|
||||
definition : TorrentClientDefinition
|
||||
test : (config: Record<string, string | number>) => Promise<{ ok: boolean; message: string }>
|
||||
@@ -65,6 +61,7 @@ const drivers = new Map<string, TorrentClientDriver>()
|
||||
|
||||
export function registerDriver(driver: TorrentClientDriver) {
|
||||
drivers.set(driver.definition.id, driver)
|
||||
logger.debug('torrent-clients', `Driver enregistré : ${driver.definition.label}`)
|
||||
}
|
||||
|
||||
export function getDriver(type: string): TorrentClientDriver | undefined {
|
||||
@@ -75,9 +72,9 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
return [...drivers.values()].map(d => d.definition)
|
||||
}
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
// ─── Persistence ──────────────────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
@@ -102,14 +99,17 @@ export function addClient(name: string, type: string, config: Record<string, str
|
||||
const client: SavedClient = { uuid: randomUUID(), name, type, config }
|
||||
clients.push(client)
|
||||
saveClients(clients)
|
||||
logger.info('torrent-clients', `Client ajouté : "${name}" (${type})`)
|
||||
return client
|
||||
}
|
||||
|
||||
export function removeClient(uuid: string): boolean {
|
||||
const clients = loadClients()
|
||||
const clients = loadClients()
|
||||
const target = clients.find(c => c.uuid === uuid)
|
||||
const filtered = clients.filter(c => c.uuid !== uuid)
|
||||
if (filtered.length === clients.length) return false
|
||||
saveClients(filtered)
|
||||
logger.info('torrent-clients', `Client supprimé : "${target?.name}" (${target?.type})`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -117,7 +117,6 @@ export function getClient(uuid: string): SavedClient | undefined {
|
||||
return loadClients().find(c => c.uuid === uuid)
|
||||
}
|
||||
|
||||
/** Masque les champs password pour l'envoi au front */
|
||||
export function sanitizeClient(client: SavedClient, definition?: TorrentClientDefinition): SavedClient {
|
||||
if (!definition) definition = getDriver(client.type)?.definition
|
||||
if (!definition) return client
|
||||
@@ -130,35 +129,40 @@ export function sanitizeClient(client: SavedClient, definition?: TorrentClientDe
|
||||
return { ...client, config }
|
||||
}
|
||||
|
||||
// ─── Dispatch download vers tous les clients ──────────────────────────────────
|
||||
// ─── Dispatch download ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchDownload(url: string): Promise<{ uuid: string; name: string; ok: boolean; error?: string }[]> {
|
||||
const clients = loadClients()
|
||||
|
||||
if (clients.length === 0) {
|
||||
logger.warn('torrent-clients', 'Aucun client configuré — téléchargement impossible')
|
||||
return []
|
||||
}
|
||||
|
||||
const results = []
|
||||
|
||||
for (const client of clients) {
|
||||
const driver = getDriver(client.type)
|
||||
if (!driver) {
|
||||
logger.error('torrent-clients', `Driver "${client.type}" introuvable pour le client "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: `Driver "${client.type}" introuvable` })
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await driver.add(client.config, url)
|
||||
logger.info('torrent-clients', `Téléchargement envoyé à "${client.name}"`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: true })
|
||||
} catch (err) {
|
||||
results.push({
|
||||
uuid : client.uuid,
|
||||
name : client.name,
|
||||
ok : false,
|
||||
error: err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
})
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Échec envoi vers "${client.name}" : ${msg}`)
|
||||
results.push({ uuid: client.uuid, name: client.name, ok: false, error: msg })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ─── Dispatch list vers tous les clients ─────────────────────────────────────
|
||||
// ─── Dispatch list ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function dispatchList(category?: string): Promise<(TorrentInfo & { client_uuid: string; client_name: string })[]> {
|
||||
const clients = loadClients()
|
||||
@@ -168,12 +172,14 @@ 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)
|
||||
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 })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[torrent-clients] list() échoué pour ${client.name}:`, err)
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.error('torrent-clients', `Impossible de récupérer la liste depuis "${client.name}" : ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
// Mapping état qBittorrent → état normalisé
|
||||
function mapState(state: string): TorrentInfo['state'] {
|
||||
if (['downloading', 'metaDL', 'queuedDL', 'stalledDL', 'forcedDL'].includes(state)) return 'downloading'
|
||||
if (['uploading', 'queuedUP', 'stalledUP', 'forcedUP'].includes(state)) return 'seeding'
|
||||
@@ -29,7 +29,7 @@ async function qbLogin(config: Record<string, string | number>): Promise<string>
|
||||
if (text !== 'Ok.') throw new Error(`Login échoué : ${text}`)
|
||||
|
||||
const cookie = res.headers.get('set-cookie') ?? ''
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
const sid = cookie.match(/SID=([^;]+)/)?.[1]
|
||||
if (!sid) throw new Error('Cookie SID introuvable')
|
||||
return sid
|
||||
}
|
||||
@@ -39,19 +39,22 @@ const QB: TorrentClientDriver = {
|
||||
id : 'qbittorrent',
|
||||
label : 'qBittorrent',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
{ key: 'url', label: 'URL WebUI', type: 'url', placeholder: 'http://localhost:8080', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'admin', required: true },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: true },
|
||||
{ key: 'category', label: 'Catégorie', type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await qbLogin(config)
|
||||
logger.info('qbittorrent', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
return { ok: false, message: err instanceof Error ? err.message : 'Erreur inconnue' }
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('qbittorrent', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,23 +62,26 @@ const QB: TorrentClientDriver = {
|
||||
try {
|
||||
const sid = await qbLogin(config)
|
||||
const res = await fetch(`${config.url}/api/v2/app/version`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
if (!res.ok) return { online: false }
|
||||
return { online: true, version: (await res.text()).trim() }
|
||||
} catch {
|
||||
const version = (await res.text()).trim()
|
||||
logger.debug('qbittorrent', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('qbittorrent', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const sid = await qbLogin(config)
|
||||
const sid = await qbLogin(config)
|
||||
const params = new URLSearchParams()
|
||||
if (category) params.set('category', category)
|
||||
const res = await fetch(`${config.url}/api/v2/torrents/info?${params}`, {
|
||||
headers: { Cookie: `SID=${sid}` }
|
||||
headers: { Cookie: `SID=${sid}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`qB list échoué: ${res.status}`)
|
||||
if (!res.ok) throw new Error(`qB list échoué : ${res.status}`)
|
||||
const data: any[] = await res.json()
|
||||
return data.map(t => ({
|
||||
hash : t.hash,
|
||||
@@ -92,7 +98,7 @@ const QB: TorrentClientDriver = {
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const sid = await qbLogin(config)
|
||||
const sid = await qbLogin(config)
|
||||
const form = new FormData()
|
||||
form.append('urls', url)
|
||||
if (config.category) form.append('category', String(config.category))
|
||||
@@ -104,6 +110,7 @@ const QB: TorrentClientDriver = {
|
||||
})
|
||||
const text = await res.text()
|
||||
if (text !== 'Ok.') throw new Error(`Ajout échoué : ${text}`)
|
||||
logger.info('qbittorrent', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Transmission Driver
|
||||
*/
|
||||
|
||||
import type { TorrentClientDriver, TorrentInfo } from './index.js'
|
||||
import { logger } from '../logger.js'
|
||||
|
||||
function mapState(status: number): TorrentInfo['state'] {
|
||||
// Transmission status codes :
|
||||
// 0 = stopped, 1 = check queued, 2 = checking, 3 = download queued,
|
||||
// 4 = downloading, 5 = seed queued, 6 = seeding
|
||||
if (status === 4 || status === 3) return 'downloading'
|
||||
if (status === 6 || status === 5) return 'seeding'
|
||||
if (status === 0) return 'paused'
|
||||
if (status === 1 || status === 2) return 'checking'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
async function trRequest(
|
||||
config: Record<string, string | number>,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
sessionId = ''
|
||||
): Promise<{ result: string; arguments?: any; sessionId?: string }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Transmission-Session-Id': sessionId,
|
||||
}
|
||||
|
||||
if (config.username && config.password) {
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${config.username}:${config.password}`)
|
||||
}
|
||||
|
||||
const res = await fetch(`${config.url}/transmission/rpc`, {
|
||||
method : 'POST',
|
||||
headers,
|
||||
body : JSON.stringify({ method, arguments: args }),
|
||||
})
|
||||
|
||||
// Transmission renvoie 409 avec le session ID à la première requête
|
||||
if (res.status === 409) {
|
||||
const newSessionId = res.headers.get('X-Transmission-Session-Id') ?? ''
|
||||
if (!newSessionId) throw new Error('Session ID introuvable dans la réponse 409')
|
||||
return trRequest(config, method, args, newSessionId)
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Transmission HTTP ${res.status}`)
|
||||
|
||||
const data = await res.json()
|
||||
if (data.result !== 'success') throw new Error(`Transmission erreur : ${data.result}`)
|
||||
|
||||
return { ...data, sessionId }
|
||||
}
|
||||
|
||||
const TR: TorrentClientDriver = {
|
||||
definition: {
|
||||
id : 'transmission',
|
||||
label : 'Transmission',
|
||||
fields: [
|
||||
{ key: 'url', label: 'URL', type: 'url', placeholder: 'http://localhost:9091', required: true },
|
||||
{ key: 'username', label: 'Identifiant', type: 'text', placeholder: 'transmission', required: false },
|
||||
{ key: 'password', label: 'Mot de passe', type: 'password', placeholder: '••••••••', required: false },
|
||||
{ key: 'category', label: 'Dossier cible',type: 'text', placeholder: 'fankai', required: false, default: 'fankai' },
|
||||
],
|
||||
},
|
||||
|
||||
async test(config) {
|
||||
try {
|
||||
await trRequest(config, 'session-get')
|
||||
logger.info('transmission', `Test de connexion réussi sur ${config.url}`)
|
||||
return { ok: true, message: 'Connexion réussie' }
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
logger.warn('transmission', `Test de connexion échoué sur ${config.url} : ${msg}`)
|
||||
return { ok: false, message: msg }
|
||||
}
|
||||
},
|
||||
|
||||
async healthcheck(config) {
|
||||
try {
|
||||
const data = await trRequest(config, 'session-get')
|
||||
const version = data.arguments?.version ?? 'inconnue'
|
||||
logger.debug('transmission', `Healthcheck OK — version ${version}`)
|
||||
return { online: true, version }
|
||||
} catch (err) {
|
||||
logger.debug('transmission', `Healthcheck échoué : ${err instanceof Error ? err.message : err}`)
|
||||
return { online: false }
|
||||
}
|
||||
},
|
||||
|
||||
async list(config, category) {
|
||||
const fields = ['hashString', 'name', 'status', 'percentDone', 'totalSize',
|
||||
'downloadedEver', 'rateDownload', 'eta', 'downloadDir', 'labels']
|
||||
const data = await trRequest(config, 'torrent-get', { fields })
|
||||
const torrents: any[] = data.arguments?.torrents ?? []
|
||||
|
||||
return torrents
|
||||
.filter(t => {
|
||||
if (!category) return true
|
||||
// Filtrer par label ou par dossier contenant la catégorie
|
||||
return t.labels?.includes(category) ||
|
||||
(t.downloadDir as string)?.includes(category)
|
||||
})
|
||||
.map(t => ({
|
||||
hash : t.hashString,
|
||||
name : t.name,
|
||||
state : mapState(t.status),
|
||||
progress : Math.round(t.percentDone * 100),
|
||||
size : t.totalSize,
|
||||
downloaded: t.downloadedEver,
|
||||
speed : t.rateDownload,
|
||||
eta : t.eta ?? -1,
|
||||
save_path : t.downloadDir,
|
||||
category : t.labels?.[0] ?? '',
|
||||
} satisfies TorrentInfo))
|
||||
},
|
||||
|
||||
async add(config, url) {
|
||||
const args: Record<string, unknown> = { filename: url }
|
||||
|
||||
if (config.savePath) {
|
||||
args['download-dir'] = String(config.savePath)
|
||||
} else if (config.category) {
|
||||
// Si pas de savePath explicite, ajouter la catégorie comme sous-dossier
|
||||
args['download-dir'] = `/sdcard/Download/${config.category}`
|
||||
}
|
||||
|
||||
if (config.category) {
|
||||
args['labels'] = [String(config.category)]
|
||||
}
|
||||
|
||||
await trRequest(config, 'torrent-add', args)
|
||||
logger.info('transmission', `Torrent ajouté avec succès (catégorie: ${config.category ?? 'aucune'})`)
|
||||
},
|
||||
}
|
||||
|
||||
export default TR
|
||||
+2
-8
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] text-[#c8d8e8] font-mono">
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<keep-alive include="CatalogView">
|
||||
<component :is="Component" :key="route.path" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
+149
-1
@@ -1 +1,149 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ─── Tokens custom consommables via Tailwind v4 ─── */
|
||||
@theme {
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-dim: var(--accent-dim);
|
||||
--color-accent-muted: var(--accent-muted);
|
||||
|
||||
--color-shell: var(--bg-shell);
|
||||
--color-sidebar: var(--bg-sidebar);
|
||||
--color-main: var(--bg-main);
|
||||
--color-card: var(--bg-card);
|
||||
--color-hover: var(--bg-hover);
|
||||
--color-active: var(--bg-active);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-border-light: var(--border-light);
|
||||
|
||||
--color-primary: var(--text-primary);
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
|
||||
--color-gold: var(--gold);
|
||||
}
|
||||
|
||||
/* ─── Thème Indigo Électrique (défaut) ─── */
|
||||
:root,
|
||||
[data-theme="indigo"] {
|
||||
--accent: #7B7EF8;
|
||||
--accent-hover: #9496fa;
|
||||
--accent-dim: #1c1c38;
|
||||
--accent-muted: rgba(123, 126, 248, 0.15);
|
||||
|
||||
--bg-shell: #111116;
|
||||
--bg-sidebar: #0d0d14;
|
||||
--bg-main: #16161e;
|
||||
--bg-card: #111116;
|
||||
--bg-hover: #1c1c28;
|
||||
--bg-active: #22223a;
|
||||
|
||||
--border: #2a2a3a;
|
||||
--border-light: #1e1e2e;
|
||||
|
||||
--text-primary: #e8e8f8;
|
||||
--text-secondary: #9898b8;
|
||||
--text-muted: #55556a;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
|
||||
/* ─── Thème Crimson Night ─── */
|
||||
[data-theme="crimson"] {
|
||||
--accent: #E74C3C;
|
||||
--accent-hover: #f06050;
|
||||
--accent-dim: #2a0e0e;
|
||||
--accent-muted: rgba(231, 76, 60, 0.15);
|
||||
|
||||
--bg-shell: #110e0e;
|
||||
--bg-sidebar: #0d0a0a;
|
||||
--bg-main: #161010;
|
||||
--bg-card: #110e0e;
|
||||
--bg-hover: #1e1414;
|
||||
--bg-active: #2a1616;
|
||||
|
||||
--border: #2e2020;
|
||||
--border-light: #201616;
|
||||
|
||||
--text-primary: #f0e8e8;
|
||||
--text-secondary: #b89898;
|
||||
--text-muted: #6a5555;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
|
||||
/* ─── Thème Teal Froid ─── */
|
||||
[data-theme="teal"] {
|
||||
--accent: #00CDBF;
|
||||
--accent-hover: #1de8da;
|
||||
--accent-dim: #001e1c;
|
||||
--accent-muted: rgba(0, 205, 191, 0.15);
|
||||
|
||||
--bg-shell: #0a0f0f;
|
||||
--bg-sidebar: #060c0c;
|
||||
--bg-main: #0e1616;
|
||||
--bg-card: #0a0f0f;
|
||||
--bg-hover: #101e1e;
|
||||
--bg-active: #142828;
|
||||
|
||||
--border: #1a2e2e;
|
||||
--border-light: #121e1e;
|
||||
|
||||
--text-primary: #e0f0f0;
|
||||
--text-secondary: #88b0b0;
|
||||
--text-muted: #4a6868;
|
||||
|
||||
--gold: #f5c842;
|
||||
}
|
||||
|
||||
/* ─── Base globale ─── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ─── Composants réutilisables ─── */
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-accent text-white px-4 py-2 text-sm rounded-lg
|
||||
hover:bg-accent-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply border border-border text-secondary px-4 py-2 text-sm rounded-lg
|
||||
hover:border-accent hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-muted px-2 py-1 text-sm rounded-md
|
||||
hover:text-primary hover:bg-hover transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply bg-card border border-border rounded-xl p-4;
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
@apply text-xs text-muted block;
|
||||
}
|
||||
|
||||
.settings-input {
|
||||
@apply w-full bg-shell border border-border text-primary text-sm px-3 py-2.5 rounded-lg
|
||||
outline-none focus:border-accent transition-colors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="$emit('cancel')">
|
||||
<div class="bg-card border border-border rounded-xl 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-border shrink-0">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-0.5">Choisir un dossier</p>
|
||||
<p class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</p>
|
||||
</div>
|
||||
<button @click="$emit('cancel')" class="text-muted hover:text-primary transition-colors text-lg leading-none">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Chemin éditable -->
|
||||
<div class="px-5 py-3 border-b border-border shrink-0 flex gap-2">
|
||||
<input
|
||||
v-model="inputPath"
|
||||
@keydown.enter="navigateTo(inputPath)"
|
||||
class="settings-input flex-1 font-mono text-xs"
|
||||
placeholder="/"
|
||||
/>
|
||||
<button @click="navigateTo(inputPath)" class="btn-secondary px-3 text-xs">
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Retour parent -->
|
||||
<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-muted hover:text-primary transition-colors border-b border-border"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" fill="none">
|
||||
<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-muted text-xs gap-2">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
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-muted">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-primary hover:bg-hover transition-colors border-b border-border/50 text-left"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" fill="none">
|
||||
<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-border shrink-0">
|
||||
<span class="text-xs text-muted font-mono truncate max-w-xs">{{ current }}</span>
|
||||
<button @click="$emit('select', current)" class="btn-primary">
|
||||
Choisir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</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) {
|
||||
error.value = (await res.json()).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>
|
||||
@@ -1,22 +1,42 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2">
|
||||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
@click="remove(t.id)"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-xl border text-sm cursor-pointer min-w-64 max-w-sm"
|
||||
:class="{
|
||||
'border-[#3ae87a] text-[#3ae87a]': t.type === 'success',
|
||||
'border-[#e8513a] text-[#e8513a]': t.type === 'error',
|
||||
'border-[#3a8fe8] text-[#3a8fe8]': t.type === 'info',
|
||||
'bg-card border-green-500/30 text-green-400' : t.type === 'success',
|
||||
'bg-card border-red-500/30 text-red-400' : t.type === 'error',
|
||||
'bg-card border-accent/30 text-accent' : t.type === 'info',
|
||||
}"
|
||||
class="bg-[#0d1219] border px-4 py-3 text-xs tracking-wider cursor-pointer flex items-center gap-3 min-w-64"
|
||||
>
|
||||
<span v-if="t.type === 'success'">✓</span>
|
||||
<span v-else-if="t.type === 'error'">✕</span>
|
||||
<span v-else>·</span>
|
||||
{{ t.message }}
|
||||
<svg v-if="t.type === 'success'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<polyline points="2 7 5.5 10.5 12 3"/>
|
||||
</svg>
|
||||
<svg v-else-if="t.type === 'error'" width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/>
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 14 14" stroke="currentColor" stroke-width="2" fill="none" class="shrink-0">
|
||||
<circle cx="7" cy="7" r="5"/><line x1="7" y1="5" x2="7" y2="7.5"/><circle cx="7" cy="9.5" r="0.5" fill="currentColor"/>
|
||||
</svg>
|
||||
|
||||
<span class="flex-1 text-xs leading-snug">{{ t.message }}</span>
|
||||
|
||||
<!-- Badge count si groupé -->
|
||||
<span
|
||||
v-if="t.count && t.count > 1"
|
||||
class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500/20 text-green-400' : t.type === 'success',
|
||||
'bg-red-500/20 text-red-400' : t.type === 'error',
|
||||
'bg-accent/20 text-accent' : t.type === 'info',
|
||||
}"
|
||||
>
|
||||
×{{ t.count }}
|
||||
</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -30,7 +50,7 @@ const { toasts, remove } = useToast()
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active { transition: all 0.2s ease; }
|
||||
.toast-leave-active { transition: all 0.2s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-to { opacity: 0; transform: translateX(20px); }
|
||||
.toast-leave-active { transition: all 0.15s ease; }
|
||||
.toast-enter-from { opacity: 0; transform: translateY(8px) scale(0.97); }
|
||||
.toast-leave-to { opacity: 0; transform: translateY(4px) scale(0.97); }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<aside class="flex flex-col h-full bg-sidebar border-r border-border">
|
||||
|
||||
<!-- Logo + logout -->
|
||||
<div class="flex items-center gap-2.5 px-4 py-3.5 border-b border-border min-h-[56px]">
|
||||
<svg class="w-7 h-7 shrink-0" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
|
||||
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
|
||||
</svg>
|
||||
<RouterLink to="/series" class="text-[15px] font-semibold text-primary tracking-wide flex-1 hover:text-accent transition-colors">
|
||||
FanKarr
|
||||
</RouterLink>
|
||||
<button
|
||||
@click="logout"
|
||||
title="Se déconnecter"
|
||||
class="text-muted hover:text-red-400 transition-colors p-1 rounded-md hover:bg-hover"
|
||||
>
|
||||
<LogOut :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 flex flex-col gap-0.5">
|
||||
<template v-for="(item, i) in items" :key="i">
|
||||
<hr v-if="item.separator" class="border-border my-2 mx-1" />
|
||||
<SidebarNavItem v-else :item="item" />
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Thème -->
|
||||
<ThemeSwitcher />
|
||||
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import SidebarNavItem from './SidebarNavItem.vue'
|
||||
import ThemeSwitcher from './ThemeSwitcher.vue'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
defineProps<{ items: NavItem[] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div>
|
||||
<component
|
||||
:is="item.to ? RouterLink : 'button'"
|
||||
:to="item.to ?? undefined"
|
||||
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors duration-100 text-left group"
|
||||
:class="isActive
|
||||
? 'bg-active text-primary'
|
||||
: 'text-secondary hover:bg-hover hover:text-primary'"
|
||||
@click="item.children && !item.to && toggleOpen()"
|
||||
>
|
||||
<component
|
||||
:is="iconComponent"
|
||||
:size="17"
|
||||
class="shrink-0 transition-colors"
|
||||
:class="isActive ? 'text-accent' : 'text-muted group-hover:text-secondary'"
|
||||
/>
|
||||
<span class="flex-1 text-sm truncate">{{ item.label }}</span>
|
||||
|
||||
<!-- Badge animé (ex: downloads actifs) -->
|
||||
<span
|
||||
v-if="item.badge"
|
||||
class="min-w-[18px] h-[18px] px-1 rounded-full bg-accent text-white text-[10px] font-medium flex items-center justify-center"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</span>
|
||||
|
||||
<!-- Chevron (seulement si pas de badge) -->
|
||||
<ChevronRight
|
||||
v-else-if="item.children"
|
||||
:size="13"
|
||||
class="shrink-0 text-muted transition-transform duration-200"
|
||||
:class="isOpen ? 'rotate-90' : ''"
|
||||
/>
|
||||
</component>
|
||||
|
||||
<Transition name="slide">
|
||||
<div v-if="item.children && isOpen" class="ml-[34px] mt-0.5 flex flex-col gap-0.5">
|
||||
<RouterLink
|
||||
v-for="child in item.children"
|
||||
:key="child.to"
|
||||
:to="child.to"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-md transition-colors duration-100"
|
||||
:class="route.path === child.to
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:text-secondary'"
|
||||
>
|
||||
<span
|
||||
class="w-1 h-1 rounded-full shrink-0 transition-colors"
|
||||
:class="route.path === child.to ? 'bg-accent' : 'bg-current opacity-40'"
|
||||
/>
|
||||
<span class="text-xs">{{ child.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import * as Icons from 'lucide-vue-next'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const props = defineProps<{ item: NavItem }>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (!props.item.icon) return Icons.Circle
|
||||
return (Icons as Record<string, unknown>)[props.item.icon] ?? Icons.Circle
|
||||
})
|
||||
|
||||
const isActive = computed(() =>
|
||||
props.item.to
|
||||
? route.path.startsWith(props.item.to)
|
||||
: props.item.children?.some(c => route.path.startsWith(c.to))
|
||||
)
|
||||
|
||||
const isOpen = ref(isActive.value)
|
||||
|
||||
watch(isActive, (val) => { if (val) isOpen.value = true })
|
||||
|
||||
function toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.15s ease;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="px-3 py-3 border-t border-border">
|
||||
<p class="text-xs text-muted mb-2 uppercase tracking-widest">Thème</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="theme in themes"
|
||||
:key="theme.id"
|
||||
:title="theme.label"
|
||||
@click="setTheme(theme.id)"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all duration-150 flex items-center justify-center"
|
||||
:style="{ backgroundColor: theme.color }"
|
||||
:class="current === theme.id
|
||||
? 'border-primary scale-110'
|
||||
: 'border-transparent opacity-50 hover:opacity-80'"
|
||||
>
|
||||
<span v-if="current === theme.id" class="block w-1.5 h-1.5 rounded-full bg-white/80" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-theme'
|
||||
|
||||
const themes = [
|
||||
{ id: 'indigo', label: 'Indigo Électrique', color: '#7B7EF8' },
|
||||
{ id: 'crimson', label: 'Crimson Night', color: '#E74C3C' },
|
||||
{ id: 'teal', label: 'Teal Froid', color: '#00CDBF' },
|
||||
]
|
||||
|
||||
const current = ref('indigo')
|
||||
|
||||
function setTheme(id) {
|
||||
current.value = id
|
||||
document.documentElement.setAttribute('data-theme', id)
|
||||
localStorage.setItem(STORAGE_KEY, id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY) ?? 'indigo'
|
||||
setTheme(saved)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-primary">{{ label }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ description }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200 mt-0.5"
|
||||
:class="modelValue ? 'bg-accent' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="modelValue ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ modelValue: boolean; label: string; description?: string }>()
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'fankarr-poster-size'
|
||||
|
||||
const sizes = {
|
||||
S : '120px',
|
||||
M : '150px',
|
||||
L : '180px',
|
||||
XL : '220px',
|
||||
} as const
|
||||
|
||||
export type PosterSize = keyof typeof sizes
|
||||
|
||||
const current = ref<PosterSize>(
|
||||
(localStorage.getItem(STORAGE_KEY) as PosterSize) ?? 'M'
|
||||
)
|
||||
|
||||
watch(current, (val) => localStorage.setItem(STORAGE_KEY, val))
|
||||
|
||||
export function usePosterSize() {
|
||||
return {
|
||||
current,
|
||||
sizes,
|
||||
minmax: () => `minmax(${sizes[current.value]}, 1fr)`,
|
||||
}
|
||||
}
|
||||
+43
-14
@@ -1,25 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
id : number
|
||||
message : string
|
||||
type : 'success' | 'error' | 'info'
|
||||
count : number
|
||||
timer : ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 0
|
||||
const DURATION = 3500
|
||||
const GROUP_WINDOW = 800 // ms — toasts du même type dans cette fenêtre sont groupés
|
||||
|
||||
function add(message: string, type: Toast['type'] = 'info') {
|
||||
const now = Date.now()
|
||||
|
||||
// Cherche un toast récent du même type à grouper
|
||||
const existing = toasts.value.find(t => t.type === type)
|
||||
|
||||
if (existing) {
|
||||
// Grouper — incrémenter le count et mettre à jour le message
|
||||
clearTimeout(existing.timer)
|
||||
existing.count++
|
||||
existing.message = buildGroupMessage(type, existing.count)
|
||||
existing.timer = setTimeout(() => remove(existing.id), DURATION)
|
||||
return
|
||||
}
|
||||
|
||||
// Nouveau toast
|
||||
const id = nextId++
|
||||
const timer = setTimeout(() => remove(id), DURATION)
|
||||
|
||||
toasts.value.push({ id, message, type, count: 1, timer })
|
||||
}
|
||||
|
||||
function buildGroupMessage(type: Toast['type'], count: number): string {
|
||||
if (type === 'success') return `${count} opérations réussies`
|
||||
if (type === 'error') return `${count} erreurs`
|
||||
return `${count} notifications`
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const i = toasts.value.findIndex(t => t.id === id)
|
||||
if (i !== -1) {
|
||||
clearTimeout(toasts.value[i].timer)
|
||||
toasts.value.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
function add(message: string, type: Toast['type'] = 'info', duration = 3000) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, type })
|
||||
setTimeout(() => remove(id), duration)
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
const idx = toasts.value.findIndex(t => t.id === id)
|
||||
if (idx !== -1) toasts.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
return { toasts, add, remove }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="flex h-screen bg-shell overflow-hidden">
|
||||
|
||||
<div class="hidden md:flex w-56 shrink-0">
|
||||
<SidebarNav :items="navItems" class="w-full" />
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="mobileOpen" class="fixed inset-0 bg-black/60 z-40 md:hidden" @click="mobileOpen = false" />
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-left">
|
||||
<div v-if="mobileOpen" class="fixed top-0 left-0 h-full w-56 z-50 md:hidden">
|
||||
<SidebarNav :items="navItems" class="w-full" />
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<header class="md:hidden flex items-center gap-3 px-4 py-3 border-b border-border bg-sidebar shrink-0">
|
||||
<button @click="mobileOpen = true" class="text-muted hover:text-primary transition-colors">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<span class="text-sm font-medium text-primary">FanKarr</span>
|
||||
</header>
|
||||
|
||||
<main id="main-scroll" class="flex-1 overflow-y-auto bg-main">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<keep-alive include="SeriesView">
|
||||
<component :is="Component" class="h-full" />
|
||||
</keep-alive>
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Menu } from 'lucide-vue-next'
|
||||
import SidebarNav from '@/components/nav/SidebarNav.vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import type { NavItem } from '@/types/nav'
|
||||
|
||||
const mobileOpen = ref(false)
|
||||
const dlStore = useDownloadsStore()
|
||||
|
||||
const navItems = computed<NavItem[]>(() => [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
icon : 'LayoutDashboard',
|
||||
to : '/dashboard',
|
||||
},
|
||||
{
|
||||
label : 'Médiathèque',
|
||||
icon : 'Tv',
|
||||
to : '/series',
|
||||
children: [
|
||||
{ label: 'Séries', to: '/series' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Activité',
|
||||
icon : 'Activity',
|
||||
to : '/activity',
|
||||
badge: dlStore.activeCount > 0 ? dlStore.activeCount : undefined,
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label : 'Paramètres',
|
||||
icon : 'Settings',
|
||||
to : '/settings',
|
||||
children: [
|
||||
{ label: 'Clients de téléchargement', to: '/settings/download-client' },
|
||||
{ label: 'Gestion des médias', to: '/settings/media-management' },
|
||||
{ label: 'Catalogue Fankai', to: '/settings/catalogue' },
|
||||
{ label: 'Journaux', to: '/settings/logs' },
|
||||
{ label: 'Avancé', to: '/settings/advanced' },
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.slide-left-enter-active, .slide-left-leave-active { transition: transform 0.25s ease; }
|
||||
.slide-left-enter-from, .slide-left-leave-to { transform: translateX(-100%); }
|
||||
</style>
|
||||
+79
-19
@@ -1,38 +1,98 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/layouts/AppLayout.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// ─── Page publique hors layout ───────────────────────────
|
||||
{
|
||||
path: '/auth',
|
||||
component: () => import('@/views/AuthView.vue'),
|
||||
meta: { public: true }
|
||||
meta: { public: true },
|
||||
},
|
||||
|
||||
// ─── App principale avec layout sidebar ──────────────────
|
||||
{
|
||||
path: '/',
|
||||
name: 'catalog',
|
||||
component: () => import('@/views/CatalogView.vue')
|
||||
component: AppLayout,
|
||||
children: [
|
||||
// Redirections des anciennes URLs
|
||||
{ path: '', redirect: '/series' },
|
||||
{ path: 'downloads', redirect: '/activity' },
|
||||
|
||||
// Dashboard
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue'),
|
||||
},
|
||||
|
||||
// Médiathèque
|
||||
{
|
||||
path: 'series',
|
||||
name: 'series',
|
||||
component: () => import('@/views/SeriesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'series/:id',
|
||||
name: 'serie-detail',
|
||||
component: () => import('@/views/SerieView.vue'),
|
||||
},
|
||||
|
||||
// Activité (ex-downloads)
|
||||
{
|
||||
path: 'activity',
|
||||
name: 'activity',
|
||||
component: () => import('@/views/DownloadsView.vue'),
|
||||
},
|
||||
|
||||
// Paramètres — layout imbriqué avec sous-routes
|
||||
{
|
||||
path: 'settings',
|
||||
component: () => import('@/views/settings/SettingsLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/settings/download-client' },
|
||||
{
|
||||
path: 'download-client',
|
||||
name: 'settings-download',
|
||||
component: () => import('@/views/settings/DownloadClientView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'media-management',
|
||||
name: 'settings-media',
|
||||
component: () => import('@/views/settings/MediaManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'catalogue',
|
||||
name: 'settings-catalogue',
|
||||
component: () => import('@/views/settings/CatalogueView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'settings-logs',
|
||||
component: () => import('@/views/settings/LogsView.vue'), // ← nouveau chemin
|
||||
},
|
||||
{
|
||||
path: 'advanced',
|
||||
name: 'settings-advanced',
|
||||
component: () => import('@/views/settings/AdvancedView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/series/:id',
|
||||
component: () => import('@/views/SerieView.vue')
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
component: () => import('@/views/DownloadsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
}
|
||||
],
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) return savedPosition
|
||||
return { top: 0 }
|
||||
}
|
||||
// Retour depuis le détail vers la liste — ne pas scroller en haut
|
||||
if (to.name === 'series' && from.name === 'serie-detail') return false
|
||||
return { el: '#main-scroll', top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Guard auth (inchangé) ────────────────────────────────────
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
@@ -45,7 +105,7 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
if (to.path === '/auth' && auth.loggedIn) {
|
||||
return '/'
|
||||
return '/series'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useDownloadsStore = defineStore('downloads', () => {
|
||||
const activeCount = ref(0)
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const torrents = await res.json()
|
||||
activeCount.value = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
} catch {
|
||||
activeCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
return { activeCount, refresh }
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface NavChild {
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
label? : string
|
||||
icon? : string
|
||||
to? : string
|
||||
children? : NavChild[]
|
||||
separator?: true
|
||||
badge? : string | number // ← badge optionnel
|
||||
}
|
||||
+34
-68
@@ -1,69 +1,49 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-[#080c10] flex items-center justify-center px-4">
|
||||
<div class="min-h-screen bg-shell flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-baseline gap-1 mb-8 justify-center">
|
||||
<span class="font-sans text-3xl font-black text-white tracking-tight">Fank</span>
|
||||
<span class="font-sans text-3xl font-black text-[#e8513a] tracking-tight">Arr</span>
|
||||
<div class="flex items-center justify-center gap-2.5 mb-8">
|
||||
<svg class="w-8 h-8" viewBox="0 0 130 135" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="var(--gold)" d="m113.85,12.8c-1.94,3.57-3.8,7.09-5.76,10.55-3.6,6.35-7.31,12.65-10.9,19.01-1.69,3-3.22,6.08-4.44,9.49,3.25-2.46,6.44-5.03,9.78-7.36,2.92-2.04,5.95-3.96,9.07-5.67,8.59-4.72,19.29-1.78,24.4,6.54.42.68,1.3,1.16,1.52,1.87.26.83.05,2.62,0,2.62-3.94.13-3.45,5.79-7.59,5.89-3.46.08-6.9,1.25-10.37,1.37-6.78.25-13.48.64-19.32,4.57-1.91,1.29-3.5,3.05-4.97,4.36.21.86.19,1.13.3,1.2,4.42,2.7,8.79,5.49,13.31,8,2.11,1.17,3.66,3.77,2.82,6.63-1.18,3.97-2.64,7.86-4.04,11.76-.48,1.33-1.11,2.61-1.88,4.41,5.87,2.14,11.63,4.26,17.41,6.35,3.43,1.24,6.86,2.47,10.31,3.65,7.38,2.52,9.16,9.54,11.52,15.53,1.04,2.65-.75,6.75-2.11,9.81-.46,1.04-3.53,1.22-5.42,1.26-1.78.03-3.57-.64-4.46-.83-5.97-8.4-11.54-15.98-23.28-14.7.1-.32.21-.65.31-.97,2.32-.52,4.64-1.05,6.97-1.57.03-.22.06-.45.09-.67-2.97-1.18-5.94-2.37-8.92-3.52-1.33-.52-2.7-.93-4.03-1.45-4.83-1.9-4.79-1.89-8.26,2.27-4.31,5.17-10.22,8.13-16.3,11.01.69-.84,1.31-1.75,2.1-2.48.76-.71,1.69-1.22,2.26-2.29-4.9,1.87-9.81,3.74-14.71,5.61-.13-.22-.26-.45-.39-.67.86-.62,1.72-1.24,2.58-1.85-.04-.17-.08-.34-.12-.51-2.94.65-5.93,1.11-8.8,2-4.18,1.29-8.27,2.87-12.38,4.38-2.59.95-5.12,1.51-7.19-.14,11.84-9.4,25.16-17.2,36.59-28.93-4.21-3.65-8.1-7.01-11.98-10.38.15-.43.3-.87.45-1.3,1.61-.09,3.3-.55,4.81-.21,4.14.92,8.21,2.18,12.66,3.4,3.58-6.6,7.1-13.29,7.3-20.58-6.68-2.65-9.79,4.13-15.7,5.76,2.55-2.92,4.54-5.19,6.52-7.46-.2-.17-.39-.33-.59-.5-5.58,4.28-16.89,16.06-24.16,29.07-.19-.29-.37-.59-.55-.87-.81,1.46-1.57,2.83-2.33,4.21l-1.04-.35c1.58-5.71,2.86-11.53,4.81-17.11,2.67-7.67,6.32-15.02,8.71-22.76,4.87-15.8,13.75-29.67,20.7-44.45.65-1.38,1.28-2.77,1.88-4.17,1.58-3.68,3.97-7.37-.47-11.13.3-.16.59-.33.89-.49,7.13,4.08,14.25,8.15,22.38,12.8Z"/>
|
||||
<path fill="var(--gold)" d="m7.03,71.72c.25-.48.39-1.21.77-1.38,1.1-.49,2.62-1.38,3.41-.98,2.58,1.28,4.8.38,7.06-.48,2.76-1.04,5.57-2.05,8.14-3.47,1.22-.67,2.63-2.19,2.77-3.46,1.03-9.65,3.6-18.83,7.54-27.67.24-.53.3-1.14-.17-2.03-1.01,1.63-1.78,3.49-3.08,4.85-2.08,2.21-4.42,4.2-6.82,6.07-1.39,1.09-3.02,2.05-4.7,2.54-1.31.39-3.26.51-4.24-.2-4.32-3.13-8.38-6.6-12.71-10.09,2.03-2.48,3.73-4.2,7.18-3.5,1.74.36,4.04-.43,5.67-1.4,5.43-3.21,10.65-6.79,16.01-10.13,5.58-3.48,11.45-3.46,17.13-.53,4.5,2.32,8.73,5.19,13.03,7.9,1.47.93,2.35,2.23,1.34,4.11-2.08,3.88-4.01,7.85-6.22,11.66-1.95,3.37-4.2,6.56-6.47,10.06,2.04.96,3.78,1.67,5.4,2.58,2.25,1.27,3.25,3.24,2.97,5.87-.33,3.15-1.59,5.64-4.68,6.84-.84.33-1.83.72-2.65.56-5.8-1.1-9.79,2.93-14.43,5.04-1.2.54-2.19,1.83-2.95,2.99-3.3,5.08-8.83,6.7-13.83,9.17-.53.26-1.22.74-1.34,1.24-1.76,7.82-3.45,15.65-4.58,24.09,4.1-2.32,8.19-4.65,12.29-6.97.12.15.23.31.35.46-1.05,1.17-2.1,2.34-3.15,3.52l.5.66c5.73-2.61,10.97-5.98,15.24-10.87.28.22.57.43.85.65-1.83,3.39-3.78,6.72-5.44,10.2-.95,1.99-1.38,4.21-2.13,6.3-.48,1.33-.84,2.82-1.71,3.86-2.61,3.13-5.46,6.06-8.19,9.09-.45.5-.76,1.12-1.14,1.68-.66.99-1.21,2.09-2.02,2.94-.39.41-1.58.71-1.85.46-2.26-2.14-5.5,2.25-7.78-1.52-1.9-3.13-5.35-5.33-7.23-8.47-2.37-3.95-5.75-7.84-5.14-12.85.51-4.17,2.16-8.18,2.89-12.34,1.43-8.11,2.6-16.27,3.82-24.42.14-.95-.11-1.95-.19-2.93.14.09.29.17.43.26Z"/>
|
||||
</svg>
|
||||
<span class="text-2xl font-bold text-primary tracking-wide">FanKarr</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-8">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">
|
||||
{{ isSetup ? 'PREMIÈRE CONNEXION' : 'CONNEXION' }}
|
||||
<div class="settings-card p-8 flex flex-col gap-5">
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">
|
||||
{{ isSetup ? 'Première connexion' : 'Connexion' }}
|
||||
</p>
|
||||
<h1 class="text-lg font-semibold text-primary">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
</div>
|
||||
<h1 class="text-lg font-black text-white font-sans mb-6">
|
||||
{{ isSetup ? 'Créer un compte' : 'Bon retour' }}
|
||||
</h1>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">IDENTIFIANT</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Identifiant</label>
|
||||
<input v-model="username" type="text" placeholder="admin" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">MOT DE PASSE</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Mot de passe</label>
|
||||
<input v-model="password" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<div v-if="isSetup">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">CONFIRMER</label>
|
||||
<input
|
||||
v-model="confirm"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<label class="settings-label mb-1.5">Confirmer</label>
|
||||
<input v-model="confirm" type="password" placeholder="••••••••" class="settings-input" @keyup.enter="submit" />
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="text-[#e8513a] text-xs">{{ error }}</div>
|
||||
<p v-if="error" class="text-xs text-red-400">{{ error }}</p>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="submitting"
|
||||
class="w-full bg-[#e8513a] text-white py-2.5 text-xs tracking-widest font-bold hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer mt-2"
|
||||
>
|
||||
{{ submitting ? '...' : isSetup ? 'CRÉER LE COMPTE' : 'SE CONNECTER' }}
|
||||
<button @click="submit" :disabled="submitting" class="btn-primary w-full py-2.5 mt-1">
|
||||
{{ submitting ? '...' : isSetup ? 'Créer le compte' : 'Se connecter' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,42 +54,28 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
const isSetup = computed(() => !auth.setup)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Tous les champs sont requis'
|
||||
return
|
||||
}
|
||||
|
||||
if (isSetup.value && password.value !== confirm.value) {
|
||||
error.value = 'Les mots de passe ne correspondent pas'
|
||||
return
|
||||
}
|
||||
if (!username.value || !password.value) { error.value = 'Tous les champs sont requis'; return }
|
||||
if (isSetup.value && password.value !== confirm.value) { error.value = 'Les mots de passe ne correspondent pas'; return }
|
||||
|
||||
submitting.value = true
|
||||
|
||||
const err = isSetup.value
|
||||
? await auth.setupAccount(username.value, password.value)
|
||||
: await auth.login(username.value, password.value)
|
||||
|
||||
submitting.value = false
|
||||
|
||||
if (err) {
|
||||
error.value = err
|
||||
return
|
||||
}
|
||||
|
||||
if (err) { error.value = err; return }
|
||||
await router.push('/')
|
||||
}
|
||||
</script>
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
|
||||
<!-- 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">
|
||||
<span class="text-zinc-100">Fan</span><span class="text-orange-500">karr</span>
|
||||
</span>
|
||||
<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">
|
||||
<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"/>
|
||||
</svg>
|
||||
<!-- Badge si téléchargements actifs -->
|
||||
<span v-if="activeDownloads > 0"
|
||||
class="absolute top-1 right-1 w-2 h-2 bg-[#e8513a] rounded-full"/>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
</router-link>
|
||||
<button
|
||||
class="p-2 rounded-lg text-zinc-500 hover:text-zinc-100 hover:bg-white/5 transition"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Filters -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-3 flex flex-wrap items-center gap-3">
|
||||
<div class="relative flex-1 min-w-48 max-w-sm">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600 pointer-events-none" width="15" height="15" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="w-full bg-white/5 border border-white/[0.08] rounded-lg pl-9 pr-4 py-2 text-sm placeholder-zinc-600 outline-none focus:border-orange-500/50 transition"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.value"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium border transition"
|
||||
:class="activeFilter === f.value
|
||||
? 'bg-orange-500 border-orange-500 text-white'
|
||||
: 'bg-transparent border-white/[0.08] text-zinc-500 hover:text-zinc-100 hover:border-white/20'"
|
||||
@click="activeFilter = f.value"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
<span class="text-xs text-zinc-600 ml-auto">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="max-w-7xl mx-auto px-6 py-8">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement du catalogue…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
<p class="text-sm">{{ store.error }}</p>
|
||||
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm hover:bg-orange-600 transition" @click="store.fetchSeries()">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="filtered.length === 0" class="flex flex-col items-center justify-center gap-3 min-h-96 text-zinc-600">
|
||||
<p class="text-sm">Aucune série trouvée</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-else class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-5">
|
||||
<router-link
|
||||
v-for="serie in filtered"
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
class="group flex flex-col gap-2 transition-transform duration-200 hover:-translate-y-1"
|
||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||
>
|
||||
<!-- Poster -->
|
||||
<div class="relative aspect-[2/3] rounded-xl overflow-hidden bg-zinc-900 border border-white/5">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/>
|
||||
<path d="m9 9 6 6M15 9l-6 6"/>
|
||||
</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 }}
|
||||
</div>
|
||||
|
||||
<!-- Overlay hover -->
|
||||
<div class="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<span class="text-xs text-white border border-white/40 px-3 py-1.5 rounded-md">Voir</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div>
|
||||
<p class="text-xs font-medium truncate leading-snug">{{ serie.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span v-if="serie.year" class="text-[11px] text-zinc-600">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-white/5 text-zinc-600'"
|
||||
>{{ serie.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineOptions({ name: 'CatalogView' })
|
||||
|
||||
const store = useSeriesStore()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const filters = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
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 (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await auth.logout()
|
||||
router.push('/auth')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (store.series.length === 0) store.fetchSeries()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Catalogue</span>
|
||||
<span class="text-2xl font-semibold text-primary">{{ stats.catalogue }}</span>
|
||||
<span class="text-xs text-muted">séries disponibles</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Importées</span>
|
||||
<span class="text-2xl font-semibold text-green-500">{{ stats.imported }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.catalogue > 0 ? Math.round(stats.imported / stats.catalogue * 100) : 0 }}% du catalogue</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">En cours</span>
|
||||
<span class="text-2xl font-semibold text-accent">{{ stats.downloading }}</span>
|
||||
<span class="text-xs text-muted">téléchargements</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Erreurs</span>
|
||||
<span class="text-2xl font-semibold" :class="stats.errors > 0 ? 'text-red-400' : 'text-muted'">{{ stats.errors }}</span>
|
||||
<span class="text-xs text-muted">{{ stats.errors === 0 ? 'aucune erreur' : 'à corriger' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4 mb-6">
|
||||
|
||||
<!-- Téléchargements actifs -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-secondary">Téléchargements actifs</h2>
|
||||
<div v-if="loadingTorrents" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="activeTorrents.length === 0" class="text-xs text-muted py-4">Aucun téléchargement en cours</div>
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div v-for="t in activeTorrents" :key="t.hash" class="flex flex-col gap-1.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-primary truncate">{{ t.name }}</span>
|
||||
<span class="text-xs text-muted shrink-0">{{ t.progress }}%</span>
|
||||
</div>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500" :class="t.state === 'seeding' ? 'bg-green-500' : 'bg-accent'" :style="{ width: `${t.progress}%` }" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[11px] text-muted">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</span>
|
||||
<span v-if="t.state === 'downloading' && t.eta > 0" class="text-[11px] text-muted">{{ formatEta(t.eta) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Derniers imports -->
|
||||
<div class="settings-card flex flex-col gap-3">
|
||||
<h2 class="text-sm font-medium text-secondary">Derniers imports</h2>
|
||||
<div v-if="loadingNotifs" class="flex items-center gap-2 text-muted text-xs py-4">
|
||||
<div class="w-3 h-3 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
<div v-else-if="recentNotifs.length === 0" class="text-xs text-muted py-4">Aucun import récent</div>
|
||||
<div v-else class="flex flex-col divide-y divide-border/50">
|
||||
<div v-for="n in recentNotifs" :key="n.hash + n.at" class="flex items-start gap-3 py-2.5 first:pt-0 last:pb-0">
|
||||
<div class="w-5 h-5 rounded-md flex items-center justify-center shrink-0 mt-0.5" :class="n.errors > 0 ? 'bg-red-500/10' : 'bg-green-500/10'">
|
||||
<svg v-if="n.errors === 0" width="10" height="10" viewBox="0 0 12 12" stroke="#22c55e" stroke-width="2.5" fill="none"><polyline points="2 6 5 9 10 3"/></svg>
|
||||
<svg v-else width="10" height="10" viewBox="0 0 12 12" stroke="#f87171" stroke-width="2.5" fill="none"><line x1="3" y1="3" x2="9" y2="9"/><line x1="9" y1="3" x2="3" y2="9"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-primary truncate">{{ n.name }}</p>
|
||||
<p class="text-[11px] text-muted mt-0.5">
|
||||
{{ n.done }} fichier{{ n.done > 1 ? 's' : '' }} importé{{ n.done > 1 ? 's' : '' }}
|
||||
<span v-if="n.errors > 0" class="text-red-400"> · {{ n.errors }} erreur{{ n.errors > 1 ? 's' : '' }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-[11px] text-muted shrink-0">{{ formatRelative(n.at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Panneau debug -->
|
||||
<div v-if="devMode" class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-sm font-medium text-secondary">Debug</h2>
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded border border-accent/30 text-accent">DEV</span>
|
||||
<button @click="fetchDebug" class="ml-auto text-xs text-muted hover:text-primary transition-colors">↺ Rafraîchir</button>
|
||||
</div>
|
||||
<div v-if="debug" class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Heap utilisé</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.memory.heapUsed }} MB</span>
|
||||
<span class="text-xs text-muted">/ {{ debug.memory.heapTotal }} MB total</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">RSS (process)</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.memory.rss }} MB</span>
|
||||
<span class="text-xs text-muted">mémoire système</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Uptime</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.uptime }}</span>
|
||||
<span class="text-xs text-muted">depuis le démarrage</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Cache GitHub</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.cache.entries }}</span>
|
||||
<span class="text-xs text-muted">entrées (TTL {{ debug.cache.ttlHours }}h)</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Fichiers trackés</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ debug.organized.trackedFiles }}</span>
|
||||
<span class="text-xs text-muted">dans organized.json</span>
|
||||
</div>
|
||||
<div class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">Worker organize</span>
|
||||
<span class="text-xl font-semibold" :class="debug.worker.running ? 'text-accent' : 'text-muted'">
|
||||
{{ debug.worker.running ? 'Actif' : 'Inactif' }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ debug.requests.notifs }} notif(s) en mémoire</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="settings-card text-xs text-muted py-4 text-center">Chargement des stats...</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const loadingTorrents = ref(true)
|
||||
const loadingNotifs = ref(true)
|
||||
const devMode = ref(false)
|
||||
const debug = ref<any>(null)
|
||||
const stats = ref({ catalogue: 0, imported: 0, downloading: 0, errors: 0 })
|
||||
const activeTorrents = ref<any[]>([])
|
||||
const recentNotifs = ref<any[]>([])
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetchSettings() {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
devMode.value = s.devMode ?? false
|
||||
if (devMode.value) fetchDebug()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDebug() {
|
||||
const res = await fetch('/api/debug/stats', { credentials: 'include' })
|
||||
if (res.ok) debug.value = await res.json()
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const [statusRes, seriesRes, downloadsRes] = await Promise.all([
|
||||
fetch('/api/torrents/status', { credentials: 'include' }),
|
||||
fetch('/api/series', { credentials: 'include' }),
|
||||
fetch('/api/downloads', { credentials: 'include' }),
|
||||
])
|
||||
if (statusRes.ok) { const s = await statusRes.json(); stats.value.catalogue = s.count ?? 0 }
|
||||
if (seriesRes.ok) {
|
||||
const s = await seriesRes.json()
|
||||
const list = Array.isArray(s) ? s : (s.series ?? [])
|
||||
stats.value.imported = list.filter((s: any) => s.download_state === 'complete').length
|
||||
}
|
||||
if (downloadsRes.ok) {
|
||||
const torrents = await downloadsRes.json()
|
||||
stats.value.downloading = torrents.filter((t: any) => t.state === 'downloading').length
|
||||
stats.value.errors = torrents.filter((t: any) => t.state === 'error').length
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function fetchTorrents() {
|
||||
const isFirst = loadingTorrents.value
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const list = await res.json()
|
||||
activeTorrents.value = list.filter((t: any) => ['downloading', 'paused', 'checking', 'error'].includes(t.state)).slice(0, 5)
|
||||
}
|
||||
} catch {} finally {
|
||||
if (isFirst) loadingTorrents.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNotifs() {
|
||||
loadingNotifs.value = true
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (res.ok) recentNotifs.value = (await res.json()).slice(0, 6)
|
||||
} catch {} finally {
|
||||
loadingNotifs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
const h = Math.floor(seconds / 3600), m = Math.floor((seconds % 3600) / 60), s = seconds % 60
|
||||
if (h > 0) return `${h}h ${m.toString().padStart(2, '0')}m`
|
||||
if (m > 0) return `${m}m ${s.toString().padStart(2, '0')}s`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const m = Math.floor(diff / 60000), h = Math.floor(diff / 3600000), d = Math.floor(diff / 86400000)
|
||||
if (m < 1) return "à l'instant"
|
||||
if (m < 60) return `il y a ${m}min`
|
||||
if (h < 24) return `il y a ${h}h`
|
||||
return `il y a ${d}j`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
Promise.all([fetchStats(), fetchTorrents(), fetchNotifs(), fetchSettings()])
|
||||
interval = setInterval(() => {
|
||||
fetchTorrents()
|
||||
if (devMode.value) fetchDebug()
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (interval) clearInterval(interval) })
|
||||
</script>
|
||||
+341
-144
@@ -1,148 +1,325 @@
|
||||
<template>
|
||||
<div class="max-w-4xl 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">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' }}
|
||||
</div>
|
||||
<button @click="togglePolling"
|
||||
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">
|
||||
{{ polling ? 'PAUSE' : 'REPRENDRE' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
|
||||
<!-- Résumé -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-1">{{ stat.label }}</div>
|
||||
<div class="text-lg font-bold text-white">{{ stat.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="flex flex-col items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<p class="text-sm">Aucun client torrent configuré</p>
|
||||
<router-link to="/settings"
|
||||
class="text-[10px] tracking-widest text-[#e8513a] hover:opacity-75 transition-opacity">
|
||||
CONFIGURER →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-3 min-h-64 text-[#5a7a94]">
|
||||
<div class="w-5 h-5 border-2 border-white/10 border-t-[#e8513a] rounded-full animate-spin"/>
|
||||
<span class="text-sm">Chargement...</span>
|
||||
</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>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in torrents" :key="t.hash"
|
||||
class="bg-[#0d1219] border border-[#1e2d3d] p-4">
|
||||
|
||||
<!-- Ligne principale -->
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
<!-- 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">
|
||||
{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}
|
||||
</p>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-lg font-semibold text-primary">Activité</h1>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full transition-colors"
|
||||
:class="polling ? 'bg-green-500 animate-pulse' : 'bg-border'"
|
||||
/>
|
||||
<span class="text-xs text-muted">{{ polling ? 'En direct' : 'Pause' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-[#1e2d3d] rounded-full overflow-hidden mb-2">
|
||||
<div class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-[#5a7a94]' : 'bg-[#e8513a]'"
|
||||
:style="{ width: `${t.progress}%` }"/>
|
||||
</div>
|
||||
|
||||
<!-- Infos bas -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-[10px] text-[#5a7a94]">
|
||||
<span v-if="t.state === 'downloading'">
|
||||
↓ {{ formatSpeed(t.speed) }}
|
||||
</span>
|
||||
<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' }}
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="importAll" :disabled="importingAll" class="btn-primary">
|
||||
{{ importingAll ? '...' : 'Importer tout' }}
|
||||
</button>
|
||||
<button @click="togglePolling" class="btn-secondary">
|
||||
{{ polling ? 'Pause' : 'Reprendre' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="stat in stats" :key="stat.label" class="settings-card flex flex-col gap-1">
|
||||
<span class="text-xs text-muted">{{ stat.label }}</span>
|
||||
<span class="text-xl font-semibold text-primary">{{ stat.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar : recherche + tri -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="relative flex-1 max-w-xs">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher..."
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0" ref="sortRef">
|
||||
<button
|
||||
@click="sortOpen = !sortOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5"
|
||||
:class="activeSort !== 'none' ? 'border-accent text-accent' : ''"
|
||||
>
|
||||
<ArrowUpDown :size="14" />
|
||||
<span class="hidden md:inline text-xs">Trier</span>
|
||||
<span v-if="activeSort !== 'none'" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="sortOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-2 z-20 w-48 flex flex-col gap-0.5 shadow-xl"
|
||||
>
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="setSort(s.value)"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg text-xs transition-colors"
|
||||
:class="activeSort === s.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ s.label }}
|
||||
<span v-if="activeSort === s.value && sortDir === 'asc'">↑</span>
|
||||
<span v-else-if="activeSort === s.value && sortDir === 'desc'">↓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Onglets + filtre -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-1 bg-shell rounded-lg p-1 border border-border">
|
||||
<button
|
||||
@click="activeTab = 'active'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'active'
|
||||
? 'bg-active text-primary'
|
||||
: 'text-muted hover:text-primary'"
|
||||
>
|
||||
En cours
|
||||
<span v-if="activeTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
{{ activeTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'done'"
|
||||
class="px-4 py-1.5 text-xs rounded-md transition-colors"
|
||||
:class="activeTab === 'done'
|
||||
? 'bg-active text-primary'
|
||||
: 'text-muted hover:text-primary'"
|
||||
>
|
||||
Terminés
|
||||
<span v-if="doneTorrents.length > 0" class="ml-1.5 text-[10px] text-accent">
|
||||
{{ doneTorrents.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'done'" class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted">Masquer importés</span>
|
||||
<button
|
||||
@click="hideImported = !hideImported"
|
||||
class="relative shrink-0 w-10 h-[22px] rounded-full transition-colors duration-200"
|
||||
:class="hideImported ? 'bg-accent' : 'bg-border'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200"
|
||||
:class="hideImported ? 'translate-x-[18px]' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pas de clients -->
|
||||
<div v-if="noClients" class="settings-card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p class="text-sm text-muted">Aucun client torrent configuré</p>
|
||||
<RouterLink to="/settings/download-client" class="text-xs text-accent hover:underline">
|
||||
Configurer un client →
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Loading initial -->
|
||||
<div v-else-if="loading && torrents.length === 0" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="!loading && visibleTorrents.length === 0" class="settings-card flex flex-col items-center gap-2 py-16 text-center">
|
||||
<p class="text-sm text-muted">
|
||||
{{ activeTab === 'active' ? 'Aucun téléchargement en cours' : 'Aucun torrent terminé' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ activeTab === 'active'
|
||||
? 'Les torrents avec la catégorie « fankai » apparaîtront ici'
|
||||
: hideImported ? 'Tous les torrents terminés sont importés' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Liste torrents -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="t in visibleTorrents" :key="t.hash" class="settings-card flex flex-col gap-3">
|
||||
|
||||
<!-- Nom + état + progression -->
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-primary font-medium truncate">{{ t.name }}</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span class="text-[10px] px-2 py-0.5 rounded border" :class="stateBadge(t.state).class">
|
||||
{{ stateBadge(t.state).label }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">{{ t.client_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
<p class="text-sm font-semibold text-primary">{{ t.progress }}%</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ formatSize(t.downloaded) }} / {{ formatSize(t.size) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barre de progression -->
|
||||
<div class="h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-500"
|
||||
:class="t.state === 'seeding' ? 'bg-green-500' :
|
||||
t.state === 'error' ? 'bg-red-500' :
|
||||
t.state === 'paused' ? 'bg-muted' : 'bg-accent'"
|
||||
:style="{ width: `${t.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Infos bas + actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-xs text-muted">
|
||||
<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>
|
||||
|
||||
<div v-if="t.state === 'seeding'" class="flex items-center gap-2">
|
||||
|
||||
<!-- Erreurs fichiers -->
|
||||
<div v-if="t.errorFiles?.length > 0" class="relative group/err">
|
||||
<span class="text-xs px-2 py-0.5 rounded 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-card border border-red-500/30 rounded-lg p-3 text-xs shadow-xl">
|
||||
<p class="text-red-400 mb-2 font-medium">Fichiers en erreur</p>
|
||||
<div v-for="e in t.errorFiles" :key="e.file" class="mb-1.5 last:mb-0">
|
||||
<p class="text-primary truncate">{{ e.file }}</p>
|
||||
<p class="text-muted">{{ e.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Badge import -->
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded border"
|
||||
:class="t.organizeState === 'done' ? 'border-green-500/40 text-green-500' :
|
||||
t.organizeState === 'partial' ? 'border-yellow-500/40 text-yellow-500' :
|
||||
'border-border text-muted'"
|
||||
>
|
||||
{{ t.organizeState === 'done' ? `Importé (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
t.organizeState === 'partial' ? `En cours (${t.organizeProgress?.done}/${t.organizeProgress?.total})` :
|
||||
'Non importé' }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
v-if="t.organizeState !== 'done'"
|
||||
@click="importTorrent(t)"
|
||||
:disabled="importing[t.hash]"
|
||||
class="btn-secondary text-xs py-1"
|
||||
>
|
||||
{{ importing[t.hash] ? '...' : 'Importer' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Search, ArrowUpDown } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
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 importing = ref<Record<string, boolean>>({})
|
||||
const importingAll = ref(false)
|
||||
const activeTab = ref<'active' | 'done'>('active')
|
||||
const hideImported = ref(false)
|
||||
const search = ref('')
|
||||
const sortOpen = ref(false)
|
||||
const activeSort = ref<'none' | 'name' | 'size' | 'progress' | 'state'>('none')
|
||||
const sortDir = ref<'asc' | 'desc'>('asc')
|
||||
const sortRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(sortRef, () => { sortOpen.value = false })
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Nom', value: 'name' },
|
||||
{ label: 'Taille', value: 'size' },
|
||||
{ label: 'Progression', value: 'progress' },
|
||||
{ label: 'État', value: 'state' },
|
||||
]
|
||||
|
||||
function setSort(val: typeof activeSort.value) {
|
||||
if (activeSort.value === val) {
|
||||
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
activeSort.value = val
|
||||
sortDir.value = 'asc'
|
||||
}
|
||||
sortOpen.value = false
|
||||
}
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────
|
||||
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(() => {
|
||||
let list = activeTab.value === 'active'
|
||||
? activeTorrents.value
|
||||
: hideImported.value
|
||||
? doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
: doneTorrents.value
|
||||
|
||||
// Recherche
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(t => t.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Tri
|
||||
if (activeSort.value !== 'none') {
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1
|
||||
list = [...list].sort((a, b) => {
|
||||
switch (activeSort.value) {
|
||||
case 'name' : return a.name.localeCompare(b.name, 'fr') * dir
|
||||
case 'size' : return (a.size - b.size) * dir
|
||||
case 'progress': return (a.progress - b.progress) * dir
|
||||
case 'state' : return a.state.localeCompare(b.state) * dir
|
||||
default : return 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{ label: 'EN COURS', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'COMPLÉTÉS', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'EN PAUSE', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'ERREURS', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
{ label: 'En cours', value: torrents.value.filter(t => t.state === 'downloading').length },
|
||||
{ label: 'Complétés', value: torrents.value.filter(t => t.state === 'seeding').length },
|
||||
{ label: 'En pause', value: torrents.value.filter(t => t.state === 'paused').length },
|
||||
{ label: 'Erreurs', value: torrents.value.filter(t => t.state === 'error').length },
|
||||
])
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────
|
||||
async function fetchTorrents() {
|
||||
try {
|
||||
const res = await fetch('/api/downloads', { credentials: 'include' })
|
||||
@@ -151,7 +328,6 @@ async function fetchTorrents() {
|
||||
torrents.value = await res.json()
|
||||
noClients.value = false
|
||||
} catch {
|
||||
// silencieux en polling
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -168,46 +344,69 @@ function stopPolling() {
|
||||
|
||||
function togglePolling() {
|
||||
polling.value = !polling.value
|
||||
if (polling.value) startPolling()
|
||||
else stopPolling()
|
||||
polling.value ? startPolling() : stopPolling()
|
||||
}
|
||||
|
||||
onMounted(startPolling)
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
// ── Organiser ──────────────────────────────────────────────────
|
||||
async function organize(torrent: any) {
|
||||
organizing.value[torrent.hash] = true
|
||||
async function importTorrent(torrent: any) {
|
||||
importing.value[torrent.hash] = true
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method : 'POST',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: torrent.hash, save_path: torrent.save_path, name: torrent.name })
|
||||
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')
|
||||
toast(`${torrent.name} importé ✓`, 'success')
|
||||
await fetchTorrents()
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de l\'organisation', 'error')
|
||||
toast(error ?? "Erreur lors de l'import", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
organizing.value[torrent.hash] = false
|
||||
importing.value[torrent.hash] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers affichage ──────────────────────────────────────────
|
||||
async function importAll() {
|
||||
const toImport = doneTorrents.value.filter(t => t.organizeState !== 'done')
|
||||
if (toImport.length === 0) { toast('Tous les torrents sont déjà importés', 'success'); return }
|
||||
|
||||
importingAll.value = true
|
||||
let done = 0, errors = 0
|
||||
|
||||
for (const t of toImport) {
|
||||
try {
|
||||
const res = await fetch('/api/organize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ hash: t.hash, save_path: t.save_path, name: t.name }),
|
||||
})
|
||||
res.ok ? done++ : errors++
|
||||
} catch { errors++ }
|
||||
}
|
||||
|
||||
importingAll.value = false
|
||||
await fetchTorrents()
|
||||
errors === 0
|
||||
? toast(`${done} torrent(s) importé(s) ✓`, 'success')
|
||||
: toast(`${done} OK, ${errors} erreur(s)`, 'error')
|
||||
}
|
||||
|
||||
function stateBadge(state: string) {
|
||||
const map: Record<string, { label: string; class: string }> = {
|
||||
downloading: { label: 'TÉLÉCHARGEMENT', class: 'border-[#e8513a]/40 text-[#e8513a]' },
|
||||
seeding : { label: 'COMPLÉTÉ', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'EN PAUSE', class: 'border-[#5a7a94]/40 text-[#5a7a94]' },
|
||||
checking : { label: 'VÉRIFICATION', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'ERREUR', class: 'border-red-500/40 text-red-500' },
|
||||
unknown : { label: 'INCONNU', class: 'border-[#1e2d3d] text-[#5a7a94]' },
|
||||
downloading: { label: 'Téléchargement', class: 'border-accent/40 text-accent' },
|
||||
seeding : { label: 'Complété', class: 'border-green-500/40 text-green-500' },
|
||||
paused : { label: 'En pause', class: 'border-border text-muted' },
|
||||
checking : { label: 'Vérification', class: 'border-yellow-500/40 text-yellow-500' },
|
||||
error : { label: 'Erreur', class: 'border-red-500/40 text-red-400' },
|
||||
unknown : { label: 'Inconnu', class: 'border-border text-muted' },
|
||||
}
|
||||
return map[state] ?? map.unknown
|
||||
}
|
||||
@@ -219,9 +418,7 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function formatSpeed(bps: number): string {
|
||||
return `${formatSize(bps)}/s`
|
||||
}
|
||||
function formatSpeed(bps: number): string { return `${formatSize(bps)}/s` }
|
||||
|
||||
function formatEta(seconds: number): string {
|
||||
if (seconds < 0 || seconds > 86400 * 7) return '∞'
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto px-8 py-8">
|
||||
<!-- Back -->
|
||||
<button
|
||||
@click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors mb-8 cursor-pointer"
|
||||
>
|
||||
← CATALOGUE
|
||||
</button>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-[#5a7a94] text-sm tracking-widest py-20 text-center">
|
||||
CHARGEMENT...
|
||||
</div>
|
||||
|
||||
<template v-else-if="production">
|
||||
<!-- Hero -->
|
||||
<div class="flex gap-8 mb-10">
|
||||
<!-- Poster -->
|
||||
<div class="w-40 flex-shrink-0">
|
||||
<img
|
||||
v-if="production.cover"
|
||||
:src="imageUrl(production.cover)"
|
||||
:alt="production.name"
|
||||
class="w-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full aspect-[2/3] bg-[#0d1219] flex items-center justify-center text-5xl">🎌</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-2">FANKAI</div>
|
||||
<h1 class="text-3xl font-black text-white font-sans mb-4">{{ production.name }}</h1>
|
||||
|
||||
<!-- Badges -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
|
||||
{{ production.status.toUpperCase() }}
|
||||
</span>
|
||||
<span class="text-[10px] border border-[#3a8fe8] text-[#3a8fe8] bg-[#3a8fe8]/10 px-2 py-1 tracking-wider">
|
||||
{{ production.episodes_count }} FILMS
|
||||
</span>
|
||||
<span class="text-[10px] border border-[#1e2d3d] text-[#5a7a94] px-2 py-1 tracking-wider">
|
||||
PAR {{ production.kaieur.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[#5a7a94] leading-relaxed mb-6">{{ production.description }}</p>
|
||||
|
||||
<!-- Torrents globaux -->
|
||||
<div v-if="torrents.length > 0">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest mb-2">TORRENTS</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="t in torrents"
|
||||
:key="t.url"
|
||||
:href="t.url"
|
||||
target="_blank"
|
||||
class="flex items-center justify-between bg-[#0d1219] border border-[#1e2d3d] hover:border-[#e8513a] px-4 py-2.5 transition-colors group"
|
||||
>
|
||||
<span class="text-sm text-white">{{ t.name }}</span>
|
||||
<span class="text-[10px] text-[#e8513a] tracking-widest group-hover:text-white transition-colors">
|
||||
NYAA ↗
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lien wiki -->
|
||||
<a
|
||||
v-if="production.link"
|
||||
:href="production.link"
|
||||
target="_blank"
|
||||
class="inline-block mt-4 text-[10px] text-[#5a7a94] hover:text-[#3a8fe8] tracking-widest transition-colors"
|
||||
>
|
||||
GUIDE DES ÉPISODES ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saisons -->
|
||||
<!-- Saisons -->
|
||||
<div v-if="production.seasons.length > 0" class="space-y-6">
|
||||
<div v-for="season in production.seasons" :key="season.id">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="text-[10px] text-[#5a7a94] tracking-widest">SAISON {{ season.number }}</div>
|
||||
<div class="text-sm font-bold text-white">{{ season.name }}</div>
|
||||
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
|
||||
<div v-for="ep in season.episodes" :key="ep.id">
|
||||
<EpisodeRow :ep="ep" :production-id="production.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Episodes sans saison -->
|
||||
<div v-else-if="production.episodes.length > 0">
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<div class="text-sm font-bold text-white">ÉPISODES</div>
|
||||
<div class="flex-1 h-px bg-[#1e2d3d]"></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-px bg-[#1e2d3d]">
|
||||
<div v-for="ep in production.episodes" :key="ep.id">
|
||||
<EpisodeRow :ep="ep" :production-id="production.id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useCatalogStore } from '@/stores/catalog'
|
||||
import { imageUrl, parseTorrents, type ProductionDetail } from '@/api/fankai'
|
||||
import EpisodeRow from "@/components/EpisodeRow.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const store = useCatalogStore()
|
||||
|
||||
const production = ref<ProductionDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const torrents = computed(() => parseTorrents(production.value?.torrents ?? null))
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
const name = route.query.name as string ?? ''
|
||||
production.value = await store.loadProduction(id, name)
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
+305
-162
@@ -1,242 +1,373 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
|
||||
<!-- Back -->
|
||||
<div class="max-w-7xl mx-auto px-6 pt-6">
|
||||
<router-link to="/" class="inline-flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-100 transition">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 5l-7 7 7 7"/>
|
||||
</svg>
|
||||
Catalogue
|
||||
</router-link>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-4 min-h-96 text-zinc-600">
|
||||
<div class="w-8 h-8 border-2 border-white/10 border-t-orange-500 rounded-full animate-spin" />
|
||||
<div v-if="store.loadingDetail" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
|
||||
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-4 min-h-96 text-orange-400">
|
||||
<p class="text-sm">{{ store.error }}</p>
|
||||
<button class="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm" @click="load">Réessayer</button>
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-sm text-red-400">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="load">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Hero -->
|
||||
|
||||
<!-- Hero avec fanart -->
|
||||
<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"/>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-zinc-950" />
|
||||
<div v-if="data.serie.fanart_image" class="absolute inset-0 h-64 overflow-hidden pointer-events-none">
|
||||
<img
|
||||
:src="data.serie.fanart_image"
|
||||
class="w-full h-full object-cover opacity-15"
|
||||
@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-main" />
|
||||
</div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-6 pt-8 pb-10 flex gap-8">
|
||||
<div class="relative px-4 md:px-8 pt-6 pb-10 flex gap-6 min-h-[200px]">
|
||||
<!-- 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"/>
|
||||
<div v-else class="w-full aspect-[2/3] bg-zinc-900 flex items-center justify-center text-zinc-700">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2"/><path d="m9 9 6 6M15 9l-6 6"/>
|
||||
</svg>
|
||||
<div class="shrink-0 w-32 rounded-xl overflow-hidden border border-border shadow-xl hidden sm:block">
|
||||
<img v-if="data.serie.poster_image" :src="data.serie.poster_image" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full aspect-[2/3] bg-card flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<!-- Infos -->
|
||||
<div class="flex flex-col gap-3 justify-end min-w-0">
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ data.serie.title }}</h1>
|
||||
<!-- Retour -->
|
||||
<RouterLink to="/series" class="inline-flex items-center gap-1.5 text-xs text-muted hover:text-primary transition-colors w-fit">
|
||||
<ArrowLeft :size="13" />
|
||||
Séries
|
||||
</RouterLink>
|
||||
|
||||
<h1 class="text-2xl font-bold text-primary tracking-tight">{{ data.serie.title }}</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span v-if="data.serie.year" class="text-sm text-zinc-400">{{ data.serie.year }}</span>
|
||||
<span v-if="data.serie.year" class="text-sm text-muted">{{ data.serie.year }}</span>
|
||||
<span
|
||||
v-if="data.serie.status"
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium border"
|
||||
class="text-xs px-2 py-0.5 rounded-full border"
|
||||
:class="data.serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/15 text-green-400 border-green-500/20'
|
||||
: 'bg-white/5 text-zinc-500 border-white/10'"
|
||||
>{{ data.serie.status }}</span>
|
||||
: 'bg-hover text-muted border-border'"
|
||||
>
|
||||
{{ data.serie.status }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="data.serie.plot" class="text-sm text-zinc-400 leading-relaxed max-w-2xl line-clamp-3">
|
||||
|
||||
<p v-if="data.serie.plot" class="text-sm text-secondary leading-relaxed max-w-2xl line-clamp-3">
|
||||
{{ data.serie.plot }}
|
||||
</p>
|
||||
|
||||
<!-- Boutons hero : intégrale, pack_saison promu, pack_episodes -->
|
||||
<!-- Boutons intégrale -->
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<button
|
||||
v-for="(t, i) in data.torrents_integrale"
|
||||
:key="i"
|
||||
:title="t.raw"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg transition"
|
||||
:class="t.label === 'Intégrale'
|
||||
? 'bg-orange-500 hover:bg-orange-600 text-white'
|
||||
: 'bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 border border-orange-500/20'"
|
||||
:disabled="downloading === `integrale-${i}`"
|
||||
:class="[
|
||||
t.label === 'Intégrale'
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-accent-muted text-accent border border-accent/20 hover:bg-accent/20',
|
||||
(isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t))
|
||||
? 'opacity-50 cursor-not-allowed' : ''
|
||||
]"
|
||||
:disabled="isDownloading(`integrale-${i}`) || isDownloaded(`integrale-${i}`) || isAlreadyQueued(t)"
|
||||
@click="download(`integrale-${i}`, t.torrent_url, t.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `integrale-${i}`" />
|
||||
{{ t.label }}
|
||||
<EpStateIcon :state="epBtnState(`integrale-${i}`, t)" />
|
||||
{{ isAlreadyQueued(t) ? 'Déjà ajouté' : isDownloaded(`integrale-${i}`) ? 'Envoyé ✓' : t.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progression intégrale -->
|
||||
<template v-for="(t, i) in data.torrents_integrale" :key="`prog-${i}`">
|
||||
<ProgressBar :progress="torrentProgress(extractHash(t))?.progress" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seasons -->
|
||||
<div class="max-w-7xl mx-auto px-6 pb-16 flex flex-col gap-6">
|
||||
<!-- Saisons -->
|
||||
<div class="px-4 md:px-8 pb-16 flex flex-col gap-4">
|
||||
<div
|
||||
v-for="season in data.seasons"
|
||||
:key="season.id"
|
||||
class="bg-zinc-900/50 border border-white/5 rounded-2xl overflow-hidden"
|
||||
class="bg-card border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
<!-- Season header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-white/5">
|
||||
<!-- Header saison -->
|
||||
<div class="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="text-zinc-500 hover:text-zinc-100 transition" @click="toggleSeason(season.id)">
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||
<button class="text-muted hover:text-primary transition-colors" @click="toggleSeason(season.id)">
|
||||
<ChevronUp
|
||||
:size="15"
|
||||
class="transition-transform duration-200"
|
||||
:class="collapsedSeasons.has(season.id) ? '' : 'rotate-180'"
|
||||
>
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
:class="collapsedSeasons.has(season.id) ? 'rotate-180' : ''"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="font-semibold text-sm">
|
||||
<h2 class="text-sm font-semibold text-primary">
|
||||
{{ season.season_number === 0 ? 'Spéciaux' : `Saison ${season.season_number}` }}
|
||||
<span v-if="season.title && season.title !== `Saison ${season.season_number}`" class="text-zinc-500 font-normal ml-1">
|
||||
<span
|
||||
v-if="season.title && season.title !== `Saison ${season.season_number}`"
|
||||
class="text-muted font-normal ml-1"
|
||||
>
|
||||
— {{ season.title }}
|
||||
</span>
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-600 mt-0.5">
|
||||
<p class="text-xs text-muted 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]"
|
||||
: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} importé${season.organized_count > 1 ? 's' : ''}`
|
||||
: `${season.organized_count}/${seasonAvailableCount(season)} importés` }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton pack_saison sur le header -->
|
||||
<!-- Bouton pack saison -->
|
||||
<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="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition"
|
||||
:class="season.torrent && !isDownloaded(`season-${season.id}`) && !isAlreadyQueued(season.torrent) && season.organized_state !== 'complete'
|
||||
? 'bg-accent-muted text-accent border-accent/20 hover:bg-accent/20'
|
||||
: 'text-muted border-border cursor-not-allowed opacity-50'"
|
||||
:disabled="!season.torrent || isDownloading(`season-${season.id}`) || isDownloaded(`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
|
||||
<EpStateIcon :state="seasonBtnState(season)" :size="12" />
|
||||
{{ season.organized_state === 'complete' ? 'Importé' : isAlreadyQueued(season.torrent) ? 'Déjà ajouté' : isDownloaded(`season-${season.id}`) ? 'Envoyé' : 'Saison entière' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Episodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-white/[0.04]">
|
||||
<!-- Progression saison — dans le header, sous les contrôles -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(season.torrent)) && torrentProgress(extractHash(season.torrent))!.progress < 100"
|
||||
class="px-5 pb-3 -mt-1"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(season.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[11px] text-muted mt-1">{{ torrentProgress(extractHash(season.torrent))!.progress }}%</p>
|
||||
</div>
|
||||
|
||||
<!-- Épisodes -->
|
||||
<div v-if="!collapsedSeasons.has(season.id)" class="divide-y divide-border/50">
|
||||
<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-hover/50 transition-colors"
|
||||
:class="{ 'opacity-40': !ep.available && !ep.organized }"
|
||||
>
|
||||
<!-- 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}` }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
<!-- 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>
|
||||
<!-- Thumbnail -->
|
||||
<div class="shrink-0 w-20 aspect-video rounded-md overflow-hidden bg-shell hidden sm:flex items-center justify-center text-muted text-xs font-mono">
|
||||
<img v-if="ep.thumb_image" :src="ep.thumb_image" class="w-full h-full object-cover" loading="lazy" />
|
||||
<span v-else>{{ season.season_number === 0 ? 'SP' : `E${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>
|
||||
|
||||
<!-- Infos épisode -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-muted shrink-0 font-mono">
|
||||
{{ season.season_number === 0 ? 'SP' : `E${String(ep.episode_number).padStart(2, '0')}` }}
|
||||
</span>
|
||||
<span class="text-sm text-primary truncate">{{ ep.title || `Épisode ${ep.episode_number}` }}</span>
|
||||
<!-- Badge Hors Fankai -->
|
||||
<span
|
||||
v-if="ep.fankai === false || ep.torrent?.fankai === false"
|
||||
class="shrink-0 text-[10px] px-1.5 py-0.5 rounded border bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
title="Ce fichier ne provient pas du catalogue Fan-Kai officiel"
|
||||
>
|
||||
Hors Fankai
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span v-if="ep.aired" class="text-xs text-muted">{{ formatDate(ep.aired) }}</span>
|
||||
<span v-if="ep.duration" class="text-xs text-muted">{{ formatDuration(ep.duration) }}</span>
|
||||
</div>
|
||||
</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é -->
|
||||
<!-- Bouton état unique -->
|
||||
<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)"
|
||||
class="shrink-0 w-8 h-8 rounded-lg border flex items-center justify-center transition-colors"
|
||||
:class="epBtnClass(ep)"
|
||||
:disabled="!ep.torrent || ep.organized || isAlreadyQueued(ep.torrent)"
|
||||
@click="ep.torrent && !ep.organized && !isAlreadyQueued(ep.torrent) && download(`ep-${ep.id}`, ep.torrent.torrent_url, ep.torrent.magnet)"
|
||||
>
|
||||
<DownloadIcon :spinning="downloading === `ep-${ep.id}`" :size="12" />
|
||||
Ep.
|
||||
<EpStateIcon :state="epState(ep)" :size="14" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Barre progression épisode -->
|
||||
<div
|
||||
v-if="torrentProgress(extractHash(ep.torrent)) && torrentProgress(extractHash(ep.torrent))!.progress < 100"
|
||||
class="mt-1.5 sm:pl-[92px]"
|
||||
>
|
||||
<div class="h-0.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-accent rounded-full transition-all duration-500"
|
||||
:style="{ width: `${torrentProgress(extractHash(ep.torrent))!.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Toast -->
|
||||
<Transition name="toast">
|
||||
<div
|
||||
v-if="toast"
|
||||
class="fixed bottom-6 right-6 flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl text-sm font-medium z-50"
|
||||
:class="toast.type === 'success'
|
||||
? 'bg-zinc-800 border border-green-500/30 text-green-400'
|
||||
: 'bg-zinc-800 border border-red-500/30 text-red-400'"
|
||||
>
|
||||
<svg v-if="toast.type === 'success'" width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M20 6 9 17l-5-5"/>
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
|
||||
</svg>
|
||||
{{ toast.message }}
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, defineComponent, h } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ref, computed, onMounted, onUnmounted, defineComponent, h } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Tv, ChevronUp, Download, Loader, Check, X } from 'lucide-vue-next'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Season } from '@/stores/series'
|
||||
|
||||
// ── Composant icône download ──────────────────────────────────
|
||||
const DownloadIcon = defineComponent({
|
||||
props: { spinning: Boolean, size: { type: Number, default: 14 } },
|
||||
// ── Icône d'état du bouton ────────────────────────────────────
|
||||
// states: 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
const EpStateIcon = defineComponent({
|
||||
props: {
|
||||
state: { type: String, default: 'idle' },
|
||||
size : { type: Number, default: 14 },
|
||||
},
|
||||
setup(props) {
|
||||
return () => props.spinning
|
||||
? h('div', { class: `w-${props.size === 12 ? '3' : '3.5'} h-${props.size === 12 ? '3' : '3.5'} border border-current/30 border-t-current rounded-full animate-spin` })
|
||||
: h('svg', { width: props.size, height: props.size, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2' }, [
|
||||
h('path', { d: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3' })
|
||||
])
|
||||
}
|
||||
return () => {
|
||||
if (props.state === 'loading') return h(Loader, { size: props.size, class: 'animate-spin' })
|
||||
if (props.state === 'done') return h(Check, { size: props.size })
|
||||
if (props.state === 'unavailable') return h(X, { size: props.size })
|
||||
return h(Download, { size: props.size })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ── Setup ─────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
// ── Barre de progression ──────────────────────────────────────
|
||||
const ProgressBar = defineComponent({
|
||||
props: { progress: { type: Number, default: null } },
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (props.progress == null || props.progress >= 100) return null
|
||||
return h('div', { class: 'h-0.5 bg-border rounded-full overflow-hidden' }, [
|
||||
h('div', {
|
||||
class: 'h-full bg-accent rounded-full transition-all duration-500',
|
||||
style: { width: `${props.progress}%` },
|
||||
}),
|
||||
])
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
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<string[]>([])
|
||||
const downloaded = ref<string[]>([])
|
||||
|
||||
function isDownloading(key: string) { return downloading.value.includes(key) }
|
||||
function isDownloaded(key: string) { return downloaded.value.includes(key) }
|
||||
function addDownloading(key: string) { if (!downloading.value.includes(key)) downloading.value.push(key) }
|
||||
function removeDownloading(key: string) { downloading.value = downloading.value.filter(k => k !== key) }
|
||||
function addDownloaded(key: string) { if (!downloaded.value.includes(key)) downloaded.value.push(key) }
|
||||
|
||||
interface ActiveTorrent { hash: string; progress: number; state: string }
|
||||
const activeTorrents = ref<ActiveTorrent[]>([])
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const data = computed(() => store.currentSerie)
|
||||
|
||||
// ── Polling downloads ─────────────────────────────────────────
|
||||
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,
|
||||
}))
|
||||
} 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 } | null | undefined): string | null {
|
||||
if (!torrent?.magnet) return null
|
||||
const m = torrent.magnet.match(/xt=urn:btih:([a-fA-F0-9]{40})/i)
|
||||
return m ? m[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
function isAlreadyQueued(torrent: { magnet?: string | null } | null | undefined): boolean {
|
||||
const hash = extractHash(torrent)
|
||||
if (!hash) return false
|
||||
return activeTorrents.value.some(t => t.hash.toLowerCase() === hash.toLowerCase())
|
||||
}
|
||||
|
||||
// ── États boutons ─────────────────────────────────────────────
|
||||
type BtnState = 'idle' | 'loading' | 'done' | 'unavailable'
|
||||
|
||||
function epState(ep: any): BtnState {
|
||||
if (ep.organized) return 'done'
|
||||
if (isDownloaded(`ep-${ep.id}`)) return 'done'
|
||||
if (isAlreadyQueued(ep.torrent)) return 'done'
|
||||
const prog = torrentProgress(extractHash(ep.torrent))
|
||||
if (prog && prog.progress < 100) return 'loading'
|
||||
if (!ep.torrent || !ep.available) return 'unavailable'
|
||||
if (isDownloading(`ep-${ep.id}`)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnState(key: string, torrent: any): BtnState {
|
||||
if (isDownloaded(key) || isAlreadyQueued(torrent)) return 'done'
|
||||
if (isDownloading(key)) return 'loading'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function seasonBtnState(season: any): BtnState {
|
||||
if (season.organized_state === 'complete') return 'done'
|
||||
if (isDownloaded(`season-${season.id}`)) return 'done'
|
||||
if (isAlreadyQueued(season.torrent)) return 'done'
|
||||
if (isDownloading(`season-${season.id}`)) return 'loading'
|
||||
if (!season.torrent) return 'unavailable'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function epBtnClass(ep: any): string {
|
||||
const state = epState(ep)
|
||||
if (state === 'done') return 'border-green-500/30 text-green-500 bg-green-500/10 cursor-default'
|
||||
if (state === 'loading') return 'border-accent/30 text-accent bg-accent-muted cursor-default'
|
||||
if (state === 'unavailable') return 'border-border text-muted opacity-40 cursor-not-allowed'
|
||||
return 'border-border text-muted hover:border-accent hover:text-accent cursor-pointer'
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
function seasonAvailableCount(season: Season) {
|
||||
return season.episodes.filter(e => e.available).length
|
||||
return season.episodes.filter((e: any) => e.available).length
|
||||
}
|
||||
|
||||
function toggleSeason(id: number) {
|
||||
@@ -244,32 +375,44 @@ function toggleSeason(id: number) {
|
||||
else collapsedSeasons.value.add(id)
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function showToast(type: 'success' | 'error', message: string) {
|
||||
toast.value = { type, message }
|
||||
setTimeout(() => { toast.value = null }, 3500)
|
||||
}
|
||||
|
||||
async function download(key: string, torrent_url: string | null, magnet: string | null) {
|
||||
downloading.value = key
|
||||
if (isDownloading(key) || isDownloaded(key)) return
|
||||
addDownloading(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')
|
||||
removeDownloading(key)
|
||||
if (result.success) {
|
||||
addDownloaded(key)
|
||||
toast('Téléchargement lancé ✓', 'success')
|
||||
fetchActiveDownloads()
|
||||
} else {
|
||||
toast(result.error ?? 'Erreur inconnue', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
store.fetchSerieDetail(Number(route.params.id))
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return ''
|
||||
return new Date(d).toLocaleDateString('fr-FR', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active, .toast-leave-active { transition: all 0.25s ease; }
|
||||
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(6px); }
|
||||
</style>
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) return m > 0 ? `${h}h ${m}min` : `${h}h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
fetchActiveDownloads()
|
||||
pollTimer = setInterval(fetchActiveDownloads, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-3 border-b border-border flex flex-col gap-2">
|
||||
|
||||
<!-- Ligne 1 : recherche + filtres -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" :size="14" />
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Rechercher…"
|
||||
class="settings-input pl-9 w-full py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filtres : icône only sur mobile, texte sur desktop -->
|
||||
<div class="relative shrink-0" ref="filterRef">
|
||||
<button
|
||||
@click="filterOpen = !filterOpen"
|
||||
class="btn-secondary flex items-center gap-1.5 py-1.5 px-2.5 relative"
|
||||
:class="hasActiveFilter ? 'border-accent text-accent' : ''"
|
||||
title="Filtres"
|
||||
>
|
||||
<SlidersHorizontal :size="15" />
|
||||
<span class="hidden md:inline text-xs">Filtres</span>
|
||||
<ChevronDown class="hidden md:inline transition-transform" :size="12" :class="filterOpen ? 'rotate-180' : ''" />
|
||||
<span v-if="hasActiveFilter" class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="filterOpen"
|
||||
class="absolute top-full right-0 mt-1 bg-card border border-border rounded-xl p-3 z-20 w-52 flex flex-col gap-3 shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1.5">Disponibilité</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersDisponibilite" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
{{ f.label }}
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-border pt-3">
|
||||
<p class="text-xs text-muted mb-1.5">État import</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button
|
||||
v-for="f in filtersImport" :key="f.value"
|
||||
@click="activeFilter = f.value"
|
||||
class="flex items-center justify-between px-2 py-1.5 rounded-lg text-xs transition-colors"
|
||||
:class="activeFilter === f.value ? 'bg-active text-primary' : 'text-secondary hover:bg-hover'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: f.color }" />
|
||||
{{ f.label }}
|
||||
</div>
|
||||
<span v-if="activeFilter === f.value" class="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="hasActiveFilter"
|
||||
@click="activeFilter = 'all'"
|
||||
class="text-xs text-muted hover:text-primary transition-colors text-left pt-1 border-t border-border"
|
||||
>
|
||||
Effacer les filtres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ligne 2 : tri + taille + count -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="s in sortOptions" :key="s.value"
|
||||
@click="activeSort = s.value"
|
||||
class="px-2.5 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="activeSort === s.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 border-l border-border pl-2 ml-auto">
|
||||
<button
|
||||
v-for="(_, size) in posterSizes" :key="size"
|
||||
@click="posterSize = size"
|
||||
class="w-7 h-7 text-[10px] rounded-md border transition-colors flex items-center justify-center"
|
||||
:class="posterSize === size
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-xs text-muted">{{ filtered.length }} série{{ filtered.length > 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende barres -->
|
||||
<div class="shrink-0 px-4 md:px-6 py-2 flex items-center gap-4 border-b border-border">
|
||||
<div v-for="l in legend" :key="l.label" class="flex items-center gap-1.5">
|
||||
<span class="w-3 h-1.5 rounded-full" :style="{ background: l.color }" />
|
||||
<span class="text-[11px] text-muted">{{ l.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grille -->
|
||||
<div class="px-4 md:px-6 py-6">
|
||||
|
||||
<div v-if="store.loadingSeries" class="flex flex-col items-center justify-center gap-3 h-64 text-muted">
|
||||
<div class="w-6 h-6 border border-border border-t-accent rounded-full animate-spin" />
|
||||
<p class="text-sm">Chargement du catalogue…</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="flex flex-col items-center justify-center gap-3 h-64">
|
||||
<p class="text-sm text-red-400">{{ store.error }}</p>
|
||||
<button class="btn-primary" @click="store.fetchSeries()">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filtered.length === 0" class="flex items-center justify-center h-64 text-muted text-sm">
|
||||
Aucune série trouvée
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4" :style="{ gridTemplateColumns: `repeat(auto-fill, minmax(${posterSizes[posterSize]}, 1fr))` }">
|
||||
<RouterLink
|
||||
v-for="serie in filtered"
|
||||
:key="serie.id"
|
||||
:to="`/series/${serie.id}`"
|
||||
class="group flex flex-col gap-1.5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="{ 'opacity-40': !serie.has_torrents }"
|
||||
>
|
||||
<div class="relative aspect-[2/3] rounded-lg overflow-hidden bg-card border border-border">
|
||||
<img
|
||||
v-if="serie.poster_image"
|
||||
:src="serie.poster_image"
|
||||
:alt="serie.title"
|
||||
loading="lazy"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-muted">
|
||||
<Tv :size="28" />
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
||||
<span class="text-xs text-white border border-white/30 px-3 py-1 rounded-md">Voir</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 right-0 h-1"
|
||||
:style="{ background: stateColor(serie.download_state) }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium truncate leading-snug text-primary">{{ serie.title }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<span v-if="serie.year" class="text-[11px] text-muted">{{ serie.year }}</span>
|
||||
<span
|
||||
v-if="serie.status"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="serie.status.toLowerCase() === 'continuing'
|
||||
? 'bg-green-500/10 text-green-400'
|
||||
: 'bg-hover text-muted'"
|
||||
>
|
||||
{{ serie.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { Search, SlidersHorizontal, ChevronDown, Tv } from 'lucide-vue-next'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useSeriesStore } from '@/stores/series'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { usePosterSize } from '@/composables/usePosterSize'
|
||||
|
||||
defineOptions({ name: 'SeriesView' })
|
||||
|
||||
const store = useSeriesStore()
|
||||
const dlStore = useDownloadsStore()
|
||||
const { add: toast } = useToast()
|
||||
const { current: posterSize, sizes: posterSizes } = usePosterSize()
|
||||
|
||||
const search = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const activeSort = ref('alpha')
|
||||
const filterOpen = ref(false)
|
||||
const filterRef = ref<HTMLElement | null>(null)
|
||||
|
||||
onClickOutside(filterRef, () => { filterOpen.value = false })
|
||||
|
||||
const filtersDisponibilite = [
|
||||
{ label: 'Toutes', value: 'all' },
|
||||
{ label: 'Disponibles', value: 'available' },
|
||||
{ label: 'Sans torrent', value: 'unavailable' },
|
||||
]
|
||||
|
||||
const filtersImport = [
|
||||
{ label: 'Importés', value: 'complete', color: '#22c55e' },
|
||||
{ label: 'En cours', value: 'downloading', color: '#3b82f6' },
|
||||
{ label: 'Partiel', value: 'partial', color: '#9b59b6' },
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'A→Z', value: 'alpha' },
|
||||
{ label: 'Z→A', value: 'alpha-desc' },
|
||||
{ label: 'Récents', value: 'recent' },
|
||||
{ label: 'Importés', value: 'imported' },
|
||||
]
|
||||
|
||||
const legend = [
|
||||
{ label: 'Importé', color: '#22c55e' },
|
||||
{ label: 'En cours', color: '#3b82f6' },
|
||||
{ label: 'Partiel', color: '#9b59b6' },
|
||||
{ label: 'Non importé', color: 'var(--border)' },
|
||||
]
|
||||
|
||||
const hasActiveFilter = computed(() => activeFilter.value !== 'all')
|
||||
|
||||
const filtered = computed(() => {
|
||||
let list = [...store.series]
|
||||
|
||||
if (activeFilter.value === 'available') list = list.filter(s => s.has_torrents)
|
||||
if (activeFilter.value === 'unavailable') list = list.filter(s => !s.has_torrents)
|
||||
if (activeFilter.value === 'complete') list = list.filter(s => s.download_state === 'complete')
|
||||
if (activeFilter.value === 'downloading') list = list.filter(s => ['downloading', 'partial'].includes(s.download_state))
|
||||
if (activeFilter.value === 'partial') list = list.filter(s => s.download_state === 'partial')
|
||||
|
||||
if (search.value.trim()) {
|
||||
const q = search.value.toLowerCase()
|
||||
list = list.filter(s => s.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
switch (activeSort.value) {
|
||||
case 'alpha': list.sort((a, b) => a.title.localeCompare(b.title, 'fr')); break
|
||||
case 'alpha-desc': list.sort((a, b) => b.title.localeCompare(a.title, 'fr')); break
|
||||
case 'recent': list.sort((a, b) => (b.year ?? 0) - (a.year ?? 0)); break
|
||||
case 'imported': {
|
||||
const order: Record<string, number> = { complete: 0, partial: 1, downloading: 2, none: 3 }
|
||||
list.sort((a, b) => (order[a.download_state] ?? 3) - (order[b.download_state] ?? 3))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function stateColor(state: string): string {
|
||||
return ({
|
||||
complete : '#22c55e',
|
||||
downloading: '#3b82f6',
|
||||
partial : '#9b59b6',
|
||||
none : 'transparent',
|
||||
} as Record<string, string>)[state] ?? 'transparent'
|
||||
}
|
||||
|
||||
// ── Polling downloads + notifs import ────────────────────────
|
||||
let dlInterval: ReturnType<typeof setInterval> | null = null
|
||||
const seenNotifs = new Set<string>()
|
||||
|
||||
async function fetchOrganizeNotifs() {
|
||||
try {
|
||||
const res = await fetch('/api/organize/recent', { credentials: 'include' })
|
||||
if (!res.ok) return
|
||||
const notifs: any[] = await res.json()
|
||||
let hasNew = false
|
||||
for (const n of notifs) {
|
||||
const key = `${n.hash}-${n.at}`
|
||||
if (seenNotifs.has(key)) continue
|
||||
seenNotifs.add(key)
|
||||
hasNew = true
|
||||
if (n.done > 0) {
|
||||
const msg = n.errors > 0
|
||||
? `${n.name} — ${n.done} fichier(s) importé(s), ${n.errors} erreur(s)`
|
||||
: `${n.name} — ${n.done} fichier(s) importé(s) ✓`
|
||||
toast(msg, n.errors > 0 ? 'error' : 'success')
|
||||
}
|
||||
}
|
||||
if (hasNew) await store.fetchSeries()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
dlInterval = setInterval(() => {
|
||||
dlStore.refresh()
|
||||
fetchOrganizeNotifs()
|
||||
}, 10000)
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
store.fetchSeries()
|
||||
dlStore.refresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (dlInterval) clearInterval(dlInterval)
|
||||
})
|
||||
</script>
|
||||
@@ -1,401 +0,0 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto px-8 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<button
|
||||
@click="$router.push('/')"
|
||||
class="text-[#5a7a94] text-xs tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
← CATALOGUE
|
||||
</button>
|
||||
<h1 class="text-xl font-black text-white font-sans">Paramètres</h1>
|
||||
</div>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div v-if="torrentsStatus.empty" class="border border-[#e8513a]/40 bg-[#e8513a]/5 p-4 mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES MANQUANTES</div>
|
||||
<p class="text-sm text-[#a0b4c4]">
|
||||
{{ torrentsStatus.exists ? 'Le fichier de torrents est vide.' : 'Aucun fichier de torrents trouvé.' }}
|
||||
Téléchargez les données pour utiliser Fankarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating"
|
||||
class="bg-[#e8513a] text-white px-5 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer whitespace-nowrap">
|
||||
{{ updating ? 'TÉLÉCHARGEMENT...' : 'TÉLÉCHARGER' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Données torrents -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">DONNÉES</div>
|
||||
<h2 class="text-sm font-bold text-white mb-1">Torrents Fankai</h2>
|
||||
<p class="text-[11px] text-[#5a7a94] mb-5">
|
||||
{{ torrentsStatus.empty ? 'Aucune donnée chargée' : `${torrentsStatus.count} torrents chargés` }}
|
||||
</p>
|
||||
<button @click="update" :disabled="updating"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ updating ? 'MISE À JOUR...' : 'METTRE À JOUR' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Clients torrent -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">TÉLÉCHARGEMENT</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-sm font-bold text-white">Clients torrent</h2>
|
||||
<button @click="openAddModal"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-4 py-1.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
+ AJOUTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="clients.length === 0" class="text-[11px] text-[#5a7a94]">
|
||||
Aucun client configuré
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div v-for="client in clients" :key="client.uuid"
|
||||
class="flex items-center justify-between border border-[#1e2d3d] px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 rounded-full"
|
||||
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
|
||||
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-[#1e2d3d]'"/>
|
||||
<div>
|
||||
<p class="text-sm text-white font-medium">{{ client.name }}</p>
|
||||
<p class="text-[10px] text-[#5a7a94] tracking-widest mt-0.5">{{ clientLabel(client.type) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="testClient(client.uuid)"
|
||||
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#3a8fe8] transition-colors cursor-pointer px-2 py-1">
|
||||
TESTER
|
||||
</button>
|
||||
<button @click="deleteClient(client.uuid)"
|
||||
class="text-[10px] text-[#5a7a94] tracking-widest hover:text-[#e8513a] transition-colors cursor-pointer px-2 py-1">
|
||||
SUPPRIMER
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Médias & Organisation -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6 mb-4">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">MÉDIAS</div>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Organisation</h2>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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"/>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">Dossier racine de la bibliothèque Fankai dans Jellyfin</p>
|
||||
</div>
|
||||
|
||||
<!-- Mode organisation -->
|
||||
<div>
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-2">MODE</label>
|
||||
<div class="flex gap-2">
|
||||
<button @click="form.organizeMode = 'hardlink'"
|
||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||
:class="form.organizeMode === 'hardlink'
|
||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
HARDLINK
|
||||
</button>
|
||||
<button @click="form.organizeMode = 'move'"
|
||||
class="px-5 py-2 text-xs tracking-widest border transition-colors cursor-pointer"
|
||||
:class="form.organizeMode === 'move'
|
||||
? 'border-[#e8513a] text-[#e8513a] bg-[#e8513a]/5'
|
||||
: 'border-[#1e2d3d] text-[#5a7a94] hover:border-[#5a7a94]'">
|
||||
MOVE
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-[#5a7a94] mt-1.5">
|
||||
<span class="text-white">Hardlink recommandé</span> — le fichier reste dans qBittorrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compte -->
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] p-6">
|
||||
<div class="text-[10px] text-[#e8513a] tracking-widest mb-1">COMPTE</div>
|
||||
<h2 class="text-sm font-bold text-white mb-6">Session</h2>
|
||||
<button type="button" @click="logout"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#e8513a] hover:text-[#e8513a] transition-colors cursor-pointer">
|
||||
SE DÉCONNECTER
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout client -->
|
||||
<div v-if="modal.open" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 px-4">
|
||||
<div class="bg-[#0d1219] border border-[#1e2d3d] w-full max-w-md p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-sm font-bold text-white">Ajouter un client</h3>
|
||||
<button @click="closeModal" class="text-[#5a7a94] hover:text-white transition-colors cursor-pointer">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">NOM</label>
|
||||
<input v-model="modal.name" placeholder="Mon qBittorrent"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">TYPE</label>
|
||||
<select v-model="modal.type" @change="onTypeChange"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors cursor-pointer">
|
||||
<option value="">Choisir un type...</option>
|
||||
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="currentDefinition" class="flex flex-col gap-3 mb-6">
|
||||
<div v-for="field in currentDefinition.fields" :key="field.key">
|
||||
<label class="text-[10px] text-[#5a7a94] tracking-widest block mb-1.5">{{ field.label.toUpperCase() }}</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="w-full bg-[#121920] border border-[#1e2d3d] text-white px-3 py-2.5 text-sm outline-none focus:border-[#e8513a] transition-colors"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button @click="saveClient" :disabled="modal.saving || !modal.tested"
|
||||
class="bg-[#e8513a] text-white px-6 py-2.5 text-xs tracking-widest hover:opacity-85 transition-opacity disabled:opacity-50 cursor-pointer">
|
||||
{{ modal.saving ? '...' : 'ENREGISTRER' }}
|
||||
</button>
|
||||
<button @click="testNewClient" :disabled="modal.testing || !modal.type"
|
||||
class="border border-[#1e2d3d] text-[#5a7a94] px-6 py-2.5 text-xs tracking-widest hover:border-[#3a8fe8] hover:text-[#3a8fe8] transition-colors disabled:opacity-50 cursor-pointer">
|
||||
{{ modal.testing ? '...' : modal.tested ? 'TESTÉ ✓' : 'TESTER' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!modal.tested && modal.type" class="text-[10px] text-[#5a7a94] mt-3">
|
||||
Testez la connexion avant d'enregistrer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { add: toast } = useToast()
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────
|
||||
const form = ref({
|
||||
mediaPath : '/media/Kai',
|
||||
completePath: '/downloads/complete',
|
||||
organizeMode: 'hardlink' as 'hardlink' | 'move',
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const updating = ref(false)
|
||||
const torrentsStatus = ref({ exists: false, count: 0, empty: true })
|
||||
|
||||
const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : false,
|
||||
})
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────
|
||||
const currentDefinition = computed(() =>
|
||||
availableClients.value.find(d => d.id === modal.value.type) ?? null
|
||||
)
|
||||
|
||||
function clientLabel(type: string): string {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const [settingsRes, statusRes, clientsRes, availRes] = 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' }),
|
||||
])
|
||||
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()
|
||||
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
})
|
||||
|
||||
// ── Healthcheck ────────────────────────────────────────────────
|
||||
async function refreshHealth(uuid: string) {
|
||||
healthStatus.value[uuid] = null
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
||||
} catch {
|
||||
healthStatus.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test / delete clients ──────────────────────────────────────
|
||||
async function testClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
}
|
||||
|
||||
async function deleteClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
clients.value = clients.value.filter(c => c.uuid !== uuid)
|
||||
delete healthStatus.value[uuid]
|
||||
toast('Client supprimé', 'success')
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Modal ──────────────────────────────────────────────────────
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.value.open = false
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testNewClient() {
|
||||
modal.value.testing = true
|
||||
modal.value.tested = false
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients/test-config', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config })
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
modal.value.tested = ok
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
modal.value.testing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!modal.value.tested) return
|
||||
modal.value.saving = true
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name : modal.value.name || clientLabel(modal.value.type),
|
||||
type : modal.value.type,
|
||||
config: modal.value.config
|
||||
})
|
||||
})
|
||||
if (res.ok) {
|
||||
const client = await res.json()
|
||||
clients.value.push(client)
|
||||
refreshHealth(client.uuid)
|
||||
closeModal()
|
||||
toast('Client enregistré ✓', 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||
}
|
||||
} finally {
|
||||
modal.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Données torrents ───────────────────────────────────────────
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
torrentsStatus.value = { exists: true, count, empty: count === 0 }
|
||||
toast(`${count} torrents téléchargés ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sauvegarder settings ───────────────────────────────────────
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(form.value)
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.push('/auth')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Avancé</h2>
|
||||
<p class="text-sm text-muted mt-1">Options pour le débogage et le développement.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
<SettingsToggle
|
||||
v-model="devMode"
|
||||
label="Mode développeur"
|
||||
description="Affiche un panneau de debug sur le dashboard : mémoire, cache, uptime, worker."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="saved" class="text-xs text-green-400">Paramètre sauvegardé.</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
|
||||
const devMode = ref(false)
|
||||
const saved = ref(false)
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/settings', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const s = await res.json()
|
||||
devMode.value = s.devMode ?? false
|
||||
}
|
||||
})
|
||||
|
||||
watch(devMode, async (val) => {
|
||||
await fetch('/api/settings', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body : JSON.stringify({ devMode: val }),
|
||||
})
|
||||
saved.value = true
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = setTimeout(() => { saved.value = false }, 2000)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Catalogue Fankai</h2>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
Données locales synchronisées depuis GitLab. Mise à jour automatique toutes les 6h.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bannière données manquantes -->
|
||||
<div
|
||||
v-if="status.empty"
|
||||
class="border border-accent/30 bg-accent-muted rounded-lg p-4 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs text-accent font-medium mb-1">Données manquantes</p>
|
||||
<p class="text-sm text-secondary">
|
||||
{{ status.exists ? 'Le catalogue est vide.' : 'Aucun catalogue trouvé.' }}
|
||||
Téléchargez les données pour utiliser FanKarr.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary whitespace-nowrap">
|
||||
{{ updating ? 'Téléchargement...' : 'Télécharger' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statut -->
|
||||
<div class="settings-card">
|
||||
<p class="text-xs text-muted mb-1">Statut</p>
|
||||
<p class="text-sm text-primary font-medium">
|
||||
{{ status.empty ? 'Aucune donnée chargée' : `${status.count} séries disponibles` }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<button @click="update" :disabled="updating" class="btn-primary">
|
||||
{{ updating ? 'Mise à jour...' : 'Mettre à jour maintenant' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-2">Force la synchronisation sans attendre le cache de 6h.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const updating = ref(false)
|
||||
const status = ref({ exists: false, count: 0, empty: true })
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await fetch('/api/torrents/status', { credentials: 'include' })
|
||||
if (res.ok) status.value = await res.json()
|
||||
})
|
||||
|
||||
async function update() {
|
||||
updating.value = true
|
||||
try {
|
||||
const res = await fetch('/api/update', { method: 'POST', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const { count } = await res.json()
|
||||
status.value = { exists: true, count, empty: count === 0 }
|
||||
toast(`${count} séries chargées ✓`, 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? 'Erreur lors de la mise à jour', 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
updating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Clients torrent</h2>
|
||||
<p class="text-sm text-muted mt-1">Gérez vos clients de téléchargement.</p>
|
||||
</div>
|
||||
<button @click="openAddModal" class="btn-secondary">+ Ajouter</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste vide -->
|
||||
<div v-if="clients.length === 0" class="settings-card text-sm text-muted">
|
||||
Aucun client configuré.
|
||||
</div>
|
||||
|
||||
<!-- Liste clients -->
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.uuid"
|
||||
class="settings-card flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:class="healthStatus[client.uuid] === true ? 'bg-green-500' :
|
||||
healthStatus[client.uuid] === false ? 'bg-red-500' : 'bg-border'"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-primary font-medium">{{ client.name }}</p>
|
||||
<p class="text-xs text-muted mt-0.5">{{ clientLabel(client.type) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="testClient(client.uuid)" class="btn-ghost text-xs">Tester</button>
|
||||
<button @click="deleteClient(client.uuid)" class="btn-ghost text-xs text-red-400 hover:text-red-300">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal ajout -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="modal.open"
|
||||
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div class="bg-card border border-border rounded-xl w-full max-w-md p-6 flex flex-col gap-4">
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-primary">Ajouter un client</h3>
|
||||
<button @click="closeModal" class="text-muted hover:text-primary transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="settings-label">Nom</label>
|
||||
<input v-model="modal.name" placeholder="Mon client torrent" class="settings-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="settings-label">Type</label>
|
||||
<select v-model="modal.type" @change="onTypeChange" class="settings-input">
|
||||
<option value="">Choisir un type...</option>
|
||||
<option v-for="def in availableClients" :key="def.id" :value="def.id">{{ def.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="currentDefinition" v-for="field in currentDefinition.fields" :key="field.key">
|
||||
<label class="settings-label">{{ field.label }}</label>
|
||||
<input
|
||||
v-model="modal.config[field.key]"
|
||||
:type="field.type === 'password' ? 'password' : 'text'"
|
||||
:placeholder="field.placeholder ?? ''"
|
||||
class="settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!modal.tested && modal.type" class="text-xs text-muted">
|
||||
Testez la connexion avant d'enregistrer.
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button
|
||||
@click="saveClient"
|
||||
:disabled="modal.saving || !modal.tested"
|
||||
class="btn-primary"
|
||||
>
|
||||
{{ modal.saving ? '...' : 'Enregistrer' }}
|
||||
</button>
|
||||
<button
|
||||
@click="testNewClient"
|
||||
:disabled="modal.testing || !modal.type"
|
||||
class="btn-secondary"
|
||||
>
|
||||
{{ modal.testing ? '...' : modal.tested ? 'Testé ✓' : 'Tester' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const clients = ref<any[]>([])
|
||||
const availableClients = ref<any[]>([])
|
||||
const healthStatus = ref<Record<string, boolean | null>>({})
|
||||
|
||||
const modal = ref({
|
||||
open : false,
|
||||
name : '',
|
||||
type : '',
|
||||
config : {} as Record<string, string>,
|
||||
testing: false,
|
||||
saving : false,
|
||||
tested : false,
|
||||
})
|
||||
|
||||
const currentDefinition = computed(() =>
|
||||
availableClients.value.find(d => d.id === modal.value.type) ?? null
|
||||
)
|
||||
|
||||
function clientLabel(type: string) {
|
||||
return availableClients.value.find(d => d.id === type)?.label ?? type
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [clientsRes, availRes] = await Promise.all([
|
||||
fetch('/api/torrent-clients', { credentials: 'include' }),
|
||||
fetch('/api/torrent-clients/available', { credentials: 'include' }),
|
||||
])
|
||||
if (clientsRes.ok) clients.value = await clientsRes.json()
|
||||
if (availRes.ok) availableClients.value = await availRes.json()
|
||||
for (const c of clients.value) refreshHealth(c.uuid)
|
||||
})
|
||||
|
||||
async function refreshHealth(uuid: string) {
|
||||
healthStatus.value[uuid] = null
|
||||
try {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/healthcheck`, { credentials: 'include' })
|
||||
healthStatus.value[uuid] = res.ok ? (await res.json()).online : false
|
||||
} catch {
|
||||
healthStatus.value[uuid] = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}/test`, { method: 'POST', credentials: 'include' })
|
||||
const { ok, message } = await res.json()
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
healthStatus.value[uuid] = ok
|
||||
}
|
||||
|
||||
async function deleteClient(uuid: string) {
|
||||
const res = await fetch(`/api/torrent-clients/${uuid}`, { method: 'DELETE', credentials: 'include' })
|
||||
if (res.ok) {
|
||||
clients.value = clients.value.filter(c => c.uuid !== uuid)
|
||||
delete healthStatus.value[uuid]
|
||||
toast('Client supprimé', 'success')
|
||||
} else {
|
||||
toast('Erreur lors de la suppression', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modal.value = { open: true, name: '', type: '', config: {}, testing: false, saving: false, tested: false }
|
||||
}
|
||||
|
||||
function closeModal() { modal.value.open = false }
|
||||
|
||||
function onTypeChange() {
|
||||
modal.value.config = {}
|
||||
modal.value.tested = false
|
||||
const def = currentDefinition.value
|
||||
if (def) {
|
||||
for (const field of def.fields) {
|
||||
if (field.default !== undefined) modal.value.config[field.key] = String(field.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testNewClient() {
|
||||
modal.value.testing = true
|
||||
modal.value.tested = false
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients/test-config', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({ type: modal.value.type, config: modal.value.config }),
|
||||
})
|
||||
const { ok, message } = await res.json()
|
||||
modal.value.tested = ok
|
||||
toast(ok ? 'Connexion réussie ✓' : (message ?? 'Connexion échouée'), ok ? 'success' : 'error')
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
modal.value.testing = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClient() {
|
||||
if (!modal.value.tested) return
|
||||
modal.value.saving = true
|
||||
try {
|
||||
const res = await fetch('/api/torrent-clients', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
name : modal.value.name || clientLabel(modal.value.type),
|
||||
type : modal.value.type,
|
||||
config: modal.value.config,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const client = await res.json()
|
||||
clients.value.push(client)
|
||||
refreshHealth(client.uuid)
|
||||
closeModal()
|
||||
toast('Client enregistré ✓', 'success')
|
||||
} else {
|
||||
const { error } = await res.json()
|
||||
toast(error ?? "Erreur lors de l'enregistrement", 'error')
|
||||
}
|
||||
} finally {
|
||||
modal.value.saving = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-muted">{{ entries.length }} entrées · {{ formatSize(fileSize) }}</span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="load" class="btn-secondary text-xs">↺ Rafraîchir</button>
|
||||
<button @click="showConfirm = true" class="btn-ghost text-xs text-red-400 hover:text-red-300">Effacer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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-xs rounded-lg border transition-colors"
|
||||
:class="filterLevel === l.value
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ l.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-model="filterSource" @change="load()" class="settings-input w-auto text-xs py-1">
|
||||
<option value="">Toutes les sources</option>
|
||||
<option value="organize">organize</option>
|
||||
<option value="api">api</option>
|
||||
<option value="auth">auth</option>
|
||||
</select>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<span class="text-xs text-muted mr-1">Afficher</span>
|
||||
<button
|
||||
v-for="n in [100, 500, 2000]" :key="n"
|
||||
@click="limit = n; load()"
|
||||
class="px-2 py-1 text-xs rounded-lg border transition-colors"
|
||||
:class="limit === n
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
{{ n === 2000 ? 'Tout' : n }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center gap-2 py-16 text-muted text-sm">
|
||||
<div class="w-4 h-4 border border-border border-t-accent rounded-full animate-spin" />
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<!-- Vide -->
|
||||
<div v-else-if="entries.length === 0" class="flex items-center justify-center py-16 text-muted text-sm">
|
||||
Aucun log
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="settings-card p-0 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr class="border-b border-border">
|
||||
<th class="text-left text-muted px-4 py-2.5 w-36 font-normal">Horodatage</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-16 font-normal">Niveau</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 w-24 font-normal">Source</th>
|
||||
<th class="text-left text-muted px-3 py-2.5 font-normal">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(entry, i) in entries" :key="i"
|
||||
class="border-b border-border/50 hover:bg-hover transition-colors"
|
||||
:class="entry.level === 'error' ? 'bg-red-500/5' : entry.level === 'warn' ? 'bg-yellow-500/5' : ''"
|
||||
>
|
||||
<td class="px-4 py-2 text-muted whitespace-nowrap">{{ formatDate(entry.at) }}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
<span class="px-1.5 py-0.5 rounded text-[10px]" :class="levelClass(entry.level)">
|
||||
{{ entry.level.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-muted whitespace-nowrap">{{ entry.source }}</td>
|
||||
<td class="px-3 py-2 text-primary break-all">
|
||||
{{ entry.msg }}
|
||||
<span v-if="entry.meta" class="text-muted ml-2">{{ JSON.stringify(entry.meta) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm effacement -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showConfirm" class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 px-4" @click.self="showConfirm = false">
|
||||
<div class="bg-card border border-border rounded-xl p-6 max-w-sm w-full flex flex-col gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-primary mb-1">Effacer les logs ?</h3>
|
||||
<p class="text-xs text-muted">Cette action est irréversible.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="doClear" class="btn-primary bg-red-500 hover:bg-red-400">Effacer</button>
|
||||
<button @click="showConfirm = false" class="btn-secondary">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
</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
|
||||
}
|
||||
}
|
||||
|
||||
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 < 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-border text-muted',
|
||||
info : 'bg-blue-500/10 text-blue-400',
|
||||
warn : 'bg-yellow-500/10 text-yellow-400',
|
||||
error: 'bg-red-500/10 text-red-400',
|
||||
} as Record<string, string>)[level] ?? 'bg-border text-muted'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-primary">Media Management</h2>
|
||||
<p class="text-sm text-muted mt-1">Configuration des dossiers et du comportement d'import.</p>
|
||||
</div>
|
||||
|
||||
<!-- Dossiers -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Dossier téléchargements</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('completePath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="form.completePath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.completePath || '/' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-1.5">Dossier où sont déposés les fichiers téléchargés</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<label class="settings-label">Médiathèque Fankai</label>
|
||||
<span v-if="isDocker" class="text-[10px] text-accent border border-accent/40 px-1.5 py-0.5 rounded">Docker</span>
|
||||
</div>
|
||||
<button @click="openPicker('mediaPath')" class="settings-input text-left font-mono truncate w-full"
|
||||
:class="form.mediaPath ? 'text-primary' : 'text-muted'">
|
||||
{{ form.mediaPath || '/' }}
|
||||
</button>
|
||||
<p class="text-xs text-muted mt-1.5">Dossier racine de votre médiathèque (Jellyfin, Kodi, Plex…)</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Mode import -->
|
||||
<div class="settings-card flex flex-col gap-2">
|
||||
<label class="settings-label">Mode d'import</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="form.organizeMode = 'hardlink'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'hardlink'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Hardlink
|
||||
</button>
|
||||
<button
|
||||
@click="form.organizeMode = 'move'"
|
||||
class="px-4 py-2 text-xs rounded-lg border transition-colors"
|
||||
:class="form.organizeMode === 'move'
|
||||
? 'border-accent text-accent bg-accent-muted'
|
||||
: 'border-border text-muted hover:border-secondary'"
|
||||
>
|
||||
Déplacer
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted">
|
||||
<span class="text-primary">Hardlink recommandé</span> — le fichier reste dans le client torrent pour le ratio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Toggles -->
|
||||
<div class="settings-card flex flex-col gap-5">
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.autoImport"
|
||||
label="Import automatique"
|
||||
description="Importer automatiquement dès qu'un téléchargement est terminé. Vérifie toutes les 5 minutes."
|
||||
/>
|
||||
|
||||
<SettingsToggle
|
||||
v-model="form.nfoSupport"
|
||||
label="NFO / Métadonnées"
|
||||
description="Télécharge les NFO et images depuis GitLab lors de l'import (Kodi, Infuse, etc.)."
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="save" :disabled="saving" class="btn-primary">
|
||||
{{ saving ? '...' : 'Sauvegarder' }}
|
||||
</button>
|
||||
<button @click="scan" :disabled="scanning" class="btn-secondary">
|
||||
{{ scanning ? 'Scan...' : 'Analyser médiathèque' }}
|
||||
</button>
|
||||
<span
|
||||
v-if="scanResult"
|
||||
class="text-xs"
|
||||
:class="scanResult.added > 0 ? 'text-green-400' : 'text-muted'"
|
||||
>
|
||||
{{ scanResult.found }} fichiers · {{ scanResult.added }} ajoutés
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Folder Picker -->
|
||||
<FolderPicker
|
||||
v-if="picker.open"
|
||||
:initial-path="picker.currentPath"
|
||||
@select="onPickerSelect"
|
||||
@cancel="picker.open = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import SettingsToggle from '@/components/settings/SettingsToggle.vue'
|
||||
|
||||
const { add: toast } = useToast()
|
||||
|
||||
const saving = ref(false)
|
||||
const scanning = ref(false)
|
||||
const isDocker = ref(false)
|
||||
const scanResult = ref<{ found: number; added: number } | null>(null)
|
||||
|
||||
const form = ref({
|
||||
mediaPath : '',
|
||||
completePath : '',
|
||||
organizeMode : 'hardlink' as 'hardlink' | 'move',
|
||||
nfoSupport : false,
|
||||
autoImport : true,
|
||||
})
|
||||
|
||||
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[field] || picker.value.currentPath || '/' }
|
||||
}
|
||||
|
||||
function onPickerSelect(path: string) {
|
||||
form.value[picker.value.field] = path
|
||||
picker.value.open = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [settingsRes, systemRes, infoRes] = await Promise.all([
|
||||
fetch('/api/settings', { credentials: 'include' }),
|
||||
fetch('/api/system', { credentials: 'include' }),
|
||||
fetch('/api/system/info', { credentials: 'include' }),
|
||||
])
|
||||
if (settingsRes.ok) Object.assign(form.value, await settingsRes.json())
|
||||
if (systemRes.ok) isDocker.value = (await systemRes.json()).isDocker
|
||||
if (infoRes.ok) picker.value.currentPath = (await infoRes.json()).defaultPath ?? '/'
|
||||
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
||||
body: JSON.stringify(form.value),
|
||||
})
|
||||
if (res.ok) toast('Configuration sauvegardée', 'success')
|
||||
else toast('Erreur lors de la sauvegarde', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function 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 analysés — ${data.added} ajoutés`
|
||||
: `${data.found} fichiers analysés — rien de nouveau`,
|
||||
'success'
|
||||
)
|
||||
} else {
|
||||
toast("Erreur lors de l'analyse", 'error')
|
||||
}
|
||||
} catch {
|
||||
toast('Impossible de contacter le serveur', 'error')
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="px-8 py-8 max-w-4xl">
|
||||
<h1 class="text-lg font-semibold text-primary mb-6">{{ pageTitle }}</h1>
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
'settings-download' : 'Clients de téléchargement',
|
||||
'settings-media' : 'Gestion des médias',
|
||||
'settings-catalogue' : 'Catalogue Fankai',
|
||||
'settings-logs' : 'Journaux',
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => titles[route.name as string] ?? 'Paramètres')
|
||||
</script>
|
||||
+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