Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
data
|
||||
config
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Build Binaries
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Vue frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload frontend dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: dist/client/
|
||||
retention-days: 1
|
||||
|
||||
build-binaries:
|
||||
name: Build ${{ matrix.target }}
|
||||
needs: build-frontend
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: windows-x64
|
||||
os: windows-latest
|
||||
bun-target: bun-windows-x64
|
||||
binary: binaries/fankarr.exe
|
||||
archive: fankarr-windows-x64.zip
|
||||
- target: linux-x64
|
||||
os: ubuntu-latest
|
||||
bun-target: bun-linux-x64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-linux-x64.zip
|
||||
- target: macos
|
||||
os: macos-latest
|
||||
bun-target: bun-darwin-arm64
|
||||
binary: binaries/fankarr
|
||||
archive: fankarr-macos.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: public/
|
||||
|
||||
- name: Build binary (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
mkdir -p binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
|
||||
- name: Build binary (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force -Path binaries
|
||||
bun build server/index.ts --compile --target ${{ matrix.bun-target }} --outfile ${{ matrix.binary }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Build worker
|
||||
run: bun build server/organize-worker.ts --target node --outfile organize-worker.js
|
||||
|
||||
- name: Package archive (Windows)
|
||||
if: matrix.target == 'windows-x64'
|
||||
run: Compress-Archive -Path ${{ matrix.binary }}, organize-worker.js, public -DestinationPath ${{ matrix.archive }}
|
||||
shell: pwsh
|
||||
|
||||
- name: Package archive (Unix)
|
||||
if: matrix.target != 'windows-x64'
|
||||
run: |
|
||||
chmod +x ${{ matrix.binary }}
|
||||
zip -r ${{ matrix.archive }} ${{ matrix.binary }} organize-worker.js public/
|
||||
|
||||
- name: Upload archive to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.archive }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
binaries
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
||||
+158
@@ -1,5 +1,163 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ COPY --from=build /app/dist/server /app/dist/server
|
||||
COPY --from=build /app/dist/client /app/public
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
RUN mkdir -p /app/data
|
||||
RUN mkdir -p /config
|
||||
|
||||
EXPOSE 3001
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
# Fankarr
|
||||
|
||||
|
||||

|
||||

|
||||

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

|
||||

|
||||
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
**Gestionnaire de téléchargements pour le catalogue [Fankai](https://fankai.fr)**
|
||||
Inspiré de Radarr/Sonarr — interface dédiée aux éditions Kai & Yabai
|
||||
|
||||
</div>
|
||||
@@ -69,14 +68,9 @@ Fankarr récupère ce fichier au démarrage et via le bouton **Mettre à jour**
|
||||
|
||||
## Installation
|
||||
|
||||
### Prérequis
|
||||
### Docker Compose (recommandé)
|
||||
|
||||
- Docker + Docker Compose
|
||||
- qBittorrent accessible en réseau
|
||||
- Un dossier de téléchargements complétés (`/downloads/complete` ou équivalent)
|
||||
- Un dossier médiathèque pour Jellyfin (`/media/Kai` ou équivalent)
|
||||
|
||||
### Docker Compose
|
||||
**Prérequis** : Docker + Docker Compose, qBittorrent accessible en réseau.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -88,11 +82,10 @@ services:
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre médiathèque (voir note hardlinks)
|
||||
ports:
|
||||
- 3001:3001
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -100,21 +93,51 @@ services:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:3001`.
|
||||
> **⚠️ Note sur les hardlinks** : Pour que le mode `hardlink` fonctionne, vos dossiers de téléchargement complets et votre médiathèque doivent être sur le **même filesystem**. La solution recommandée est de monter un seul volume racine (ex: `/votre/chemin:/media`) et de configurer vos chemins à l'intérieur — comme le font Radarr et Sonarr.
|
||||
>
|
||||
> Vous pouvez tout à fait monter plusieurs volumes si vous le souhaitez, mais dans ce cas les hardlinks ne seront pas possibles entre deux volumes différents et Fankarr basculera automatiquement en copie.
|
||||
|
||||
### Binaire autonome (Windows / Linux / macOS)
|
||||
|
||||
Téléchargez l'archive correspondant à votre système depuis les [Releases GitHub](https://github.com/masutayunikon/fankarr/releases/latest) :
|
||||
|
||||
| Système | Archive |
|
||||
| --------------------- | ------------------------- |
|
||||
| Windows x64 | `fankarr-windows-x64.zip` |
|
||||
| Linux x64 | `fankarr-linux-x64.zip` |
|
||||
| macOS (Apple Silicon) | `fankarr-macos.zip` |
|
||||
|
||||
Extrayez l'archive — elle contient le binaire **et** le dossier `public/` qui doivent rester ensemble :
|
||||
|
||||
```
|
||||
fankarr-windows-x64/
|
||||
├── fankarr.exe ← lancer ce fichier
|
||||
└── public/ ← ne pas déplacer séparément
|
||||
```
|
||||
|
||||
**Linux / macOS** : rendre le binaire exécutable avant le premier lancement :
|
||||
|
||||
```bash
|
||||
chmod +x fankarr
|
||||
./fankarr
|
||||
```
|
||||
|
||||
Fankarr sera accessible sur `http://localhost:9898`. Les données (config, logs, base de données) sont sauvegardées dans un dossier `data/` créé automatiquement à côté du binaire.
|
||||
|
||||
### Variables d'environnement
|
||||
|
||||
| Variable | Défaut | Description |
|
||||
|---|---|---|
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `data/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
| Variable | Défaut | Description |
|
||||
| ---------------- | ------------ | ----------------------------------------------------------------------- |
|
||||
| `PUID` | `1000` | UID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `PGID` | `1000` | GID utilisateur pour les permissions fichiers (Docker) |
|
||||
| `TZ` | — | Timezone (ex: `Europe/Paris`) |
|
||||
| `PORT` | `9898` | Port d'écoute du serveur |
|
||||
| `JWT_SECRET` | auto-généré | Secret JWT — généré automatiquement dans `/config/secret.key` si absent |
|
||||
| `GITHUB_RAW_URL` | repo scraper | URL du `torrent_final.json` à utiliser |
|
||||
|
||||
### Premier démarrage
|
||||
|
||||
1. Ouvrir `http://localhost:3001`
|
||||
1. Ouvrir `http://localhost:9898`
|
||||
2. Créer un mot de passe à la première connexion
|
||||
3. Aller dans **Paramètres** → ajouter votre client qBittorrent
|
||||
4. Vérifier les chemins `completePath` et `mediaPath`
|
||||
@@ -130,7 +153,7 @@ Fankarr organise automatiquement les fichiers terminés toutes les **30 secondes
|
||||
```
|
||||
/media/Kai/
|
||||
└── Black Lagoon Henshū/
|
||||
└── Saison 001/
|
||||
└── Saison 1/
|
||||
├── Black Lagoon Henshū S01E01.mkv
|
||||
├── Black Lagoon Henshū S01E02.mkv
|
||||
└── ...
|
||||
@@ -152,13 +175,13 @@ Fankarr est disponible dans l'appstore personnalisé. Pour l'installer :
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologie |
|
||||
|---|---|
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging | Docker (`node:20-slim`) + pnpm |
|
||||
| Permissions | gosu (PUID/PGID) |
|
||||
| Couche | Technologie |
|
||||
| ----------------- | ---------------------------------------- |
|
||||
| Frontend | Vue 3 + Vite + Tailwind CSS |
|
||||
| Backend | Express 5 + TypeScript |
|
||||
| Auth | JWT + bcrypt |
|
||||
| Packaging Docker | `node:20-slim` + pnpm + gosu (PUID/PGID) |
|
||||
| Packaging binaire | Bun (self-contained, pas de Node requis) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+14
-15
@@ -1,15 +1,14 @@
|
||||
services:
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./data:/app/data # Config, logs, base de données
|
||||
- /votre/chemin/media:/media/Kai # Destination Jellyfin
|
||||
- /votre/chemin/complete:/downloads/complete # Dossier complétés qBittorrent
|
||||
ports:
|
||||
- 3001:3001
|
||||
restart: unless-stopped
|
||||
fankarr:
|
||||
image: masutayunikon/fankarr:latest
|
||||
container_name: fankarr
|
||||
environment:
|
||||
- PUID=1000 # UID de votre utilisateur (id -u)
|
||||
- PGID=1000 # GID de votre utilisateur (id -g)
|
||||
- TZ=Europe/Paris # Votre timezone
|
||||
volumes:
|
||||
- ./config:/config # Config, logs, base de données
|
||||
- /votre/chemin:/media # Racine de votre mediatheque (hardlinks compatibles)
|
||||
ports:
|
||||
- 9898:9898
|
||||
restart: unless-stopped
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ PGID=${PGID:-1000}
|
||||
echo "[fankarr] Running as UID=${PUID} GID=${PGID}"
|
||||
|
||||
# Corriger les permissions du dossier data
|
||||
chown -R "$PUID:$PGID" /app/data
|
||||
chown -R "$PUID:$PGID" /config
|
||||
|
||||
exec gosu "$PUID:$PGID" node dist/server/index.js
|
||||
+10
-5
@@ -7,12 +7,16 @@
|
||||
"dev": "concurrently \"vite\" \"tsx watch server/index.ts\"",
|
||||
"dev:server": "tsx watch server/index.ts",
|
||||
"dev:client": "vite",
|
||||
"build": "vite build --outDir dist/client && tsc -p tsconfig.server.json",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"build:bun:linux": "bun build server/index.ts --compile --target bun-linux-x64 --outfile binaries/fankarr-linux-x64 && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:windows": "bun build server/index.ts --compile --target bun-windows-x64 --outfile binaries/fankarr-windows-x64.exe && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:macos": "bun build server/index.ts --compile --target bun-darwin-arm64 --outfile binaries/fankarr-macos && bun build server/organize-worker.ts --target node --outfile binaries/organize-worker.js",
|
||||
"build:bun:all": "pnpm build:bun:linux && pnpm build:bun:windows && pnpm build:bun:macos",
|
||||
"start": "node dist/server/index.js",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
@@ -20,10 +24,10 @@
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"vue": "^3.5.25",
|
||||
"vue-router": "^5.0.3"
|
||||
"vue-router": "^5.0.3",
|
||||
"bcryptjs": "^3.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
@@ -34,6 +38,7 @@
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.1.5"
|
||||
"vue-tsc": "^3.1.5",
|
||||
"@types/bcrypt": "^5.0.2"
|
||||
}
|
||||
}
|
||||
Generated
+7
-22
@@ -11,9 +11,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
bcryptjs:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
cookie-parser:
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7
|
||||
@@ -650,9 +650,9 @@ packages:
|
||||
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
bcrypt@6.0.0:
|
||||
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
|
||||
engines: {node: '>= 18'}
|
||||
bcryptjs@3.0.3:
|
||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||
hasBin: true
|
||||
|
||||
birpc@2.9.0:
|
||||
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
|
||||
@@ -1087,10 +1087,6 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-addon-api@8.6.0:
|
||||
resolution: {integrity: sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==}
|
||||
engines: {node: ^18 || ^20 || >= 21}
|
||||
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
@@ -1100,10 +1096,6 @@ packages:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-gyp-build@4.8.4:
|
||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||
hasBin: true
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1919,10 +1911,7 @@ snapshots:
|
||||
'@babel/parser': 7.29.0
|
||||
ast-kit: 2.2.0
|
||||
|
||||
bcrypt@6.0.0:
|
||||
dependencies:
|
||||
node-addon-api: 8.6.0
|
||||
node-gyp-build: 4.8.4
|
||||
bcryptjs@3.0.3: {}
|
||||
|
||||
birpc@2.9.0: {}
|
||||
|
||||
@@ -2351,8 +2340,6 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-addon-api@8.6.0: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
node-fetch@3.3.2:
|
||||
@@ -2361,8 +2348,6 @@ snapshots:
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-gyp-build@4.8.4: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
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'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'auth.json')
|
||||
const DATA_PATH = path.join(process.cwd(), 'config', 'auth.json')
|
||||
const SALT_ROUNDS = 10
|
||||
|
||||
interface AuthData {
|
||||
|
||||
+113
-20
@@ -15,10 +15,24 @@ import { logger, readLogs, clearLogs, logsFileSize } from './logger.js'
|
||||
|
||||
registerDriver(qbittorrentDriver)
|
||||
|
||||
// ── Base directory ─────────────────────────────────────────────
|
||||
// Binaire Bun compilé : data/ et public/ sont à côté de l'exécutable
|
||||
// Docker / dev : relatif au cwd (comportement inchangé)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
export const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
// DATA_DIR : /config
|
||||
export const DATA_DIR = fs.existsSync('/.dockerenv')
|
||||
? '/config'
|
||||
: path.join(BASE_DIR, 'config')
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const PORT = Number(process.env.PORT) || 9898
|
||||
const FANKAI_API = 'https://metadata.fankai.fr'
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const TORRENTS_PATH = path.join(DATA_DIR, 'torrent_final.json')
|
||||
const GITHUB_RAW_URL = process.env.GITHUB_RAW_URL
|
||||
?? 'https://raw.githubusercontent.com/masutayunikon/fankarr-scraper/main/data/torrent_final.json'
|
||||
|
||||
@@ -26,7 +40,7 @@ app.use(express.json())
|
||||
app.use(cookieParser())
|
||||
|
||||
// ── Static frontend (prod) ─────────────────────────────────────
|
||||
const PUBLIC_PATH = path.join(process.cwd(), 'public')
|
||||
const PUBLIC_PATH = path.join(BASE_DIR, 'public')
|
||||
if (fs.existsSync(PUBLIC_PATH)) {
|
||||
app.use(express.static(PUBLIC_PATH))
|
||||
}
|
||||
@@ -42,8 +56,8 @@ app.get('/api/settings', requireAuth, (_req, res) => {
|
||||
res.json(readSettings())
|
||||
})
|
||||
app.post('/api/settings', requireAuth, (req, res) => {
|
||||
const { mediaPath, completePath, organizeMode, category } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category }))
|
||||
const { mediaPath, completePath, organizeMode, category, nfoSupport } = req.body
|
||||
res.json(writeSettings({ mediaPath, completePath, organizeMode, category, nfoSupport }))
|
||||
})
|
||||
|
||||
// ── Torrent clients ────────────────────────────────────────────
|
||||
@@ -98,6 +112,15 @@ async function fankaiGet(endpoint: string): Promise<any> {
|
||||
if (!res.ok) throw new Error(`Fankai API ${res.status}: ${endpoint}`)
|
||||
return res.json()
|
||||
}
|
||||
function normalizeTitle(s: string): string {
|
||||
return s.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '') // strip accents (ï→i, é→e...)
|
||||
.replace(/[^a-z0-9]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
const map = new Map<number, any[]>()
|
||||
for (const t of torrents) {
|
||||
@@ -108,6 +131,18 @@ function indexBySerie(torrents: any[]): Map<number, any[]> {
|
||||
return map
|
||||
}
|
||||
|
||||
function indexByNormalizedTitle(torrents: any[]): Map<string, any[]> {
|
||||
const map = new Map<string, any[]>()
|
||||
for (const t of torrents) {
|
||||
const title = t.serie_title || t.show_title
|
||||
if (!title) continue
|
||||
const key = normalizeTitle(title)
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(t)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// ── Series ─────────────────────────────────────────────────────
|
||||
|
||||
function computeSerieDownloadState(
|
||||
@@ -145,12 +180,13 @@ function computeSerieDownloadState(
|
||||
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 byId = indexBySerie(torrents)
|
||||
const byNormTitle = indexByNormalizedTitle(torrents)
|
||||
|
||||
// Charger organized.json
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -166,9 +202,30 @@ app.get('/api/series', requireAuth, async (_req, res) => {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const seriesList = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
const seriesRaw = Array.isArray(apiData) ? apiData : (apiData.series ?? [])
|
||||
|
||||
// Dédupliquer les séries Kaï/Kai — garder celle qui a des torrents, sinon la plus récente (ID le plus grand)
|
||||
const seenTitles = new Map<string, any>()
|
||||
for (const serie of seriesRaw) {
|
||||
const key = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||
if (!seenTitles.has(key)) {
|
||||
seenTitles.set(key, serie)
|
||||
} else {
|
||||
const existing = seenTitles.get(key)!
|
||||
const existingHasTorrents = (byId.get(existing.id) ?? byNormTitle.get(key) ?? []).length > 0
|
||||
const newHasTorrents = (byId.get(serie.id) ?? byNormTitle.get(key) ?? []).length > 0
|
||||
// Préférer celle qui a des torrents, sinon la plus récente
|
||||
if (!existingHasTorrents && newHasTorrents) seenTitles.set(key, serie)
|
||||
else if (existingHasTorrents === newHasTorrents && serie.id > existing.id) seenTitles.set(key, serie)
|
||||
}
|
||||
}
|
||||
const seriesList = [...seenTitles.values()]
|
||||
|
||||
res.json({ series: seriesList.map((serie: any) => {
|
||||
const serieTorrents = byId.get(serie.id) ?? []
|
||||
// Fallback par titre normalisé si l'ID ne matche pas (Kaï→Kai migration)
|
||||
const serieTorrents = byId.get(serie.id)
|
||||
?? byNormTitle.get(normalizeTitle(serie.title ?? serie.show_title ?? ''))
|
||||
?? []
|
||||
return {
|
||||
...serie,
|
||||
torrent_count : serieTorrents.length,
|
||||
@@ -191,10 +248,19 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
return { ...season, episodes: Array.isArray(epsData) ? epsData : (epsData.episodes ?? []) }
|
||||
}))
|
||||
|
||||
const torrents = readTorrents().filter(t => t.serie_id === id)
|
||||
const allTorrents = readTorrents()
|
||||
const serieMeta = allTorrents.find(t => t.serie_id === id)
|
||||
const normApiTitle = normalizeTitle(serie.title ?? serie.show_title ?? '')
|
||||
const torrents = allTorrents.filter(t =>
|
||||
t.serie_id === id ||
|
||||
normalizeTitle(t.serie_title ?? t.show_title ?? '') === normApiTitle
|
||||
)
|
||||
const allSeasonIds = new Set<number>(seasonsWithEpisodes.filter((s: any) => s.season_number !== 0).map((s: any) => s.id))
|
||||
const availableEpisodeIds = new Set<number>()
|
||||
// Fallback par season_number+episode_number pour les séries migrées Kaï→Kai
|
||||
const availableBySnEn = new Set<string>() // "sn:en"
|
||||
const episodeTorrentMap: Record<number, any> = {}
|
||||
const episodeTorrentMapBySnEn: Record<string, any> = {}
|
||||
const seasonTorrentMap : Record<number, any> = {}
|
||||
const integraleTorrents: any[] = []
|
||||
const packEpisodesTorrents: any[] = []
|
||||
@@ -203,7 +269,12 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
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 snEnKey = `${ep.season_number}:${ep.episode_number}`
|
||||
availableBySnEn.add(snEnKey)
|
||||
if (t.type === 'episode') {
|
||||
episodeTorrentMap[ep.episode_id] = ref
|
||||
episodeTorrentMapBySnEn[snEnKey] = ref
|
||||
}
|
||||
}
|
||||
if (t.type === 'pack_saison') {
|
||||
const rs: any[] = t.resolved_seasons ?? []
|
||||
@@ -256,7 +327,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
// Charger organized.json pour les badges d'état
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -264,6 +335,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
// Construire un set des episode_id organisés
|
||||
// On croise resolved_episodes de chaque torrent avec organized.json
|
||||
const organizedEpisodeIds = new Set<number>()
|
||||
const organizedBySnEn = new Set<string>()
|
||||
for (const t of torrents) {
|
||||
const hash = t.infohash?.toLowerCase()
|
||||
const files = t.torrent_files ?? []
|
||||
@@ -277,6 +349,7 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
)
|
||||
if (epFile ? orgFiles[epFile.filename] : Object.keys(orgFiles).length > 0) {
|
||||
organizedEpisodeIds.add(ep.episode_id)
|
||||
organizedBySnEn.add(`${ep.season_number}:${ep.episode_number}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,9 +357,13 @@ app.get('/api/series/:id', requireAuth, async (req, res) => {
|
||||
const enrichedSeasons = seasonsWithEpisodes.map((season: any) => {
|
||||
const eps = season.episodes.map((ep: any) => ({
|
||||
...ep,
|
||||
available : availableEpisodeIds.has(ep.id),
|
||||
torrent : episodeTorrentMap[ep.id] ?? null,
|
||||
organized : organizedEpisodeIds.has(ep.id),
|
||||
available : availableEpisodeIds.has(ep.id)
|
||||
|| availableBySnEn.has(`${season.season_number}:${ep.episode_number}`),
|
||||
torrent : episodeTorrentMap[ep.id]
|
||||
?? episodeTorrentMapBySnEn[`${season.season_number}:${ep.episode_number}`]
|
||||
?? null,
|
||||
organized : organizedEpisodeIds.has(ep.id)
|
||||
|| organizedBySnEn.has(`${season.season_number}:${ep.episode_number}`),
|
||||
}))
|
||||
// State saison : none / partial / complete
|
||||
const total = eps.filter((e: any) => e.available).length
|
||||
@@ -332,14 +409,14 @@ app.get('/api/downloads', requireAuth, async (_req, res) => {
|
||||
// Enrichir avec l'état d'organisation
|
||||
let organized: Record<string, Record<string, string>> = {}
|
||||
try {
|
||||
const orgPath = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const orgPath = path.join(DATA_DIR, 'organized.json')
|
||||
if (fs.existsSync(orgPath))
|
||||
organized = JSON.parse(fs.readFileSync(orgPath, 'utf-8'))
|
||||
} catch {}
|
||||
|
||||
let torrentFinal: any[] = []
|
||||
try {
|
||||
const tfPath = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const tfPath = path.join(process.cwd(), 'config', 'torrent_final.json')
|
||||
if (fs.existsSync(tfPath))
|
||||
torrentFinal = JSON.parse(fs.readFileSync(tfPath, 'utf-8'))
|
||||
} catch {}
|
||||
@@ -478,7 +555,7 @@ app.post('/api/update', requireAuth, async (_req, res) => {
|
||||
app.post('/api/scan', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const { mediaPath } = readSettings()
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const result = await scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH)
|
||||
logger.info('api', `Scan manuel: ${result.found} fichiers scannés, ${result.added} ajoutés`)
|
||||
res.json({ ok: true, ...result })
|
||||
@@ -495,7 +572,23 @@ if (fs.existsSync(PUBLIC_PATH)) {
|
||||
}
|
||||
|
||||
// ── Start ──────────────────────────────────────────────────────
|
||||
app.listen(PORT, () => {
|
||||
app.listen(PORT, async () => {
|
||||
// Télécharger torrent_final.json si absent
|
||||
if (!fs.existsSync(TORRENTS_PATH)) {
|
||||
try {
|
||||
console.log('[fankarr] torrent_final.json absent → téléchargement...')
|
||||
const response = await fetch(GITHUB_RAW_URL)
|
||||
if (!response.ok) throw new Error(`GitHub a répondu ${response.status}`)
|
||||
const data = await response.json() as unknown[]
|
||||
if (!Array.isArray(data)) throw new Error('Format invalide')
|
||||
fs.mkdirSync(path.dirname(TORRENTS_PATH), { recursive: true })
|
||||
fs.writeFileSync(TORRENTS_PATH, JSON.stringify(data, null, 2), 'utf-8')
|
||||
console.log(`[fankarr] ${data.length} torrents téléchargés`)
|
||||
} catch (err) {
|
||||
console.warn(`[fankarr] ⚠ Téléchargement échoué: ${err instanceof Error ? err.message : err}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8'))
|
||||
const count = Array.isArray(data) ? data.length : 0
|
||||
@@ -505,7 +598,7 @@ app.listen(PORT, () => {
|
||||
console.log(`[fankarr] Serveur sur http://localhost:${PORT}`)
|
||||
|
||||
// Scan initial du mediaPath pour reconnaître les fichiers déjà présents
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
const ORGANIZED_PATH = path.join(DATA_DIR, 'organized.json')
|
||||
const { mediaPath } = readSettings()
|
||||
scanMediaPath(mediaPath, TORRENTS_PATH, ORGANIZED_PATH).catch(err =>
|
||||
logger.error('organize', `scanMediaPath échoué: ${err}`)
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ export interface LogEntry {
|
||||
meta? : Record<string, any>
|
||||
}
|
||||
|
||||
const LOGS_PATH = path.join(process.cwd(), 'data', 'logs.jsonl')
|
||||
const LOGS_PATH = path.join(process.cwd(), 'config', 'logs.jsonl')
|
||||
const MAX_LINES = 2000 // rotation auto au-delà
|
||||
const IS_PROD = process.env.NODE_ENV === 'production'
|
||||
|
||||
|
||||
+71
-13
@@ -16,8 +16,15 @@ import fsp from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { parentPort } from 'worker_threads'
|
||||
|
||||
const TORRENTS_PATH = path.join(process.cwd(), 'data', 'torrent_final.json')
|
||||
const ORGANIZED_PATH = path.join(process.cwd(), 'data', 'organized.json')
|
||||
// Base directory : binaire Bun ou cwd (Docker/dev)
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
|
||||
const TORRENTS_PATH = path.join(BASE_DIR, 'config', 'torrent_final.json')
|
||||
const ORGANIZED_PATH = path.join(BASE_DIR, 'config', 'organized.json')
|
||||
|
||||
// ─── Utils log ────────────────────────────────────────────────
|
||||
// Le worker tourne dans un thread séparé — il passe les logs au thread principal
|
||||
@@ -32,7 +39,7 @@ function debug(msg: string) { parentPort?.postMessage({ type: 'log', level: 'deb
|
||||
|
||||
function readSettings(): { mediaPath: string; completePath: string; organizeMode: string; nfoSupport: boolean } {
|
||||
try {
|
||||
const p = path.join(process.cwd(), 'data', 'settings.json')
|
||||
const p = path.join(process.cwd(), 'config', '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 } }
|
||||
@@ -132,18 +139,25 @@ async function downloadGitlabFolder(serieTitle: string, destRoot: string): Promi
|
||||
|
||||
log(`[nfo] Récupération dossier GitLab: ${gitlabTitle}`)
|
||||
|
||||
// Lister récursivement tous les fichiers du dossier
|
||||
let files: any[]
|
||||
// Lister récursivement tous les fichiers du dossier (pagination GitLab)
|
||||
let files: any[] = []
|
||||
try {
|
||||
files = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&ref=main`
|
||||
)
|
||||
let page = 1
|
||||
while (true) {
|
||||
const batch = await fetchJson(
|
||||
`${GITLAB_API}/tree?path=${encodeURIComponent(folderPath)}&recursive=true&per_page=100&page=${page}&ref=main`
|
||||
)
|
||||
if (!Array.isArray(batch) || batch.length === 0) break
|
||||
files.push(...batch)
|
||||
if (batch.length < 100) break
|
||||
page++
|
||||
}
|
||||
} catch (err) {
|
||||
warn(`[nfo] Dossier GitLab introuvable pour "${gitlabTitle}": ${err instanceof Error ? err.message : err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
if (files.length === 0) {
|
||||
warn(`[nfo] Aucun fichier trouvé sur GitLab pour "${gitlabTitle}"`)
|
||||
return
|
||||
}
|
||||
@@ -211,8 +225,35 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
|
||||
// ── Téléchargement NFO/images depuis GitLab ───────────────
|
||||
if (nfoSupport) {
|
||||
const serieDestRoot = path.join(mediaPath, serieTitle)
|
||||
await downloadGitlabFolder(serieTitle, serieDestRoot)
|
||||
// Collecter tous les titres de séries distincts depuis resolved_episodes
|
||||
// (un pack_integrale peut mélanger plusieurs séries, ex: One Piece Yabai + Kaï)
|
||||
const serieTitlesMap = new Map<number, string>()
|
||||
|
||||
// Titre principal du torrent
|
||||
serieTitlesMap.set(torrent.serie_id ?? 0, serieTitle)
|
||||
|
||||
// Titres alternatifs depuis resolved_episodes
|
||||
// resolved_episodes n'a pas toujours serie_title → on cherche dans torrent_final.json
|
||||
const allTorrents: any[] = (() => {
|
||||
try { return JSON.parse(fs.readFileSync(TORRENTS_PATH, 'utf-8')) } catch { return [] }
|
||||
})()
|
||||
const serieIdToTitle = new Map<number, string>()
|
||||
for (const t of allTorrents) {
|
||||
if (t.serie_id && t.serie_title) serieIdToTitle.set(t.serie_id, t.serie_title)
|
||||
}
|
||||
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.serie_id && !serieTitlesMap.has(ep.serie_id)) {
|
||||
const title = ep.serie_title ?? serieIdToTitle.get(ep.serie_id)
|
||||
if (title) serieTitlesMap.set(ep.serie_id, title)
|
||||
}
|
||||
}
|
||||
|
||||
// Télécharger NFO pour chaque série distincte
|
||||
for (const title of serieTitlesMap.values()) {
|
||||
const serieDestRoot = path.join(mediaPath, title)
|
||||
await downloadGitlabFolder(title, serieDestRoot)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cas fichier unique ────────────────────────────────────
|
||||
@@ -249,10 +290,27 @@ async function organizeTorrent(hash: string, name: string, savePath: string) {
|
||||
}
|
||||
|
||||
// ── Map filename → season ─────────────────────────────────
|
||||
// Priorité : season_number dans torrent_files (packs multi-saisons)
|
||||
// Fallback : resolved_episodes matchés par episode_number (torrents individuels)
|
||||
const filenameSeasonMap = new Map<string, number>()
|
||||
|
||||
// Index resolved_episodes par episode_number pour lookup rapide
|
||||
const epByNum = new Map<number, number>() // ep_number → season_number
|
||||
for (const ep of torrent.resolved_episodes ?? []) {
|
||||
if (ep.filename && ep.season_number !== undefined)
|
||||
filenameSeasonMap.set(ep.filename, ep.season_number)
|
||||
if (ep.episode_number !== undefined && ep.season_number !== undefined)
|
||||
epByNum.set(Number(ep.episode_number), ep.season_number)
|
||||
}
|
||||
|
||||
for (const tf of torrentFiles) {
|
||||
const sn: number | null = tf.season_number != null ? Number(tf.season_number) : null
|
||||
const tfNum = tf.num !== undefined ? Number(tf.num) : undefined
|
||||
if (sn !== null) {
|
||||
filenameSeasonMap.set(tf.filename, sn)
|
||||
} else if (tfNum !== undefined && epByNum.has(tfNum)) {
|
||||
filenameSeasonMap.set(tf.filename, epByNum.get(tfNum)!)
|
||||
} else if (torrent.season_number !== undefined) {
|
||||
filenameSeasonMap.set(tf.filename, torrent.season_number)
|
||||
}
|
||||
}
|
||||
|
||||
result.total = torrentFiles.length
|
||||
|
||||
+28
-11
@@ -13,6 +13,19 @@ import { logger } from './logger.js'
|
||||
// Un seul worker à la fois
|
||||
let workerRunning = false
|
||||
|
||||
// Résolution du chemin du worker
|
||||
// En binaire Bun compilé : le worker est à côté de l'exécutable
|
||||
// En Docker/dev : à côté du fichier courant
|
||||
function resolveWorkerPath(): string {
|
||||
// Si Bun est présent → le worker est toujours à côté de l'exécutable
|
||||
// (que ce soit un binaire compilé ou `bun run` en dev)
|
||||
// En Node/tsx → on utilise import.meta.url
|
||||
if (typeof (globalThis as any).Bun !== 'undefined') {
|
||||
return path.join(path.dirname((process as any).execPath), 'organize-worker.js')
|
||||
}
|
||||
return path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
}
|
||||
|
||||
export interface OrganizeResult {
|
||||
total : number
|
||||
skipped : number
|
||||
@@ -112,7 +125,7 @@ export async function autoOrganizeAll(
|
||||
onResult?: (r: { hash: string; name: string; done: number; skipped: number; errors: number; errorFiles: { file: string; error: string }[] }) => void
|
||||
): Promise<void> {
|
||||
if (workerRunning) {
|
||||
logger.debug('organize', 'Worker déjà en cours, skip')
|
||||
console.log('[organize] Worker déjà en cours, skip')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +133,7 @@ export async function autoOrganizeAll(
|
||||
try {
|
||||
torrents = await listFn()
|
||||
} catch (err) {
|
||||
logger.error('organize', "Impossible de récupérer la liste des torrents: " + (err instanceof Error ? err.message : String(err)))
|
||||
console.error('[organize] Impossible de récupérer la liste des torrents:', err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,18 +142,20 @@ export async function autoOrganizeAll(
|
||||
const seedingCount = torrents.filter(t => t.state === 'seeding').length
|
||||
if (seedingCount === 0) return
|
||||
|
||||
logger.info('organize', `Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
console.log(`[organize] Lancement worker (${seedingCount} torrents en seeding)`)
|
||||
workerRunning = true
|
||||
|
||||
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
const workerPath = resolveWorkerPath()
|
||||
|
||||
const worker = new Worker(workerPath)
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
logger.info('organize', `${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
console.log(`[organize] ${msg.name} → ${msg.done} OK, ${msg.skipped} skippés, ${msg.errors.length} erreurs`)
|
||||
onResult?.({
|
||||
hash : msg.hash,
|
||||
name : msg.name,
|
||||
@@ -150,19 +165,19 @@ export async function autoOrganizeAll(
|
||||
errorFiles : msg.errors,
|
||||
})
|
||||
} else if (msg.type === 'done') {
|
||||
logger.debug('organize', 'Worker terminé')
|
||||
console.log('[organize] Worker terminé')
|
||||
workerRunning = false
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err) => {
|
||||
logger.error('organize', `Worker erreur: ${err instanceof Error ? err.message : err}`)
|
||||
console.error('[organize] Worker erreur:', err)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
worker.on('exit', (code) => {
|
||||
if (code !== 0) logger.error('organize', `Worker exit code ${code}`)
|
||||
if (code !== 0) console.error(`[organize] Worker exit code ${code}`)
|
||||
workerRunning = false
|
||||
})
|
||||
|
||||
@@ -180,7 +195,7 @@ export async function organizeTorrent(
|
||||
savePath: string
|
||||
): Promise<OrganizeResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'organize-worker.js')
|
||||
const workerPath = resolveWorkerPath()
|
||||
const worker = new Worker(workerPath)
|
||||
|
||||
// On envoie un seul torrent "fictif seeding"
|
||||
@@ -189,7 +204,9 @@ export async function organizeTorrent(
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'log') {
|
||||
logger[msg.level as 'debug'|'info'|'warn'|'error']('organize', msg.msg)
|
||||
if (msg.level === 'error') console.error(msg.msg)
|
||||
else if (msg.level === 'warn') console.warn(msg.msg)
|
||||
else console.log(msg.msg)
|
||||
} else if (msg.type === 'result') {
|
||||
result.total = msg.total
|
||||
result.skipped = msg.skipped
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import fs from 'fs'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
|
||||
const SECRET_PATH = path.join(process.cwd(), 'data', 'secret.key')
|
||||
const SECRET_PATH = path.join(process.cwd(), 'config', 'secret.key')
|
||||
|
||||
function generateSecret(): string {
|
||||
return crypto.randomBytes(48).toString('hex')
|
||||
|
||||
+6
-1
@@ -1,7 +1,12 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const DATA_PATH = path.join(process.cwd(), 'data', 'settings.json')
|
||||
const _isBunBinary = typeof (globalThis as any).Bun !== 'undefined'
|
||||
&& path.dirname((process as any).execPath) !== process.cwd()
|
||||
const BASE_DIR = _isBunBinary
|
||||
? path.dirname((process as any).execPath)
|
||||
: process.cwd()
|
||||
const DATA_PATH = path.join(BASE_DIR, 'config', 'settings.json')
|
||||
|
||||
export interface Settings {
|
||||
mediaPath : string
|
||||
|
||||
@@ -77,7 +77,7 @@ export function getAvailableClients(): TorrentClientDefinition[] {
|
||||
|
||||
// ─── Persistence (clients enregistrés) ────────────────────────────────────────
|
||||
|
||||
const CLIENTS_PATH = path.join(process.cwd(), 'data', 'torrent_clients.json')
|
||||
const CLIENTS_PATH = path.join(process.cwd(), 'config', 'torrent_clients.json')
|
||||
|
||||
function loadClients(): SavedClient[] {
|
||||
try {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -276,23 +276,10 @@ async function fetchOrganizeNotifs() {
|
||||
}
|
||||
|
||||
// ── Cache images ───────────────────────────────────────────────
|
||||
const imageCache = new Set<string>()
|
||||
|
||||
function precacheImages(series: any[]) {
|
||||
for (const s of series) {
|
||||
if (s.poster_image && !imageCache.has(s.poster_image)) {
|
||||
imageCache.add(s.poster_image)
|
||||
const img = new Image()
|
||||
img.src = s.poster_image
|
||||
}
|
||||
}
|
||||
}
|
||||
// Géré directement dans le store series.ts
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.series.length === 0) {
|
||||
await store.fetchSeries()
|
||||
precacheImages(store.series)
|
||||
}
|
||||
if (store.series.length === 0) await store.fetchSeries()
|
||||
fetchActiveDownloads()
|
||||
fetchOrganizeNotifs()
|
||||
dlInterval = setInterval(() => {
|
||||
@@ -304,7 +291,6 @@ onMounted(async () => {
|
||||
// Refresh au retour sur le catalogue (après update ou scan)
|
||||
onActivated(async () => {
|
||||
await store.fetchSeries()
|
||||
precacheImages(store.series)
|
||||
fetchActiveDownloads()
|
||||
})
|
||||
|
||||
|
||||
+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