Compare commits

...
157 Commits
Author SHA1 Message Date
MasutaYunikon d8173a6d72 Merge pull request #57 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.2
2026-03-24 02:55:28 +01:00
MasutaYunikon 60c9ffa14c chore(main): release 2.3.2 2026-03-24 02:54:52 +01:00
MasutaYunikon 0751c2edcc Merge remote-tracking branch 'origin/main' 2026-03-24 02:54:20 +01:00
MasutaYunikon 7e050a699d fix: badge catalogue sur les series importé 2026-03-24 02:54:12 +01:00
MasutaYunikon 9badada4a3 Merge pull request #56 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.1
2026-03-24 02:43:17 +01:00
MasutaYunikon 5e881272a1 chore(main): release 2.3.1 2026-03-24 02:42:59 +01:00
MasutaYunikon a229a1eb3b Merge remote-tracking branch 'origin/main' 2026-03-24 02:42:27 +01:00
MasutaYunikon b621adc6f6 fix: add readsettings import 2026-03-24 02:42:19 +01:00
MasutaYunikon 7d3bc413ef Merge pull request #55 from Masutayunikon/release-please--branches--main
chore(main): release 2.3.0
2026-03-24 02:36:52 +01:00
MasutaYunikon 6f9b7892b1 chore(main): release 2.3.0 2026-03-24 02:36:06 +01:00
MasutaYunikon 2332b544e6 fix: add config directory to .gitignore 2026-03-24 02:35:34 +01:00
MasutaYunikon 3b091b7aa8 Merge remote-tracking branch 'origin/main' 2026-03-24 02:32:56 +01:00
MasutaYunikon e1e430a968 feat(settings): ajout du toggle import automatique (activé par défaut)
Nouveau champ autoImport dans Settings — quand désactivé, le worker ne se
lance jamais automatiquement. Activé par défaut pour conserver le comportement
existant. Le toggle est exposé dans SettingsView avec une description claire.

feat(organize): nommage des fichiers selon original_filename ou formatted_name

Selon l'option NFO :
- nfoSupport activé → original_filename (correspond aux .nfo GitLab)
- nfoSupport désactivé → formatted_name de l'API Fankai (format Jellyfin/Plex
  standardisé, ex: Serie.S01E01.MULTI.1080p.x264-FANKAI.mkv)

formatted_name est maintenant injecté dans enrichSeriesDataWithOriginalFilenames
en même temps que original_filename.

feat(catalogue): tri du catalogue avec 4 options

- A→Z (défaut, localeCompare français)
- Z→A
- Récents (par année décroissante)
- Importés (fichiers importés en premier)

feat(catalogue): badge IMPORTÉ et refresh automatique post-import

- Renommage ORGANISÉ → IMPORTÉ sur les affiches et dans les toasts
- Le catalogue se rafraîchit automatiquement quand fetchOrganizeNotifs
  détecte de nouvelles notifications d'import

feat(settings): textes génériques sans mention de logiciels spécifiques

- "Dossier Complétion" → "Dossier Téléchargements"
- "Dossier Jellyfin" → "Médiathèque Fankai"
- "Mode Organisation" → "Mode Import"
- "Organisation" → "Import" partout dans l'interface

feat(downloads): bouton Organiser tout renommé Importer tout

Itère sur tous les torrents seeding non importés et les envoie
séquentiellement à /api/organize avec feedback toast de progression.
2026-03-24 02:32:47 +01:00
MasutaYunikon 2d49125e09 Merge pull request #54 from Masutayunikon/release-please--branches--main
chore(main): release 2.2.0
2026-03-23 15:38:51 +01:00
MasutaYunikon 721047c95f chore(main): release 2.2.0 2026-03-23 15:38:03 +01:00
MasutaYunikon 74662de2b6 Merge remote-tracking branch 'origin/main' 2026-03-23 15:37:23 +01:00
MasutaYunikon 84ef0333f3 feat: add organize all button
fix: organizer reuse the wrong name for gitlab path
2026-03-23 15:37:07 +01:00
MasutaYunikon c6624843b9 Merge pull request #53 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.3
2026-03-23 06:09:59 +01:00
MasutaYunikon 8bd30913cd chore(main): release 2.1.3 2026-03-23 06:09:36 +01:00
MasutaYunikon d7eb932ed3 Merge remote-tracking branch 'origin/main' 2026-03-23 06:09:04 +01:00
MasutaYunikon 4594dc5189 fix: use magnet before torrent 2026-03-23 06:08:48 +01:00
MasutaYunikon 894a621e85 Merge pull request #52 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.2
2026-03-23 05:38:16 +01:00
MasutaYunikon 23dbf86e70 chore(main): release 2.1.2 2026-03-23 05:38:02 +01:00
MasutaYunikon d86138ef1c Merge remote-tracking branch 'origin/main' 2026-03-23 05:37:32 +01:00
MasutaYunikon 4aa6f99676 fix: path now work on every device 2026-03-23 05:37:23 +01:00
MasutaYunikon 10432e0f99 Merge pull request #51 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.1
2026-03-23 05:32:19 +01:00
MasutaYunikon 4f69f8bf92 chore(main): release 2.1.1 2026-03-23 05:32:01 +01:00
MasutaYunikon 1511dd3ca3 Merge remote-tracking branch 'origin/main' 2026-03-23 05:31:29 +01:00
MasutaYunikon 23967a81d6 fix: torrent path to hash 2026-03-23 05:31:20 +01:00
MasutaYunikon 21ff025376 Merge pull request #50 from Masutayunikon/release-please--branches--main
chore(main): release 2.1.0
2026-03-23 05:00:49 +01:00
MasutaYunikon 9ba19f9539 chore(main): release 2.1.0 2026-03-23 05:00:34 +01:00
MasutaYunikon 215d802b12 fix: remove logo
Release-As: 2.1.0
2026-03-23 05:00:05 +01:00
MasutaYunikon 21dccbe5d9 Merge remote-tracking branch 'origin/main'
Release-As: 2.1.0
2026-03-23 04:56:45 +01:00
MasutaYunikon 329150f1c4 fix(organize): migration vers paths { infohash, path } pour le matching fichiers
BREAKING CHANGE: le format paths[] dans series/{id}.json passe de string[]
à { infohash: string, path: string }[] — nécessite une mise à jour du scraper.

fix(organize): buildFileMap matche par infohash au lieu d'index ou titre

L'ancien système utilisait soit un index positionnel fragile, soit le titre
du torrent comme clé de matching — les deux cassaient si l'utilisateur
renommait le torrent dans qBittorrent ou si un pack intégrale ne couvrait
pas tous les épisodes. Désormais chaque path est lié à un infohash précis.

fix(organize): source fichier via save_path qBittorrent + fullPath relatif

path.join(savePath, fullPath) utilise le save_path retourné par qBittorrent
au lieu de deviner le nom du dossier depuis le titre du torrent. Résiste
aux renommages de dossier côté client.

fix(organize): scanMediaPath adapté au nouveau format paths[]

L'index filename → infohash est maintenant construit directement depuis
ep.paths[].infohash sans passer par la hiérarchie torrents série/saison/épisode.
2026-03-23 04:52:33 +01:00
MasutaYunikon 17f7234b49 Merge pull request #48 from Masutayunikon/release-please--branches--main
chore(main): release 2.0.0
2026-03-23 04:05:16 +01:00
MasutaYunikon 8e529d3845 chore(main): release 2.0.0 2026-03-23 04:04:52 +01:00
MasutaYunikon a3f3539a27 Merge remote-tracking branch 'origin/main' 2026-03-23 04:04:23 +01:00
MasutaYunikon d5c37e8f2c feat!: migration vers nouvelle architecture API et scraper par série
BREAKING CHANGE: 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.

feat(api): cache mémoire GitHub TTL 6h avec invalidation via /api/update

Plus de fichiers locaux dans /config pour les torrents. Les données sont
fetchées depuis GitHub (available.json + series/{id}.json) et mises en
cache 6h. /api/update vide le cache pour forcer le rechargement.

feat(api): adaptation complète à la nouvelle structure API Fankai

- normalizeSerie/Season/Episode pour aplatir images.poster → poster_image
- Proxy wsrv.nl pour redimensionner les images (poster 300px, fanart 1280px,
  thumbnail 400px) et réduire le temps de chargement
- duration en secondes → affichage en minutes dans SerieView

feat(organize): refonte complète sans torrent_final.json

- organize-worker.ts reçoit seriesData via message au lieu de lire un fichier
- buildFileMap construit filename→{season_number,original_filename} depuis paths[]
- enrichSeriesDataWithOriginalFilenames fetche /seasons/{id}/episodes avant
  de passer les données au worker pour le renommage correct des fichiers
- Suppression de SERIE_TITLE_GITLAB_MAP (Kaï→Kai résolu côté API)
- scanMediaPath adapté : plus de torrentsPath, prend seriesData en paramètre

feat(api): matching torrents par hiérarchie série/saison/épisode

- Torrents niveau série → pack_integrale
- Torrents niveau saison → pack_saison (indexé par season_number)
- Torrents niveau épisode → episode
- Matching via paths[torrentIdx] pour résoudre episode↔fichier

feat(series-view): badge "Hors Fankai" sur épisodes de torrents manuels
feat(series-view): tooltip raw name sur boutons intégrale via :title
feat(scraper): extraction nom bencode dans raw pour torrents locaux
fix(series): boutons saison Yu-Gi-Oh via fallback season_number
fix(catalogue): état download basé sur épisodes organisés vs torrents bruts

Release-As: 2.0.0
2026-03-23 04:04:12 +01:00
MasutaYunikon a5f5dfb0da Merge pull request #47 from Masutayunikon/release-please--branches--main
chore(main): release 1.14.0
2026-03-21 08:01:19 +01:00
MasutaYunikon 08531c80f3 chore(main): release 1.14.0 2026-03-21 07:59:16 +01:00
MasutaYunikon 96ce207027 Merge remote-tracking branch 'origin/main' 2026-03-21 07:58:54 +01:00
MasutaYunikon 13f9a27af8 fix(series): résolution boutons de téléchargement saisons Yu-Gi-Oh et séries similaires
Les boutons "Saison entière" n'apparaissaient pas pour les séries dont les
season_id du scraper ne correspondent pas aux season_id de l'API Fankai.

- Ajouter seasonTorrentMapBySn indexé par season_number en fallback
- Utiliser season_number comme clé de secours dans enrichedSeasons

fix(catalogue): état "partiel" incorrect quand l'intégrale est organisée

Le badge partial s'affichait même quand tous les épisodes étaient organisés
via un pack_integrale, car les torrents d'épisodes séparés faisaient baisser
le ratio. La logique est maintenant basée sur les episode_id uniques.

feat(series-view): badge "Hors Fankai" sur les épisodes de torrents manuels

Les épisodes issus de MANUAL_TORRENTS (ex: MHA Film 4) sont maintenant
marqués d'un badge violet "Hors Fankai" avec tooltip explicatif.

- Propager manual: true du torrent dans le TorrentRef de l'épisode
- Afficher le badge dans SerieView.vue si ep.torrent?.manual

feat(series-view): tooltip avec le nom du torrent sur les boutons intégrale

Quand plusieurs intégrales existent pour une série, survoler le bouton
affiche le raw name du torrent via :title="t.raw".

feat(scraper): extraction du nom bencode pour les torrents locaux

Le champ raw des LOCAL_TORRENTS est maintenant renseigné depuis info.name
extrait directement du fichier .torrent via bencode, au lieu d'utiliser
la clé du dictionnaire LOCAL_TORRENTS.

- fetch_torrent_files_from_local retourne maintenant (files, infohash, name)
- torrent["raw"] est mis à jour avec le vrai nom bencode
2026-03-21 07:58:45 +01:00
MasutaYunikon d0d40c02b5 Merge pull request #46 from Masutayunikon/release-please--branches--main
chore(main): release 1.13.0
2026-03-21 04:21:36 +01:00
MasutaYunikon 0e541a19ba chore(main): release 1.13.0 2026-03-21 04:21:09 +01:00
MasutaYunikon 1d71c40a2b Merge pull request #45 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.2
2026-03-21 04:20:11 +01:00
MasutaYunikon adc3e91122 chore(main): release 1.12.2 2026-03-21 04:18:31 +01:00
MasutaYunikon b260208701 Merge remote-tracking branch 'origin/main' 2026-03-21 04:18:09 +01:00
MasutaYunikon 3dd517c37f fix: use season number from resolved episodes in priority 2026-03-21 04:17:59 +01:00
MasutaYunikon 93397bee77 Merge pull request #44 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.1
2026-03-21 04:00:35 +01:00
MasutaYunikon 3f72c01911 chore(main): release 1.12.1 2026-03-21 03:59:32 +01:00
MasutaYunikon 77e6a2e38c Merge remote-tracking branch 'origin/main' 2026-03-21 03:59:09 +01:00
MasutaYunikon bef99183aa fix: path 2026-03-21 03:58:55 +01:00
MasutaYunikon 7b84229510 Merge pull request #43 from Masutayunikon/release-please--branches--main
chore(main): release 1.12.0
2026-03-21 03:45:31 +01:00
MasutaYunikon 23c46a621e chore(main): release 1.12.0 2026-03-21 03:45:10 +01:00
MasutaYunikon 4dc7255bb0 Merge remote-tracking branch 'origin/main' 2026-03-21 03:44:46 +01:00
MasutaYunikon 180513c98a feat: now rename episodes with original_filename from api 2026-03-21 03:44:37 +01:00
MasutaYunikon 00894bc069 Merge pull request #42 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.10
2026-03-21 03:18:52 +01:00
MasutaYunikon a1b128640d chore(main): release 1.11.10 2026-03-21 03:18:32 +01:00
MasutaYunikon 8bec4c62fa fix: improve torrent organization and cleanup logic 2026-03-21 03:18:09 +01:00
MasutaYunikon a7ba6a54a3 Merge remote-tracking branch 'origin/main' 2026-03-21 02:14:03 +01:00
MasutaYunikon 33d0b9a52d fix: season 0 not clonficting anymore 2026-03-21 02:13:52 +01:00
MasutaYunikon 46546d0178 Merge pull request #41 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.9
2026-03-21 00:09:13 +01:00
MasutaYunikon 756ced0001 chore(main): release 1.11.9 2026-03-20 22:03:02 +01:00
MasutaYunikon 2addb39ba3 Merge remote-tracking branch 'origin/main' 2026-03-20 22:02:40 +01:00
MasutaYunikon 232ede4292 fix: season 0 resolved is no longer skipped in the worker 2026-03-20 22:02:17 +01:00
MasutaYunikon f161e84a1d Merge pull request #40 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.8
2026-03-20 20:31:54 +01:00
MasutaYunikon 4d4a8029f6 chore(main): release 1.11.8 2026-03-20 20:31:00 +01:00
MasutaYunikon 2a1dcddd90 Merge remote-tracking branch 'origin/main' 2026-03-20 20:30:33 +01:00
MasutaYunikon 4d319f97bc fix: config path /config/config to /config 2026-03-20 20:30:23 +01:00
MasutaYunikon 3372d4cb40 Merge pull request #39 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.7
2026-03-20 20:18:44 +01:00
MasutaYunikon eb22bb669f chore(main): release 1.11.7 2026-03-20 20:18:19 +01:00
MasutaYunikon 0d64a66977 Merge remote-tracking branch 'origin/main' 2026-03-20 20:17:54 +01:00
MasutaYunikon b0e81166e1 fix: worker path 2026-03-20 20:17:46 +01:00
MasutaYunikon 79d7aa7485 Merge pull request #38 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.6
2026-03-20 17:30:18 +01:00
MasutaYunikon af995a84cb chore(main): release 1.11.6 2026-03-20 17:29:09 +01:00
MasutaYunikon 5a71bdca3c Merge remote-tracking branch 'origin/main' 2026-03-20 17:28:46 +01:00
MasutaYunikon 77d918fbe0 fix: path 2026-03-20 17:28:09 +01:00
MasutaYunikon a0b255338d Merge pull request #37 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.5
2026-03-20 17:01:44 +01:00
MasutaYunikon e0b09c144f chore(main): release 1.11.5 2026-03-20 17:00:37 +01:00
MasutaYunikon 418d09b07f Merge remote-tracking branch 'origin/main' 2026-03-20 17:00:07 +01:00
MasutaYunikon 4c7936b019 fix: export _isBunBinary 2026-03-20 16:59:58 +01:00
MasutaYunikon 77f42b5b3e Merge pull request #36 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.4
2026-03-20 16:57:17 +01:00
MasutaYunikon bd69f7a730 chore(main): release 1.11.4 2026-03-20 16:55:47 +01:00
MasutaYunikon 3ce6523248 Merge remote-tracking branch 'origin/main' 2026-03-20 16:55:16 +01:00
MasutaYunikon 2efed9dcfc fix: add config.ts with config variable (BASE_DIR...) 2026-03-20 16:55:03 +01:00
MasutaYunikon 88e2a0bc38 Merge pull request #35 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.3
2026-03-20 16:08:46 +01:00
MasutaYunikon 338e3f8547 chore(main): release 1.11.3 2026-03-20 15:41:14 +01:00
MasutaYunikon d1ccc902da Merge remote-tracking branch 'origin/main' 2026-03-20 15:40:40 +01:00
MasutaYunikon 5e54e24b60 fix: entrypoint.sh config directory 2026-03-20 15:40:28 +01:00
MasutaYunikon aae2f592b5 Merge pull request #34 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.2
2026-03-20 02:11:37 +01:00
MasutaYunikon bd94e6a460 chore(main): release 1.11.2 2026-03-20 02:11:01 +01:00
MasutaYunikon cbe3e1a786 Merge remote-tracking branch 'origin/main' 2026-03-20 02:10:38 +01:00
MasutaYunikon 7a0917e053 fix: port 9898, /config volume, Kaï→Kai matching, NFO pagination GitLab, saison 0 specials, JoJo/Hokuto strict season resolve
- Port par défaut 3001 → 9898
- Volume /app/data → /config (entrypoint, docker-compose, README)
- Normalisation titres Kaï/Kai dans index.ts (catalogue + déduplication + serie detail)
- Fix pagination GitLab NFO (per_page=100 → loop multi-pages)
- Fix _is_bonus_filename avec word boundaries (\b) — évite faux positifs "national"→"ona"
- Fix dossier Bonus/Specials exclus : récupère flottants (7,5/19,5) et S00Exx → saison 0
- _EP_FLOAT_PATTERN supporte virgules (7,5) en plus des points (7.5)
- Ajout strict_season dans find_episode/resolve_ep — évite collision ep1 S1 vs ep1 S2 (JoJo Parties)
- Ajout SEASON_EP_OFFSET pour Hokuto No Ken (saison 02 torrent → +16 offset API)
- Fallback non-strict si strict_season échoue (Hokuto ep 5-16 S1)
- Fix collect_covered_episodes : fallback torrent.serie_id si absent dans resolved_episodes
- Fix s6_verify : get_all_specials + compteur S00 séparé, déduplication Kaï/Kai, format OK/TOTAL
- Ajout MANUAL_TORRENTS dans s4 (MHA Film 4 You're Next URL Nyaa)
- Injection torrents locaux manquants dans pipeline (Reborn! Kaï via fichier local)
- Fix s5 : résolution directe torrents manuels season_number=0
- Fix s2 : pack_saga_slash et pack_partie_slash → pack_integrale, lookahead (?<!/) sur episode_num_tiret_apres
- Fix s5 skip condition : re-résoudre si resolved_count < files_count (Fairy Tail)
- README : note hardlinks volumes multiples, port, chemins config
2026-03-20 02:10:27 +01:00
MasutaYunikon 8ac4f7a65b Merge pull request #33 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.1
2026-03-14 00:59:14 +01:00
MasutaYunikon 453cd47e85 chore(main): release 1.11.1 2026-03-14 00:58:57 +01:00
MasutaYunikon d5679ec529 Merge remote-tracking branch 'origin/main' 2026-03-14 00:58:37 +01:00
MasutaYunikon 2f0be502c3 fix: worker now work on multiple github files pages 2026-03-14 00:58:29 +01:00
MasutaYunikon ec971915b5 Merge pull request #32 from Masutayunikon/release-please--branches--main
chore(main): release 1.11.0
2026-03-13 19:03:34 +01:00
MasutaYunikon bb71b241d1 chore(main): release 1.11.0 2026-03-13 19:03:16 +01:00
MasutaYunikon 7aa01755ec Merge remote-tracking branch 'origin/main' 2026-03-13 19:02:46 +01:00
MasutaYunikon 8ce0ca1f24 feat: fix worker for multiple series in integral (one piece fix ?) 2026-03-13 19:02:38 +01:00
MasutaYunikon 620aac6b38 Merge pull request #31 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.3
2026-03-13 04:35:00 +01:00
MasutaYunikon e99e413e4e chore(main): release 1.10.3 2026-03-13 04:34:21 +01:00
MasutaYunikon fb578cf9f9 Merge remote-tracking branch 'origin/main' 2026-03-13 04:34:00 +01:00
MasutaYunikon 78e3055344 fix: worker path 2026-03-13 04:33:51 +01:00
MasutaYunikon 15f5847c97 Merge pull request #30 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.2
2026-03-13 04:25:54 +01:00
MasutaYunikon 8736b58d8c chore(main): release 1.10.2 2026-03-13 04:25:39 +01:00
MasutaYunikon 12e8e2f93a Merge remote-tracking branch 'origin/main' 2026-03-13 04:25:17 +01:00
MasutaYunikon ed2306bb70 fix: add target node 2026-03-13 04:25:09 +01:00
MasutaYunikon b3fd57c2c8 Merge pull request #29 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.1
2026-03-13 04:22:24 +01:00
MasutaYunikon c2da12409a chore(main): release 1.10.1 2026-03-13 04:22:06 +01:00
MasutaYunikon 178a57c444 Merge remote-tracking branch 'origin/main' 2026-03-13 04:21:42 +01:00
MasutaYunikon 1afe13c555 fix: add target node 2026-03-13 04:21:34 +01:00
MasutaYunikon c8963cc4cf Merge pull request #28 from Masutayunikon/release-please--branches--main
chore(main): release 1.10.0
2026-03-13 04:19:01 +01:00
MasutaYunikon 391b793e39 chore(main): release 1.10.0 2026-03-13 04:18:23 +01:00
MasutaYunikon 9a1519e6cd Merge pull request #27 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.5
2026-03-13 04:17:27 +01:00
MasutaYunikon a12237af68 chore(main): release 1.9.5 2026-03-13 04:16:50 +01:00
MasutaYunikon d304aa6a26 Merge remote-tracking branch 'origin/main' 2026-03-13 04:16:29 +01:00
MasutaYunikon 27092b3968 fix: worker outside the exe 2026-03-13 04:16:07 +01:00
MasutaYunikon c1c36018b1 Merge pull request #26 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.4
2026-03-13 04:12:38 +01:00
MasutaYunikon b462ec717b chore(main): release 1.9.4 2026-03-13 04:11:56 +01:00
MasutaYunikon 65518861ac Merge remote-tracking branch 'origin/main' 2026-03-13 04:11:36 +01:00
MasutaYunikon ed75280e70 fix: worker outside the exe 2026-03-13 04:11:27 +01:00
MasutaYunikon 9f53a9fef5 Merge pull request #25 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.3
2026-03-13 04:02:39 +01:00
MasutaYunikon 3e5dc8d1d4 chore(main): release 1.9.3 2026-03-13 04:02:06 +01:00
MasutaYunikon bcb7f656a1 Merge remote-tracking branch 'origin/main' 2026-03-13 04:01:43 +01:00
MasutaYunikon 372359d040 fix: use bcryptjs for compilation 2026-03-13 04:01:34 +01:00
MasutaYunikon 63847c583a Merge pull request #24 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.2
2026-03-13 03:52:42 +01:00
MasutaYunikon e90f85af70 chore(main): release 1.9.2 2026-03-13 03:52:30 +01:00
MasutaYunikon 0c683d4e37 Merge remote-tracking branch 'origin/main' 2026-03-13 03:52:10 +01:00
MasutaYunikon 16b28004cf fix: workflow directory 2026-03-13 03:52:02 +01:00
MasutaYunikon 558f4e2a1b Merge pull request #23 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.1
2026-03-13 03:47:53 +01:00
MasutaYunikon 352c18029f chore(main): release 1.9.1 2026-03-13 03:47:33 +01:00
MasutaYunikon 465d3dba8b fix: workflow directory 2026-03-13 03:47:09 +01:00
MasutaYunikon 7b82d60c69 Merge remote-tracking branch 'origin/main' 2026-03-13 03:43:01 +01:00
MasutaYunikon af03aecf0d fix: workflow permmisions 2026-03-13 03:42:50 +01:00
MasutaYunikon f878298cef Merge pull request #22 from Masutayunikon/release-please--branches--main
chore(main): release 1.9.0
2026-03-13 03:39:58 +01:00
MasutaYunikon fc5dd1d36d chore(main): release 1.9.0 2026-03-13 03:39:04 +01:00
MasutaYunikon 1b73e7d0a3 Merge remote-tracking branch 'origin/main' 2026-03-13 03:38:43 +01:00
MasutaYunikon 8030bf14f9 feat: add windows / linux / macos binaries on build 2026-03-13 03:38:34 +01:00
MasutaYunikon 908a8b6076 Merge pull request #21 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.2
2026-03-13 02:52:27 +01:00
MasutaYunikon 0d2708ca09 chore(main): release 1.8.2 2026-03-13 02:52:12 +01:00
MasutaYunikon fb7e443c23 Merge remote-tracking branch 'origin/main' 2026-03-13 02:51:50 +01:00
MasutaYunikon 8fab68a0e4 fix: season fallback 2026-03-13 02:51:41 +01:00
MasutaYunikon 0c06941452 Merge pull request #20 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.1
2026-03-13 02:34:56 +01:00
MasutaYunikon 8b8c5cc930 chore(main): release 1.8.1 2026-03-13 02:20:57 +01:00
MasutaYunikon 1dc01a101f Merge remote-tracking branch 'origin/main' 2026-03-13 02:20:36 +01:00
MasutaYunikon 054dcb915a fix: nfo settings and add json file when not existing 2026-03-13 02:20:26 +01:00
MasutaYunikon d0c876b885 Merge pull request #19 from Masutayunikon/release-please--branches--main
chore(main): release 1.8.0
2026-03-13 00:26:18 +01:00
MasutaYunikon ce0bf1b8ab chore(main): release 1.8.0 2026-03-13 00:25:54 +01:00
MasutaYunikon 014c7bbbc3 Merge remote-tracking branch 'origin/main' 2026-03-13 00:25:34 +01:00
MasutaYunikon f3bcc68104 feat: cache images 2026-03-13 00:25:24 +01:00
MasutaYunikon a8978c1a43 Merge pull request #18 from Masutayunikon/release-please--branches--main
chore(main): release 1.7.3
2026-03-13 00:13:58 +01:00
MasutaYunikon 40632c8b06 chore(main): release 1.7.3 2026-03-13 00:12:12 +01:00
MasutaYunikon 71d3c557e9 fix: CatalogView.vue badge deleted 2026-03-13 00:11:45 +01:00
MasutaYunikon 0825cb2f44 Merge remote-tracking branch 'origin/main' 2026-03-12 23:56:24 +01:00
MasutaYunikon cec376ade6 fix: empty docker-compose.yml 2026-03-12 23:56:16 +01:00
26 changed files with 2025 additions and 1153 deletions

No files matched your search

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