From 8657a856f818354c8f2a216c68297b4e5dce0dd0 Mon Sep 17 00:00:00 2001 From: Movix <178902026+MysticSaba-max@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:22:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20live=20TV=20FCTV,=20fournisseur=20OAuth?= =?UTF-8?q?,=20sources=20J1F/Cinestream,=20mod=C3=A9ration=20IA=20&=20durc?= =?UTF-8?q?issement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `//`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 --- API/Mainapi/.env.example | 27 +- API/Mainapi/app.js | 72 +- API/Mainapi/checkVip.js | 7 +- API/Mainapi/commentsRoutes.js | 26 +- API/Mainapi/liveTvRoutes.js | 8 +- API/Mainapi/middleware/auth.js | 12 +- API/Mainapi/middleware/cors.js | 4 +- API/Mainapi/middleware/security.js | 4 +- API/Mainapi/package.json | 3 +- API/Mainapi/routes/admin.js | 546 ++++++-- API/Mainapi/routes/adminOauthApps.js | 31 +- API/Mainapi/routes/authRoutes.js | 87 ++ API/Mainapi/routes/cinestream.js | 271 ++++ API/Mainapi/routes/coflix.js | 13 +- API/Mainapi/routes/cpasmal.js | 73 +- API/Mainapi/routes/darkiworld.js | 630 ++++----- API/Mainapi/routes/download.js | 445 +----- API/Mainapi/routes/dragiv.js | 518 ------- API/Mainapi/routes/francetv.js | 77 ++ API/Mainapi/routes/fstream.js | 279 +++- API/Mainapi/routes/j1f.js | 377 +++++ API/Mainapi/routes/oauth.js | 36 +- API/Mainapi/routes/profiles.js | 22 +- API/Mainapi/routes/proxy.js | 313 ----- API/Mainapi/routes/purstream.js | 2 +- API/Mainapi/routes/tmdb.js | 4 +- API/Mainapi/routes/topstream.js | 486 ------- API/Mainapi/routes/wiflix.js | 151 +- API/Mainapi/server.js | 124 +- API/Mainapi/sharedListsRoutes.js | 22 +- API/Mainapi/utils/adminIdentity.js | 30 +- API/Mainapi/utils/axiosHelpers.js | 111 +- API/Mainapi/utils/cacheManager.js | 3 +- API/Mainapi/utils/cloudflareSession.js | 249 ---- API/Mainapi/utils/darkiworldSqlite.js | 521 +++++++ API/Mainapi/utils/hydrackerBatch.js | 461 ------- API/Mainapi/utils/hydrackerLive.js | 428 ++++++ API/Mainapi/utils/hydrackerQueue.js | 529 ------- API/Mainapi/utils/lruMap.js | 106 ++ API/Mainapi/utils/oauthClientsDb.js | 58 + API/Mainapi/utils/proxyManager.js | 620 ++++++++- API/Mainapi/utils/syncPolicy.js | 88 +- API/Mainapi/utils/vipDonations.js | 172 ++- API/Mainapi/wishboardRoutes.js | 9 +- API/Mainapi/wrappedRoutes.js | 1227 ++++++++++------- API/proxiesembed/server.py | 368 +++-- package-lock.json | 293 ++++ package.json | 6 +- src/App.tsx | 53 +- src/components/AccessCodeForm.tsx | 36 +- src/components/AdFreePlayerAds.tsx | 142 +- src/components/AdminComments.tsx | 2 +- src/components/AdminDashboard.tsx | 23 +- src/components/AdminTeam.tsx | 616 +++++++++ src/components/CommentsSection.tsx | 9 +- src/components/EmblaCarousel.tsx | 2 +- src/components/ErrorBoundary.tsx | 68 +- src/components/FloatingPlayer.tsx | 3 +- src/components/Footer.tsx | 4 +- .../Greenlight/AdminLinkSubmissions.tsx | 2 +- src/components/HLSPlayer.tsx | 491 ++++--- src/components/HLSPlayerSettingsPanel.tsx | 260 +++- src/components/Header.tsx | 2 +- src/components/HeroSlider.tsx | 2 +- src/components/LiveTVPlayer.tsx | 26 +- src/components/RedirectPopup.tsx | 2 +- src/components/RequireUsernameChange.tsx | 224 +++ src/components/Settings/SourceCard.tsx | 1 + src/components/StreamingLinksManager.tsx | 247 +++- src/components/TopProgressBar.tsx | 22 +- src/components/stats/ChartCard.tsx | 21 + src/components/stats/chartTheme.ts | 34 + src/components/ui/blur-text.tsx | 16 +- src/config/discord.ts | 2 +- src/config/runtime.ts | 7 - src/context/AdFreePopupContext.tsx | 22 +- src/context/ProfileContext.tsx | 109 +- src/i18n/locales/en.json | 40 +- src/i18n/locales/fr.json | 40 +- src/main.tsx | 70 +- src/pages/DownloadPage.tsx | 74 +- src/pages/MovieDetails.tsx | 2 +- src/pages/OAuthAuthorizePage.tsx | 283 ++-- src/pages/Profile.tsx | 26 +- src/pages/SettingsPage.tsx | 150 +- src/pages/TVDetails.tsx | 67 +- src/pages/Top10Page.tsx | 2 +- src/pages/Watch/WatchMovie.tsx | 275 ++-- src/pages/Watch/WatchTv.tsx | 222 +-- src/pages/WatchPartyCreate.tsx | 1 + src/pages/WatchPartyJoin.tsx | 1 + src/pages/WhatIsMovixPage.tsx | 1 + src/pages/WrappedPage.tsx | 1130 ++++++++++----- src/pages/help/CaMarchePlusPage.tsx | 2 +- src/pages/help/DnsPage.tsx | 2 +- src/pages/help/MiroirsPage.tsx | 2 +- src/routing/lazyWithRetry.ts | 200 ++- src/services/wrappedService.ts | 11 +- src/types/sourcePriority.ts | 6 +- src/utils/accountAuth.ts | 14 + src/utils/adAdultMode.ts | 46 + src/utils/adPopupMode.ts | 33 + src/utils/adScriptMode.ts | 2 +- src/utils/animeSignals.ts | 24 +- src/utils/castUtils.ts | 31 +- src/utils/extractionPrefs.ts | 2 +- src/utils/lowLatencyPref.ts | 47 + src/utils/searchUtils.ts | 6 +- src/utils/sourceAutoSelect.ts | 10 +- src/utils/sourcePriorityPrefs.ts | 66 +- src/utils/syncStorage.ts | 9 + src/utils/wrappedCanvas.ts | 217 +++ src/utils/wrappedShareCards.ts | 311 +++++ src/vite-env.d.ts | 5 + 114 files changed, 9996 insertions(+), 5831 deletions(-) create mode 100644 API/Mainapi/routes/cinestream.js delete mode 100644 API/Mainapi/routes/dragiv.js create mode 100644 API/Mainapi/routes/j1f.js delete mode 100644 API/Mainapi/routes/proxy.js delete mode 100644 API/Mainapi/routes/topstream.js delete mode 100644 API/Mainapi/utils/cloudflareSession.js create mode 100644 API/Mainapi/utils/darkiworldSqlite.js delete mode 100644 API/Mainapi/utils/hydrackerBatch.js create mode 100644 API/Mainapi/utils/hydrackerLive.js delete mode 100644 API/Mainapi/utils/hydrackerQueue.js create mode 100644 API/Mainapi/utils/lruMap.js create mode 100644 src/components/AdminTeam.tsx create mode 100644 src/components/RequireUsernameChange.tsx create mode 100644 src/components/stats/ChartCard.tsx create mode 100644 src/components/stats/chartTheme.ts create mode 100644 src/utils/adAdultMode.ts create mode 100644 src/utils/adPopupMode.ts create mode 100644 src/utils/lowLatencyPref.ts create mode 100644 src/utils/wrappedCanvas.ts create mode 100644 src/utils/wrappedShareCards.ts diff --git a/API/Mainapi/.env.example b/API/Mainapi/.env.example index f947276..ef9f967 100644 --- a/API/Mainapi/.env.example +++ b/API/Mainapi/.env.example @@ -1,4 +1,6 @@ PORT=YOUR_API_PORT # Exemple: 3000 +DISCORD_SCRAPER_WEBHOOK=YOUR_DISCORD_WEBHOOK_URL # Webhook for scraper detection alerts (decode watchlist) +SCRAPER_BLOCKED_IPS=1.2.3.4,5.6.7.8 # Comma-separated IPs that get poison 200 on /decode JWT_SECRET=YOUR_JWT_SECRET # Exemple: change_me_super_secret_key OPENROUTER_API_KEY=YOUR_OPENROUTER_API_KEY # Exemple: sk-or-v1-xxxxxxxx TMDB_API_KEY=YOUR_BACKEND_TMDB_API_KEY # Exemple: 123abc456def7890 @@ -24,8 +26,24 @@ SOCKS5_PROXIES=YOUR_SOCKS5_PROXIES_JSON # Exemple: [{"host":"1.2.3.4","port":123 HTTP_PROXIES=YOUR_HTTP_PROXIES_JSON # Exemple: [{"host":"1.2.3.4","port":12323,"auth":"user:pass"}] WITV_SOCKS5_PROXY_URL=YOUR_WITV_SOCKS5_PROXY_URL # Exemple: socks5://user:pass@127.0.0.1:1080 DARKIWORLD_BASE_URL=YOUR_DARKIWORLD_BASE_URL # Exemple: https://darkiworld16.com +CINESTREAM_BASE_URL=YOUR_CINESTREAM_BASE_URL # Optionnel, override le domaine source des films (route wiflix movie). Exemple: https://cinestream.info +# --- 1jour1film (1J1F, route /api/j1f) — le domaine tourne, tout est overridable --- +J1F_GO_URL=https://1jour1film2026.site/go/ # Optionnel, entree stable; on en extrait TARGET_URL -> base courante +J1F_BASE_URL= # Optionnel, force la base directement (court-circuite /go/). Exemple: https://1jour1film0626c.site +J1F_DROP_SOURCES=frembed,vidsrc,videasy # Optionnel, tags `source` a ignorer (aggregateurs generiques deja couverts par Movix); on ne garde que les players uniques (manual) +J1F_SRV_VAR=J1F_SRV # Optionnel, nom de la var base64 des sources film (page /films/) +J1F_EPS_VAR=j1fEpsData # Optionnel, nom de la var base64 des episodes (page /saisons/) +J1F_SIMILARITY=0.7 # Optionnel, seuil de match titre TMDB <-> resultat recherche DARKIWORLD_COOKIES=YOUR_DARKIWORLD_COOKIES # Exemple: SERVERID=S1; connected=1; dbruser=1; remember_web_...=...; cookie_notice=true; XSRF-TOKEN=...; darkiworld_session=... DARKIWORLD_XSRF_TOKEN=YOUR_DARKIWORLD_XSRF_TOKEN # Exemple: longue-chaine-xsrf-decodée + +# === Hydracker live raw-URL resolution (decode fallback on sqlite miss) === +# true -> cache+sqlite miss fetches hydracker.com (renvoie le lien host direct). +# false -> cache+sqlite only, aucun HTTP sortant. Defaut: off. +HYDRACKER_LIVE_ENABLED=false + +DARKIWORLD_SQLITE_DIR=YOUR_DARKIWORLD_SQLITE_DIR # Chemin ABSOLU du dossier contenant mirror.sqlite + darkino.sqlite + links_small.sqlite + TURNSTILE_SECRET_KEY=YOUR_TURNSTILE_SECRET_KEY # Exemple: 0x4AAAAAAxxxxxxxxxxxxxxxxxxxxxxxx TURNSTILE_INVISIBLE_SECRETKEY=YOUR_TURNSTILE_INVISIBLE_SECRETKEY # Turnstile invisible (likes, etc.) BESTDEBRID_API_KEY=YOUR_BESTDEBRID_API_KEY @@ -86,12 +104,3 @@ VIP_PAYBLIS_STORE_NAME=Movix VIP_PAYBLIS_IPN_BASE_URL= VIP_PAYBLIS_DOMAIN=pay.payblis.com -# === Hydracker queue decoding === -# When deployed without frontend support for 202, set to 'false' to disable -# the queue (route returns 404 on cache miss instead of 202). Default: true. -HYDRACKER_QUEUE_ENABLED=true -# When 'false', /decode/:id bypasses Redis/queue entirely and POSTs hydracker -# inline (synchronous mode, like before the queue was introduced). The drain -# timer is also skipped. Useful in dev / low-traffic where the queue rarely -# reaches 50. Default: true (queue+batch of 50 enabled). -HYDRACKER_BATCHING_ENABLED=true diff --git a/API/Mainapi/app.js b/API/Mainapi/app.js index e8f8df8..f8d25e7 100644 --- a/API/Mainapi/app.js +++ b/API/Mainapi/app.js @@ -105,11 +105,11 @@ const darkiHeaders = { }; // Coflix config -const COFLIX_BASE_URL = "https://coflix.date"; +const COFLIX_BASE_URL = "https://coflix.trade"; const coflixHeaders = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - Referer: "https://coflix.date", + Referer: "https://coflix.trade", }; // === Axios instances for each source === @@ -221,6 +221,11 @@ http.globalAgent.maxFreeSockets = 32; https.globalAgent.keepAlive = true; https.globalAgent.maxSockets = 128; https.globalAgent.maxFreeSockets = 32; +// Retire each pooled socket after N requests so a long-lived worker rotates +// its keep-alive sockets instead of pinning the same TLS sessions / native +// socket objects for the whole process lifetime. +http.globalAgent.maxRequestsPerSocket = 1000; +https.globalAgent.maxRequestsPerSocket = 1000; // === Create Express app === const app = express(); @@ -253,12 +258,16 @@ app.use(keepAliveHeaders); app.use(domainRestriction); // 6. Body parsing -app.use(express.json({ limit: "30mb" })); // Reduced from 1000mb to prevent abuse +// Largest legitimate JSON body is the localStorage sync payload, capped at +// 5MB by SYNC_LIMITS.maxRequestBytes (utils/syncPolicy.js); OAuth icon uploads +// cap at 256KB. 8MB leaves headroom while preventing a single request from +// pinning tens of MB of buffer until the response completes. +app.use(express.json({ limit: "8mb" })); // 7. JSON parse error handler (must come right after json parser) app.use(jsonParseErrorHandler); -app.use(express.urlencoded({ extended: true, limit: "5mb" })); // Reduced from 1000mb to prevent abuse +app.use(express.urlencoded({ extended: true, limit: "1mb" })); // 8. Serve uploaded OAuth app icons (`public/oauth-icons/`). // Le panel admin upload ici, OAuthAuthorizePage lit `/oauth-icons/`. @@ -312,10 +321,7 @@ cpasmalRouter.configure({ CPASMAL_BASE_URL, TMDB_API_URL, TMDB_API_KEY, - axiosCpasmalRequest: require("./utils/proxyManager").axiosCpasmalRequest, - DARKINO_PROXIES: require("./utils/proxyManager").DARKINO_PROXIES, - getDarkinoHttpProxyAgent: require("./utils/proxyManager") - .getDarkinoHttpProxyAgent, + makeCpasmalRequest: require("./utils/proxyManager").makeCpasmalRequest, getFromCacheNoExpiration, shouldUpdateCache, }); @@ -468,6 +474,7 @@ app.use('/', searchRouter); app.use('/api', downloadRouter); app.use('/api/fstream', require('./routes/fstream')); app.use('/api/wiflix', require('./routes/wiflix')); +app.use('/api/j1f', require('./routes/j1f')); app.use('/api', require('./routes/sync')); app.use('/api/profiles', require('./routes/profiles')); app.use('/api/help', require('./routes/helpFeedback')); @@ -476,7 +483,6 @@ app.use('/api/oauth', oauthRouter); app.use('/api/admin/oauth-apps', require('./routes/adminOauthApps')); app.use('/api/sessions', require('./routes/sessions')); app.use('/api', require('./routes/debrid')); -app.use('/proxy', require('./routes/proxy')); app.use('/api', require('./routes/admin')); app.use('/api', vipDonationsRouter); app.use('/api', vipPayblisRouter); @@ -556,6 +562,9 @@ const appReady = (async () => { await oauthClientsDb.ensureTables(); await oauthClientsDb.migrateLegacyJsonIfNeeded(); await oauthClientsDb.reloadCache(); + // Cluster mode: subscribe so this worker reloads when any other worker + // mutates an OAuth client (see oauthClientsDb cross-worker invalidation). + oauthClientsDb.startClientsCacheSubscriber(); console.log('OAuth client tables initialized successfully'); // Initialize Wishboard routes @@ -663,38 +672,21 @@ function getAppPool() { return getPool(); } -// === Hydracker queue drain timer === -// Every worker fires the tick. drainQueueOnce uses Redis worker_lock so only -// one worker actually drains; the others get { drained: false, reason: 'lock_taken' } -// and skip. This means the drain runs in a worker process which has full -// darkiworld auth context (cookies, XSRF, darkiHeaders, axiosDarkinoRequest -// configured via axiosHelpers.configure earlier in this file). -const hydrackerQueue = require('./utils/hydrackerQueue'); -const DRAIN_INTERVAL_MS = 5000; -if (hydrackerQueue.BATCHING_ENABLED) { - setInterval(async () => { - try { - const result = await hydrackerQueue.drainQueueOnce({ - redis, - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest: axiosHelpers.axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - }); - if (result.drained) { - console.log(`[hydracker] drained batch of ${result.batchSize}`); - } else if (result.error) { - console.warn(`[hydracker] drain error: ${result.error} (requeued ${result.requeued || 0})`); - } - } catch (e) { - console.warn(`[hydracker] drain tick threw:`, e?.message || e); +// Hydracker freeze (2026-05-15): the queue, worker lock, and rate-limit key +// from the old architecture are dead. Clear them once on boot so they don't +// linger or confuse ops dashboards. +(async () => { + try { + if (redis && typeof redis.del === 'function') { + await redis.del('hydracker:queue:pending', + 'hydracker:worker_lock', + 'hydracker:rate_limited_until'); + console.log('[hydracker-freeze] cleared legacy redis keys'); } - }, DRAIN_INTERVAL_MS); -} else { - console.log('[hydracker] HYDRACKER_BATCHING_ENABLED=false → drain timer skipped, decode runs synchronously'); -} + } catch (e) { + console.warn('[hydracker-freeze] redis cleanup failed:', e?.message); + } +})(); // === Unified error handler === app.use((err, req, res, next) => { diff --git a/API/Mainapi/checkVip.js b/API/Mainapi/checkVip.js index b28ef97..bbef27c 100644 --- a/API/Mainapi/checkVip.js +++ b/API/Mainapi/checkVip.js @@ -9,10 +9,15 @@ */ const { getPool } = require('./mysqlPool'); +const { LruMap } = require('./utils/lruMap'); // Cache en mémoire pour éviter de spammer MySQL à chaque requête // TTL de 5 minutes — si une clé est révoquée, il faut max 5 min pour que ça prenne effet -const vipCache = new Map(); +// LRU-capped because the cache key is the user-supplied `x-access-key` header: +// without a cap, a spammer sending random header values could grow the map +// unboundedly within the 5-min cleanup window. +const VIP_CACHE_MAX_ENTRIES = 5000; +const vipCache = new LruMap({ max: VIP_CACHE_MAX_ENTRIES }); const VIP_CACHE_TTL = 5 * 60 * 1000; // 5 minutes /** diff --git a/API/Mainapi/commentsRoutes.js b/API/Mainapi/commentsRoutes.js index 9c2720a..0eefe24 100644 --- a/API/Mainapi/commentsRoutes.js +++ b/API/Mainapi/commentsRoutes.js @@ -136,13 +136,13 @@ function formatContentForResponse(text) { return formatted; } -// OpenRouter API Configuration for content moderation (using Gemini 2.5 Flash Lite) +// OpenRouter API Configuration for content moderation (using DeepSeek V4 Flash) const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions"; -const OPENROUTER_MODEL = "google/gemini-2.5-flash-lite"; +const OPENROUTER_MODEL = "deepseek/deepseek-v4-flash"; -// Fonction de modération avec OpenRouter/Gemini (exécutée en background) -async function moderateContentWithGemini( +// Fonction de modération avec OpenRouter (exécutée en background) +async function moderateContentWithAI( contentId, contentType, content, @@ -154,10 +154,10 @@ async function moderateContentWithGemini( Pseudo de l'utilisateur: ${JSON.stringify(username)} Commentaire à analyser: ${JSON.stringify(content)} -Critères de modération (s'appliquent au pseudo ET au commentaire): -1. INSULTES: Contient des insultes, injures, propos haineux ou dégradants +Critères de modération (s'appliquent au pseudo ET au commentaire). Ne flag QUE les violations claires et évidentes ; en cas de doute, ne pas flagger : +1. INSULTES: Insultes, injures, propos haineux ou dégradants visant une personne ou un groupe. Le simple agacement, les majuscules ou la ponctuation excessive ne sont PAS des insultes. 2. EROTIQUE: Contient du contenu érotique, sexuel ou inapproprié -3. DEMANDE_AJOUT: Demande d'ajout de films, séries, fonctionnalités ou autre contenu +3. DEMANDE_AJOUT: UNIQUEMENT une demande explicite d'ajouter un nouveau film/série/contenu au catalogue (ex: "ajoutez le film X svp", "vous pouvez mettre la série Y ?"). NE PAS flagger : les questions, les signalements de bug ("les épisodes ne marchent pas", "lien mort", "ce n'est pas le bon film"), les plaintes sur du contenu manquant ou retiré ("où est la saison 5 ?", "pourquoi la saison 5 a disparu ?"), les demandes d'aide à un modérateur, ni la discussion normale. 4. PSEUDO_INAPPROPRIE: Le pseudo contient des insultes, contenu érotique, ou est inapproprié Réponds UNIQUEMENT avec ce format JSON (sans markdown, sans backticks): @@ -1941,14 +1941,14 @@ router.post("/:commentId/replies", requireAuth, writeRateLimit, async (req, res) console.error("Erreur webhook Discord (non bloquant):", err), ); - // Modération automatique avec Gemini en arrière-plan (ne pas attendre) - moderateContentWithGemini( + // Modération automatique avec l'IA en arrière-plan (ne pas attendre) + moderateContentWithAI( result.lastID, "reply", content, userData.username, ).catch((err) => - console.error("Erreur modération Gemini réponse (non bloquant):", err), + console.error("Erreur modération IA réponse (non bloquant):", err), ); res.status(201).json({ @@ -3789,15 +3789,15 @@ router.post("/", requireAuth, writeRateLimit, async (req, res) => { console.error("Erreur webhook Discord (non bloquant):", err), ); - // Modération automatique avec Gemini en arrière-plan (ne pas attendre) - moderateContentWithGemini( + // Modération automatique avec l'IA en arrière-plan (ne pas attendre) + moderateContentWithAI( result.lastID, "comment", content, userData.username, ).catch((err) => console.error( - "Erreur modération Gemini commentaire (non bloquant):", + "Erreur modération IA commentaire (non bloquant):", err, ), ); diff --git a/API/Mainapi/liveTvRoutes.js b/API/Mainapi/liveTvRoutes.js index e214ba3..fc620af 100644 --- a/API/Mainapi/liveTvRoutes.js +++ b/API/Mainapi/liveTvRoutes.js @@ -91,7 +91,7 @@ const PROXY_SERVER_URL = process.env.PROXY_SERVER_URL; const TVMIO_CATEGORIES = {}; // URL de base pour Wiflix (Witv) -const WITV_BASE_URL = "https://witv.team"; +const WITV_BASE_URL = "https://witv.football"; // Cloudflare proxy for 403 bypass (livehdtv) const CF_PROXY_403 = process.env.CF_PROXY_403_URL; @@ -276,7 +276,7 @@ const BOLALOCA_CHANNELS = [ }, ]; -const LIVETV_BASE_URL = "https://livetv876.me"; +const LIVETV_BASE_URL = "https://livetv901.me"; const LIVETV_EMBED_REFERER = `${LIVETV_BASE_URL}/`; const LIVETV_ALLUPCOMING_PATHS = ["/frx/allupcoming/", "/frx/ads/"]; const LIVETV_CATEGORIES = { @@ -419,7 +419,7 @@ const FCTV_DEFAULT_SITE_TYPE = 2001; // (${BYPASS403_SERVER_URL}/proxy), with a hardcoded fallback. const FCTV_IMAGE_PROXY = process.env.BYPASS403_SERVER_URL ? `${process.env.BYPASS403_SERVER_URL.replace(/\/+$/, "")}/proxy` - : "https://proxy.movix.chat/proxy"; + : "https://proxy.movix.date/proxy"; function proxifyFctvImage(url) { if (!url) return ""; @@ -2763,7 +2763,7 @@ function shouldIgnoreLiveTvIframeUrl(rawUrl) { const combined = `${hostname}${pathname}${search}`; if ( - hostname === "ads.livetv876.me" || + hostname === "ads.livetv901.me" || hostname.startsWith("ads.") || hostname.startsWith("ad.") ) { diff --git a/API/Mainapi/middleware/auth.js b/API/Mainapi/middleware/auth.js index 0220fc6..e13eefb 100644 --- a/API/Mainapi/middleware/auth.js +++ b/API/Mainapi/middleware/auth.js @@ -244,9 +244,19 @@ async function isAdmin(req, res, next) { return res.status(403).json({ success: false, error: 'Accès refusé - Droits admin requis' }); } - // Récupérer le rôle (par défaut 'admin' si non défini) + // Récupérer le rôle (par défaut 'admin' si non défini — lignes legacy + // créées avant l'ajout de la colonne `role`). const role = rows[0].role || 'admin'; + // Les uploaders sont aussi dans la table `admins` (role='uploader') mais ne + // doivent PAS franchir `isAdmin` : ces routes (clés VIP, factures, OAuth apps, + // stats, gestion d'équipe…) sont réservées aux admins. Les routes ouvertes + // aux uploaders utilisent `isUploaderOrAdmin`. Sans ce contrôle, n'importe + // quel uploader pouvait appeler directement les API admin-only. + if (role !== 'admin') { + return res.status(403).json({ success: false, error: 'Accès refusé - Droits admin requis' }); + } + // Ajouter les infos admin à la requête (avec le rôle) req.admin = { userId, userType, adminId: rows[0].id, role }; next(); diff --git a/API/Mainapi/middleware/cors.js b/API/Mainapi/middleware/cors.js index 675a200..aa5d851 100644 --- a/API/Mainapi/middleware/cors.js +++ b/API/Mainapi/middleware/cors.js @@ -7,7 +7,9 @@ const cors = require("cors"); const { getOAuthAllowedCorsOrigins } = require('../utils/oauthClients'); const STATIC_ALLOWED_DOMAINS = [ - 'movix.tax', + 'movix.date', + 'movix.chat', + 'movix.cloud', 'movix.cash', 'movix.blog', 'movix.rodeo', diff --git a/API/Mainapi/middleware/security.js b/API/Mainapi/middleware/security.js index 803f223..864d1b5 100644 --- a/API/Mainapi/middleware/security.js +++ b/API/Mainapi/middleware/security.js @@ -40,7 +40,9 @@ function domainRestriction(req, res, next) { const allowedDomains = [ 'localhost:3000', - 'movix.tax', + 'movix.date', + 'movix.chat', + 'movix.cloud', 'movix.cash', 'movix.blog', 'movix.rodeo', diff --git a/API/Mainapi/package.json b/API/Mainapi/package.json index 317c986..8980b57 100644 --- a/API/Mainapi/package.json +++ b/API/Mainapi/package.json @@ -4,8 +4,7 @@ "type": "commonjs", "scripts": { "start": "node server.js", - "dev": "node server.js", - "start-new": "node server-new.js" + "dev": "node server.js" }, "dependencies": { "@fastify/compress": "^8.3.1", diff --git a/API/Mainapi/routes/admin.js b/API/Mainapi/routes/admin.js index 3e32e58..799b0ac 100644 --- a/API/Mainapi/routes/admin.js +++ b/API/Mainapi/routes/admin.js @@ -17,6 +17,8 @@ const { verifyAccessKey, invalidateVipCache } = require('../checkVip'); const { ANIME_SAMA_CACHE_DIR } = require('../utils/cacheManager'); const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore'); const { logDownloadLinkAction } = require('../utils/downloadLinksHistory'); +const { resolveAdminIdentity } = require('../utils/adminIdentity'); +const { readUserData } = require('./sync'); function parseAccessKeyExpiresAt(value) { if (value === undefined || value === null || value === '') { @@ -147,11 +149,15 @@ router.get('/links/:type/:id', async (req, res) => { }); } - // Parser les liens JSON - const result = rows.map(row => ({ - ...row, - links: typeof row.links === 'string' ? JSON.parse(row.links) : row.links - })); + // Parser les liens JSON. On retire le stamp `added_by` (identité de + // l'uploader) car cet endpoint est PUBLIC — pas de fuite d'IDs. + const result = rows.map(row => { + const parsed = typeof row.links === 'string' ? JSON.parse(row.links) : row.links; + return { + ...row, + links: Array.isArray(parsed) ? parsed.map(stripLinkOwner) : parsed + }; + }); res.json({ success: true, @@ -262,6 +268,44 @@ router.get('/check-vip', async (req, res) => { * Add or update streaming links * Body: { type: 'movie'|'tv', id: string, links: array, season?: number, episode?: number } */ +/** + * Streaming links live in the `links` JSON column as either a plain URL string + * (legacy / scraper-added) or an object ({ url, isVip, label, language, ... }). + * Links added through the admin panel are stamped with `added_by` (same shape as + * download links) so deletion can be scoped: an uploader may only remove the + * streaming links they added; a full admin may remove any. Un-stamped links + * (legacy strings/objects) have no owner and are therefore admin-only. + */ +function streamingLinkUrl(link) { + return typeof link === 'string' ? link : (link && link.url) || JSON.stringify(link); +} + +function stampStreamingLink(link, { adminId, userType }) { + const obj = typeof link === 'string' ? { url: link } : { ...link }; + obj.added_at = new Date().toISOString(); + obj.added_by = { id: String(adminId), auth_type: userType === 'bip39' ? 'bip-39' : 'oauth' }; + return obj; +} + +function ownsStreamingLink(link, admin) { + const stamp = link && typeof link === 'object' ? link.added_by : null; + if (!stamp) return false; + const myAuthType = admin.userType === 'bip39' ? 'bip-39' : 'oauth'; + return String(stamp.id) === String(admin.userId) && stamp.auth_type === myAuthType; +} + +function canModifyStreamingLink(link, admin) { + return admin.role === 'admin' || ownsStreamingLink(link, admin); +} + +// Removes the `added_by` owner stamp before returning links on the PUBLIC +// endpoint, so uploader user IDs are not leaked to anonymous visitors. +function stripLinkOwner(link) { + if (!link || typeof link !== 'object') return link; + const { added_by, ...rest } = link; + return rest; +} + router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { try { const { type, id, links, season, episode } = req.body; @@ -294,8 +338,9 @@ router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { [id] ); - let finalLinks = links; - let movieUrlsToLog = links.map(l => typeof l === 'string' ? l : l.url || JSON.stringify(l)); + const stampOpts = { adminId: req.admin.userId, userType: req.admin.userType }; + let finalLinks = links.map(l => stampStreamingLink(l, stampOpts)); + let movieUrlsToLog = links.map(streamingLinkUrl); if (existing.length > 0 && existing[0].links) { // Parse existing links const existingLinks = typeof existing[0].links === 'string' @@ -303,17 +348,14 @@ router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { : existing[0].links; // Merge with new links, avoiding duplicates - const existingUrls = new Set(existingLinks.map(link => - typeof link === 'string' ? link : link.url || JSON.stringify(link) - )); + const existingUrls = new Set(existingLinks.map(streamingLinkUrl)); - const newLinksToAdd = links.filter(link => { - const url = typeof link === 'string' ? link : link.url || JSON.stringify(link); - return !existingUrls.has(url); - }); + const newLinksToAdd = links.filter(link => !existingUrls.has(streamingLinkUrl(link))); - finalLinks = [...existingLinks, ...newLinksToAdd]; - movieUrlsToLog = newLinksToAdd.map(l => typeof l === 'string' ? l : l.url || JSON.stringify(l)); + // Keep existing links untouched (preserve their original owner stamp); + // only the freshly added ones get stamped with the current uploader. + finalLinks = [...existingLinks, ...newLinksToAdd.map(l => stampStreamingLink(l, stampOpts))]; + movieUrlsToLog = newLinksToAdd.map(streamingLinkUrl); } // Log each new streaming link as 'added' in history (for leaderboard scoring) @@ -359,8 +401,9 @@ router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { [id, season, episode] ); - let finalLinks = links; - let streamingUrlsToLog = links.map(l => typeof l === 'string' ? l : l.url || JSON.stringify(l)); + const stampOpts = { adminId: req.admin.userId, userType: req.admin.userType }; + let finalLinks = links.map(l => stampStreamingLink(l, stampOpts)); + let streamingUrlsToLog = links.map(streamingLinkUrl); if (existing.length > 0 && existing[0].links) { // Parse existing links const existingLinks = typeof existing[0].links === 'string' @@ -368,17 +411,14 @@ router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { : existing[0].links; // Merge with new links, avoiding duplicates - const existingUrls = new Set(existingLinks.map(link => - typeof link === 'string' ? link : link.url || JSON.stringify(link) - )); + const existingUrls = new Set(existingLinks.map(streamingLinkUrl)); - const newLinksToAdd = links.filter(link => { - const url = typeof link === 'string' ? link : link.url || JSON.stringify(link); - return !existingUrls.has(url); - }); + const newLinksToAdd = links.filter(link => !existingUrls.has(streamingLinkUrl(link))); - finalLinks = [...existingLinks, ...newLinksToAdd]; - streamingUrlsToLog = newLinksToAdd.map(l => typeof l === 'string' ? l : l.url || JSON.stringify(l)); + // Keep existing links untouched (preserve their original owner stamp); + // only the freshly added ones get stamped with the current uploader. + finalLinks = [...existingLinks, ...newLinksToAdd.map(l => stampStreamingLink(l, stampOpts))]; + streamingUrlsToLog = newLinksToAdd.map(streamingLinkUrl); } // Log each new streaming link as 'added' in history (for leaderboard scoring) @@ -439,12 +479,18 @@ router.post('/admin/links', isUploaderOrAdmin, async (req, res) => { /** * DELETE /admin/links - * Delete streaming links - * Body: { type: 'movie'|'tv', id: string, season?: number, episode?: number } + * Delete streaming links from a movie / TV episode. + * Body: { type, id, season?, episode?, url? } + * - With `url`: delete that single link (ownership enforced — uploaders may + * only delete a link they added; admins may delete any). + * - Without `url`: delete every link the caller is allowed to remove + * (uploader → only their own links; admin → all). Links belonging to other + * uploaders are preserved. Clears the `links` array in place — the film / + * episode row itself is kept (so download_links and other columns survive). */ router.delete('/admin/links', isUploaderOrAdmin, async (req, res) => { try { - const { type, id, season, episode } = req.body; + const { type, id, season, episode, url } = req.body; // Validation if (!type || !id) { @@ -458,118 +504,96 @@ router.delete('/admin/links', isUploaderOrAdmin, async (req, res) => { return res.status(400).json({ success: false, error: 'Type invalide. Utilisez "movie" ou "tv"' }); } + if (type === 'tv' && (season === undefined || season === null || episode === undefined || episode === null)) { + return res.status(400).json({ success: false, error: 'Pour les séries, season et episode sont requis' }); + } + const pool = getPool(); + // Load the current links array for the targeted movie / episode. + let existing; if (type === 'movie') { - // Fetch existing links for history logging before deletion - const [existingFilmRows] = await pool.execute('SELECT links FROM films WHERE id = ?', [id]); - if (existingFilmRows.length > 0 && existingFilmRows[0].links) { - const existingLinks = typeof existingFilmRows[0].links === 'string' - ? JSON.parse(existingFilmRows[0].links) - : existingFilmRows[0].links; - for (const link of (existingLinks || [])) { - const url = typeof link === 'string' ? link : link.url || JSON.stringify(link); - try { - await logDownloadLinkAction({ - adminId: req.admin.userId, - userType: req.admin.userType, - action: 'removed', - mediaType: 'movie', - tmdbId: id, - season: null, - episode: null, - linkUrl: url, - linkType: 'streaming', - }); - } catch (e) { - console.error('Failed to log streaming link removal:', e); - } - } - } - - const [result] = await pool.execute('DELETE FROM films WHERE id = ?', [id]); - - if (result.affectedRows === 0) { + const [rows] = await pool.execute('SELECT links FROM films WHERE id = ?', [id]); + if (rows.length === 0) { return res.status(404).json({ success: false, error: 'Film non trouvé' }); } - - res.json({ - success: true, - message: 'Film supprimé avec succès', - type: 'movie', - id - }); - + existing = rows[0].links + ? (typeof rows[0].links === 'string' ? JSON.parse(rows[0].links) : rows[0].links) + : []; } else { - // Pour les séries - // Fetch existing links for history logging before deletion - let fetchQuery, fetchParams; - if (season && episode) { - fetchQuery = 'SELECT links, season_number, episode_number FROM series WHERE series_id = ? AND season_number = ? AND episode_number = ?'; - fetchParams = [id, season, episode]; - } else if (season) { - fetchQuery = 'SELECT links, season_number, episode_number FROM series WHERE series_id = ? AND season_number = ?'; - fetchParams = [id, season]; - } else { - fetchQuery = 'SELECT links, season_number, episode_number FROM series WHERE series_id = ?'; - fetchParams = [id]; + const [rows] = await pool.execute( + 'SELECT links FROM series WHERE series_id = ? AND season_number = ? AND episode_number = ?', + [id, season, episode] + ); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'Épisode non trouvé' }); } - const [existingSeriesRows] = await pool.execute(fetchQuery, fetchParams); - for (const row of existingSeriesRows) { - if (!row.links) continue; - const existingLinks = typeof row.links === 'string' ? JSON.parse(row.links) : row.links; - for (const link of (existingLinks || [])) { - const url = typeof link === 'string' ? link : link.url || JSON.stringify(link); - try { - await logDownloadLinkAction({ - adminId: req.admin.userId, - userType: req.admin.userType, - action: 'removed', - mediaType: 'tv', - tmdbId: id, - season: row.season_number, - episode: row.episode_number, - linkUrl: url, - linkType: 'streaming', - }); - } catch (e) { - console.error('Failed to log streaming link removal:', e); - } - } - } - - let query, params; - - if (season && episode) { - // Supprimer un épisode spécifique - query = 'DELETE FROM series WHERE series_id = ? AND season_number = ? AND episode_number = ?'; - params = [id, season, episode]; - } else if (season) { - // Supprimer toute une saison - query = 'DELETE FROM series WHERE series_id = ? AND season_number = ?'; - params = [id, season]; - } else { - // Supprimer toute la série - query = 'DELETE FROM series WHERE series_id = ?'; - params = [id]; - } - - const [result] = await pool.execute(query, params); - - if (result.affectedRows === 0) { - return res.status(404).json({ success: false, error: 'Aucune donnée trouvée à supprimer' }); - } - - res.json({ - success: true, - message: `${result.affectedRows} épisode(s) supprimé(s) avec succès`, - type: 'tv', - id, - ...(season && { season }), - ...(episode && { episode }), - deletedCount: result.affectedRows - }); + existing = rows[0].links + ? (typeof rows[0].links === 'string' ? JSON.parse(rows[0].links) : rows[0].links) + : []; } + existing = Array.isArray(existing) ? existing : []; + + let remaining; + let removed; + if (url) { + // Single-link delete. + const target = existing.find(l => streamingLinkUrl(l) === url); + if (!target) { + return res.status(404).json({ success: false, error: 'Lien non trouvé' }); + } + if (!canModifyStreamingLink(target, req.admin)) { + return res.status(403).json({ success: false, error: 'Vous ne pouvez supprimer que les liens que vous avez ajoutés' }); + } + remaining = existing.filter(l => streamingLinkUrl(l) !== url); + removed = [target]; + } else { + // Bulk delete, scoped to what the caller owns. + removed = existing.filter(l => canModifyStreamingLink(l, req.admin)); + remaining = existing.filter(l => !canModifyStreamingLink(l, req.admin)); + if (removed.length === 0) { + return res.status(403).json({ success: false, error: 'Aucun lien vous appartenant à supprimer' }); + } + } + + const remainingJson = JSON.stringify(remaining); + if (type === 'movie') { + await pool.execute('UPDATE films SET links = ? WHERE id = ?', [remainingJson, id]); + } else { + await pool.execute( + 'UPDATE series SET links = ? WHERE series_id = ? AND season_number = ? AND episode_number = ?', + [remainingJson, id, season, episode] + ); + } + + // History logging (leaderboard scoring) for each removed link. + for (const link of removed) { + try { + await logDownloadLinkAction({ + adminId: req.admin.userId, + userType: req.admin.userType, + action: 'removed', + mediaType: type, + tmdbId: id, + season: type === 'tv' ? Number(season) : null, + episode: type === 'tv' ? Number(episode) : null, + linkUrl: streamingLinkUrl(link), + linkType: 'streaming', + }); + } catch (e) { + console.error('Failed to log streaming link removal:', e); + } + } + + res.json({ + success: true, + message: `${removed.length} lien(s) supprimé(s)`, + type, + id, + ...(type === 'tv' && { season, episode }), + removedCount: removed.length, + totalCount: remaining.length, + }); } catch (error) { console.error('Error deleting streaming links:', error); @@ -609,6 +633,16 @@ router.put('/admin/links', isUploaderOrAdmin, async (req, res) => { }); } + // Full-array replacement would let an uploader overwrite (and thereby delete) + // links added by other uploaders, bypassing the per-link ownership check. + // Reserve it for full admins; uploaders add via POST and remove via DELETE. + if (req.admin.role !== 'admin') { + return res.status(403).json({ + success: false, + error: 'Les uploaders ne peuvent pas remplacer les liens en masse. Supprimez uniquement vos propres liens.' + }); + } + const pool = getPool(); const linksJson = JSON.stringify(links); @@ -661,6 +695,47 @@ router.put('/admin/links', isUploaderOrAdmin, async (req, res) => { } }); +/** + * GET /admin/streaming-links/:type/:id (authed) + * Like the public GET /links but KEEPS the `added_by` owner stamp, so the admin + * panel can hide the delete button on links the current uploader doesn't own. + */ +router.get('/admin/streaming-links/:type/:id', isUploaderOrAdmin, async (req, res) => { + try { + const { type, id } = req.params; + const { season, episode } = req.query; + + if (!['movie', 'tv'].includes(type)) { + return res.status(400).json({ success: false, error: 'Type must be movie or tv' }); + } + + const pool = getPool(); + let links = []; + if (type === 'movie') { + const [rows] = await pool.execute('SELECT links FROM films WHERE id = ?', [id]); + links = rows[0]?.links + ? (typeof rows[0].links === 'string' ? JSON.parse(rows[0].links) : rows[0].links) + : []; + } else { + if (season == null || episode == null) { + return res.status(400).json({ success: false, error: 'season and episode are required for tv' }); + } + const [rows] = await pool.execute( + 'SELECT links FROM series WHERE series_id = ? AND season_number = ? AND episode_number = ?', + [id, Number(season), Number(episode)] + ); + links = rows[0]?.links + ? (typeof rows[0].links === 'string' ? JSON.parse(rows[0].links) : rows[0].links) + : []; + } + + res.json({ success: true, links: Array.isArray(links) ? links : [] }); + } catch (error) { + console.error('Error fetching streaming links (admin):', error); + res.status(500).json({ success: false, error: 'Failed to fetch streaming links' }); + } +}); + // === ADMIN ROUTES - VIP KEY MANAGEMENT === /** @@ -685,6 +760,173 @@ router.get('/admin/check', isUploaderOrAdmin, async (req, res) => { } }); +// ===================================== +// TEAM MANAGEMENT (admins + uploaders) — admin only +// ===================================== + +// Normalises an incoming auth type to the DB enum used by the admins table. +const normalizeAdminAuthType = (raw) => { + const v = String(raw || '').toLowerCase(); + if (v === 'bip39' || v === 'bip-39') return 'bip-39'; + if (v === 'oauth') return 'oauth'; + return null; +}; + +/** + * GET /admin/admins + * List every admin + uploader with their resolved identity (first Movix + * profile name/avatar). + */ +router.get('/admin/admins', isAdmin, async (req, res) => { + try { + const pool = getPool(); + const [rows] = await pool.execute('SELECT * FROM admins ORDER BY created_at ASC'); + const members = await Promise.all(rows.map(async (r) => { + const identity = await resolveAdminIdentity(r.user_id, r.auth_type, { preferProfile: true }); + return { + id: r.id, + userId: r.user_id, + authType: r.auth_type, + role: r.role || 'admin', + username: identity.username, + avatar: identity.avatar, + createdAt: r.created_at, + }; + })); + res.json({ success: true, members }); + } catch (error) { + console.error('Error listing admins:', error); + res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'équipe' }); + } +}); + +/** + * POST /admin/admins + * Add an uploader by their bip39 / oauth user id. Admin only. + * Body: { userId, authType: 'oauth' | 'bip39' } + */ +router.post('/admin/admins', isAdmin, async (req, res) => { + try { + const cleanUserId = String(req.body?.userId || '').trim(); + const normAuth = normalizeAdminAuthType(req.body?.authType); + if (!cleanUserId || !normAuth) { + return res.status(400).json({ success: false, error: 'userId et authType (oauth ou bip39) requis' }); + } + + // Confirm the target account actually exists before granting rights. + // readUserData returns {} (not null) for a missing file, so check for the + // markers a real account always has: at least one profile, or an auth blob. + const userType = normAuth === 'bip-39' ? 'bip39' : 'oauth'; + let userData = null; + try { + userData = await readUserData(userType, cleanUserId); + } catch { + userData = null; + } + const userExists = !!userData && ( + (Array.isArray(userData.profiles) && userData.profiles.length > 0) || !!userData.auth + ); + if (!userExists) { + return res.status(404).json({ success: false, error: 'Aucun utilisateur trouvé avec cet identifiant' }); + } + + const pool = getPool(); + // user_id is globally UNIQUE in the admins table. + const [existing] = await pool.execute('SELECT id, role FROM admins WHERE user_id = ?', [cleanUserId]); + if (existing.length > 0) { + return res.status(409).json({ success: false, error: 'Cet utilisateur est déjà admin ou uploader' }); + } + + await pool.execute( + "INSERT INTO admins (user_id, auth_type, role) VALUES (?, ?, 'uploader')", + [cleanUserId, normAuth] + ); + + const identity = await resolveAdminIdentity(cleanUserId, normAuth, { preferProfile: true }); + res.status(201).json({ + success: true, + member: { + userId: cleanUserId, + authType: normAuth, + role: 'uploader', + username: identity.username, + avatar: identity.avatar, + }, + }); + } catch (error) { + if (error && error.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ success: false, error: 'Cet utilisateur est déjà admin ou uploader' }); + } + console.error('Error adding uploader:', error); + res.status(500).json({ success: false, error: 'Erreur lors de l\'ajout de l\'uploader' }); + } +}); + +/** + * DELETE /admin/admins/:id + * Remove an uploader. Admin only. Admins cannot be removed through this route + * (guards against accidental / malicious privilege removal). + */ +router.delete('/admin/admins/:id', isAdmin, async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id)) { + return res.status(400).json({ success: false, error: 'ID invalide' }); + } + const pool = getPool(); + const [rows] = await pool.execute('SELECT * FROM admins WHERE id = ?', [id]); + if (rows.length === 0) { + return res.status(404).json({ success: false, error: 'Membre introuvable' }); + } + if ((rows[0].role || 'admin') !== 'uploader') { + return res.status(403).json({ success: false, error: 'Seuls les uploaders peuvent être retirés ici' }); + } + await pool.execute('DELETE FROM admins WHERE id = ?', [id]); + res.json({ success: true }); + } catch (error) { + console.error('Error removing uploader:', error); + res.status(500).json({ success: false, error: 'Erreur lors du retrait de l\'uploader' }); + } +}); + +/** + * GET /admin/team/history?userId=&authType=&page=&limit= + * Paginated add/remove action history (streaming + download links) for a given + * admin / uploader, so an admin can audit what someone uploaded or deleted. + * Admin only. + */ +router.get('/admin/team/history', isAdmin, async (req, res) => { + try { + const userId = String(req.query.userId || '').trim(); + const normAuth = normalizeAdminAuthType(req.query.authType); + if (!userId || !normAuth) { + return res.status(400).json({ success: false, error: 'userId et authType requis' }); + } + const page = Math.max(1, parseInt(String(req.query.page || '1'), 10) || 1); + const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit || '30'), 10) || 30)); + const offset = (page - 1) * limit; + + const pool = getPool(); + const [rows] = await pool.execute( + `SELECT action, link_type, media_type, tmdb_id, season, episode, link_url, changed_at + FROM download_links_history + WHERE admin_id = ? AND admin_auth_type = ? + ORDER BY changed_at DESC + LIMIT ? OFFSET ?`, + [userId, normAuth, limit, offset] + ); + const [countRows] = await pool.execute( + 'SELECT COUNT(*) AS total FROM download_links_history WHERE admin_id = ? AND admin_auth_type = ?', + [userId, normAuth] + ); + const total = countRows[0]?.total || 0; + res.json({ success: true, history: rows, total, hasMore: total > offset + rows.length }); + } catch (error) { + console.error('Error fetching team history:', error); + res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'historique' }); + } +}); + /** * GET /admin/vip-keys * Retrieve all VIP keys @@ -998,6 +1240,28 @@ function mergeDownloadLinks(existing, incoming, { adminId, userType, fullSeason return { merged: [...existingLinks, ...toAdd], added: toAdd }; } +/** + * Ownership check for download links. Each link is stamped with the uploader + * that added it (`added_by = { id, auth_type }`, see mergeDownloadLinks). + * Returns true only when the stamp matches the current admin's identity. + * Links with no `added_by` (legacy, added before attribution existed) are + * treated as not-owned. + */ +function ownsDownloadLink(link, admin) { + const stamp = link && link.added_by; + if (!stamp) return false; + const myAuthType = admin.userType === 'bip39' ? 'bip-39' : 'oauth'; + return String(stamp.id) === String(admin.userId) && stamp.auth_type === myAuthType; +} + +/** + * Authorises a delete/edit on a single download link. Full admins may modify + * any link; uploaders may only modify the links they uploaded themselves. + */ +function canModifyDownloadLink(link, admin) { + return admin.role === 'admin' || ownsDownloadLink(link, admin); +} + router.post('/admin/download-links', isUploaderOrAdmin, async (req, res) => { try { const { type, id, links, season, episode, fullSeason } = req.body; @@ -1131,11 +1395,15 @@ router.delete('/admin/download-links', isUploaderOrAdmin, async (req, res) => { : []; } - const before = existing.length; - const remaining = existing.filter(l => l.url !== url); - if (remaining.length === before) { + const target = existing.find(l => l.url === url); + if (!target) { return res.status(404).json({ success: false, error: 'Link not found' }); } + // Uploaders may only delete links they uploaded; admins may delete any. + if (!canModifyDownloadLink(target, req.admin)) { + return res.status(403).json({ success: false, error: 'Vous ne pouvez supprimer que les liens que vous avez ajoutés' }); + } + const remaining = existing.filter(l => l.url !== url); const remainingJson = JSON.stringify(remaining); if (type === 'movie') { @@ -1209,6 +1477,10 @@ router.put('/admin/download-links', isUploaderOrAdmin, async (req, res) => { return res.status(404).json({ success: false, error: 'Link not found' }); } const original = existing[idx]; + // Uploaders may only edit links they uploaded; admins may edit any. + if (!canModifyDownloadLink(original, req.admin)) { + return res.status(403).json({ success: false, error: 'Vous ne pouvez modifier que les liens que vous avez ajoutés' }); + } existing[idx] = { ...original, url: String(newLink.url), diff --git a/API/Mainapi/routes/adminOauthApps.js b/API/Mainapi/routes/adminOauthApps.js index 1e0cb88..c41d8e6 100644 --- a/API/Mainapi/routes/adminOauthApps.js +++ b/API/Mainapi/routes/adminOauthApps.js @@ -4,16 +4,15 @@ * * Toutes les routes sont protégées par `isAdmin` (table `admins`). * Source de vérité : la table `oauth_clients` (alimentée au boot par - * `oauthClientsDb.reloadCache()`). Toute mutation appelle `reloadCache()` - * en fin de requête pour rafraîchir le cache du worker courant. + * `oauthClientsDb.reloadCache()`). Toute mutation appelle + * `reloadCacheAndBroadcast()` en fin de requête : recharge le cache du + * worker courant ET publie sur Redis (canal `oauth:clients:changed`) pour + * que tous les autres workers du cluster rechargent aussi. * - * Note multi-worker : chaque worker a son propre cache in-process. Une - * mutation depuis le worker A ne rafraîchit pas le cache du worker B - * immédiatement. C'est acceptable car : - * 1) les opérations admin sont rares ; - * 2) le cache est rechargé au boot ; - * 3) une lecture stale max 1 requête. - * Si besoin d'invalidation cross-worker → publier un message Redis. + * Sans ce broadcast, un worker dont le cache est stale rejette + * indéfiniment les requêtes OAuth d'un client modifié après le boot + * (ex. une redirect_uri ajoutée) — symptôme intermittent selon le worker + * qui reçoit la requête. */ const express = require('express'); @@ -286,7 +285,7 @@ router.post('/', async (req, res) => { ], ); - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); const row = await fetchAppByClientId(pool, clientId); const serialized = serializeAppRow(row); // Le secret n'est exposé qu'UNE fois (à la création) — l'admin doit le copier. @@ -359,7 +358,7 @@ router.put('/:clientId', async (req, res) => { params, ); - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); const row = await fetchAppByClientId(pool, clientId); return res.json({ success: true, app: serializeAppRow(row) }); } catch (err) { @@ -382,7 +381,7 @@ router.post('/:clientId/regenerate-secret', async (req, res) => { 'UPDATE oauth_clients SET client_secret = ?, updated_at = ? WHERE client_id = ?', [newSecret, Date.now(), clientId], ); - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); return res.json({ success: true, clientSecret: newSecret }); } catch (err) { return serverError(res, err, 'Impossible de régénérer le secret'); @@ -403,7 +402,7 @@ router.delete('/:clientId', async (req, res) => { if (existing.icon_filename) { await removeIconFile(existing.icon_filename); } - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); return res.json({ success: true }); } catch (err) { return serverError(res, err, 'Impossible de supprimer l\'application'); @@ -467,7 +466,7 @@ router.post('/:clientId/icon', async (req, res) => { await removeIconFile(previousFilename); } - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); return res.json({ success: true, iconFilename: filename, @@ -491,7 +490,7 @@ router.delete('/:clientId/icon', async (req, res) => { 'UPDATE oauth_clients SET icon_filename = NULL, updated_at = ? WHERE client_id = ?', [Date.now(), clientId], ); - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); } return res.json({ success: true }); } catch (err) { @@ -532,7 +531,7 @@ router.post('/:clientId/vip-balance', async (req, res) => { [next, Date.now(), rows[0].id], ); await conn.commit(); - await oauthClientsDb.reloadCache(); + await oauthClientsDb.reloadCacheAndBroadcast(); return res.json({ success: true, previousBalance: current, diff --git a/API/Mainapi/routes/authRoutes.js b/API/Mainapi/routes/authRoutes.js index 5b1fa9b..27d6c57 100644 --- a/API/Mainapi/routes/authRoutes.js +++ b/API/Mainapi/routes/authRoutes.js @@ -346,6 +346,13 @@ async function finalizeResolvedSession(req, { fallbackResolvedProvider ); + // Si le pseudo provider (Discord/Google) viole la policy (trop long, contient + // des caractères de contrôle/zero-width), on remonte un flag pour que le + // frontend force l'user à le changer via POST /api/auth/username. + const { isProfileNameValid } = require('../utils/syncPolicy'); + const currentUsername = authData?.userProfile?.username; + const requiresUsernameChange = !isProfileNameValid(currentUsername); + return { success: true, sessionId, @@ -358,6 +365,7 @@ async function finalizeResolvedSession(req, { linkProvider: linkedTarget?.provider || null, }, authData, + requiresUsernameChange, }; } @@ -607,6 +615,10 @@ router.get('/links', async (req, res) => { || (isSupportedProvider(resolvedProviderCandidate) ? resolvedProviderCandidate : null) || (auth.userType === 'bip39' ? 'bip39' : null); + const { isProfileNameValid } = require('../utils/syncPolicy'); + const currentUsername = authData?.userProfile?.username; + const requiresUsernameChange = !isProfileNameValid(currentUsername); + return res.status(200).json({ success: true, account: { @@ -618,6 +630,8 @@ router.get('/links', async (req, res) => { manageWithProvider: accountProvider, }, links: getProviderStatusMap(records), + requiresUsernameChange, + currentUsername: currentUsername || null, }); } catch (error) { console.error('Error getting account links:', error); @@ -727,4 +741,77 @@ router.delete('/links/:provider', async (req, res) => { } }); +// POST /username — l'user met à jour son pseudo (auth.userProfile.username) +// quand le pseudo provider (Discord/Google) dépasse 32 caractères ou contient +// des caractères dangereux. Le frontend appelle ça après affichage de la +// modale bloquante. Met aussi à jour le profil par défaut si son nom matchait +// l'ancien username (pour garder la cohérence visuelle). +router.post('/username', authRateLimit, async (req, res) => { + try { + const auth = await getAuthIfValid(req); + if (!auth || !['oauth', 'bip39'].includes(auth.userType)) { + return res.status(401).json({ success: false, error: 'Non autorisé' }); + } + + const { validateProfileName, SyncPolicyError } = require('../utils/syncPolicy'); + let cleanedName; + try { + cleanedName = validateProfileName(req.body?.username); + } catch (e) { + if (e instanceof SyncPolicyError) { + return res.status(e.status).json({ success: false, error: e.message, code: e.code }); + } + throw e; + } + + const { readUserData, writeUserData } = getSyncModule(); + const userData = await readUserData(auth.userType, auth.userId) || {}; + + let parsedAuth = null; + if (typeof userData.auth === 'string') { + try { parsedAuth = JSON.parse(userData.auth); } catch { parsedAuth = null; } + } + if (!parsedAuth || !parsedAuth.userProfile) { + // Pas de userProfile stocké : on en crée un minimal pour porter le nom. + parsedAuth = { + ...(parsedAuth || {}), + userProfile: { + id: String(auth.userId), + provider: auth.userType === 'bip39' ? 'bip39' : (parsedAuth?.provider || 'oauth'), + username: cleanedName, + createdAt: new Date().toISOString(), + }, + }; + } else { + const oldUsername = parsedAuth.userProfile.username; + parsedAuth.userProfile = { ...parsedAuth.userProfile, username: cleanedName }; + // Met aussi à jour le nom du profil par défaut si l'user n'a jamais + // renommé son profil (sinon on ne touche pas — il a fait un choix conscient). + if (Array.isArray(userData.profiles) && typeof oldUsername === 'string') { + const defaultProfile = userData.profiles.find((p) => p && p.isDefault); + if (defaultProfile && defaultProfile.name === oldUsername) { + defaultProfile.name = cleanedName; + } + } + } + + userData.auth = JSON.stringify(parsedAuth); + userData.lastUpdated = Date.now(); + + const success = await writeUserData(auth.userType, auth.userId, userData); + if (!success) { + return res.status(500).json({ success: false, error: 'Impossible de sauvegarder le nouveau pseudo' }); + } + + return res.status(200).json({ + success: true, + username: cleanedName, + authData: parsedAuth, + }); + } catch (error) { + console.error('Error updating username:', error); + return res.status(500).json({ success: false, error: 'Erreur lors de la mise à jour du pseudo' }); + } +}); + module.exports = router; diff --git a/API/Mainapi/routes/cinestream.js b/API/Mainapi/routes/cinestream.js new file mode 100644 index 0000000..fa33ee5 --- /dev/null +++ b/API/Mainapi/routes/cinestream.js @@ -0,0 +1,271 @@ +/** + * CineStream movie source (helper module, not a router). + * + * Powers the *movie* path of the wiflix source (see wiflix.js): flemmix sits + * behind a Cloudflare bot-shield that's painful for films, so movies are + * scraped from cinestream.info (a public Next.js App Router site) instead. + * TV stays on flemmix. + * + * Flow (all GET, public HTML — no cookie handshake): + * 1. /search?q= -> <a href="/film/{slug}"> candidates + * 2. /film/{slug} -> embedded RSC flight carries the TMDB id + * ("tmdbid":N) + ordered players array + * ([{name},...]). tmdbid is AUTHORITATIVE + * (cinestream reuses TMDB ids), so we match + * on it — no release-year rejection needed. + * 3. /player/{tmdbid}/{index} -> <iframe src> embed url for player[index] + * + * Player language: a player whose name starts with "vostfr" -> VOSTFR, else VF. + * Array order is the /player/{tmdbid}/{index} index — preserved end to end. + * + * Returns the SAME shape as wiflix's fetchWiflixMovieData so the existing route + * + cache in wiflix.js consume it unchanged. + */ + +const { makeCinestreamRequest } = require("../utils/proxyManager"); +const { fetchTmdbDetails } = require("../utils/tmdbCache"); + +const TMDB_API_KEY = process.env.TMDB_API_KEY || ""; +const TMDB_API_URL = "https://api.themoviedb.org/3"; +// Override via env if cinestream rotates domains (no code change). +const CINESTREAM_BASE_URL = + process.env.CINESTREAM_BASE_URL || "https://cinestream.info"; + +// Cap film-page confirmations per movie. Search is relevance-sorted and we bump +// year-matches first, so the right film is almost always in the first 1-2. +const MAX_FILM_PAGE_FETCHES = 8; + +const toBody = (res) => + typeof res.data === "string" ? res.data : JSON.stringify(res.data); + +// All cinestream fetches go through CycleTLS + ProxyScrape rotation (see +// makeCinestreamRequest) — same mechanism as the other Cloudflare-fronted +// scrapers. It returns a response even on 525/5xx (never throws on status), so a +// flaky upstream just yields HTML our regexes can't match -> "not found", quiet. +// timeout is ms here; the helper takes seconds. +async function cinestreamGet(url, timeout = 15000) { + return makeCinestreamRequest(url, { timeout: Math.ceil(timeout / 1000) }); +} + +// Every cinestream slug ends with its release year ("toy-story-5-2026"). +const yearFromSlug = (slug) => { + const m = slug.match(/-(\d{4})$/); + return m ? parseInt(m[1], 10) : null; +}; + +// === Step 1: search -> candidate slugs (dedup, search order preserved) === +async function searchCinestream(title) { + const url = `${CINESTREAM_BASE_URL}/search?q=${encodeURIComponent(title)}`; + const res = await cinestreamGet(url); + const html = toBody(res); + + const slugs = []; + const seen = new Set(); + const re = /href="\/film\/([^"]+)"/g; + let m; + while ((m = re.exec(html)) !== null) { + const slug = m[1]; + if (seen.has(slug)) continue; + seen.add(slug); + slugs.push(slug); + } + return slugs; +} + +// === Step 2: film page -> { tmdbid, players: [{name}, ...] } === +// RSC flight is embedded *escaped* in the HTML (\"tmdbid\":N, \"players\":[...]). +async function fetchCinestreamFilm(slug) { + const url = `${CINESTREAM_BASE_URL}/film/${slug}`; + const res = await cinestreamGet(url); + const html = toBody(res); + + const tmdbMatch = html.match(/tmdbid\\?":(\d+)/); + const tmdbid = tmdbMatch ? parseInt(tmdbMatch[1], 10) : null; + + let players = []; + const playersMatch = html.match(/players\\?":(\[.*?\])/); + if (playersMatch) { + try { + players = JSON.parse(playersMatch[1].replace(/\\"/g, '"')); + } catch { + players = []; + } + } + + return { tmdbid, players, url }; +} + +// === Step 3: player page -> iframe embed url for a given index === +async function fetchCinestreamEmbed(tmdbid, index) { + const url = `${CINESTREAM_BASE_URL}/player/${tmdbid}/${index}`; + try { + const res = await cinestreamGet(url, 12000); + const html = toBody(res); + const m = html.match(/<iframe[^>]+src="([^"]+)"/i); + return m ? m[1] : null; + } catch { + return null; + } +} + +function categorize(players) { + const vf = []; + const vostfr = []; + for (const p of players) { + if (p.type === "VOSTFR") vostfr.push(p); + else vf.push(p); + } + return { vf, vostfr }; +} + +// === Main: same return shape as wiflix's fetchWiflixMovieData === +async function fetchCinestreamMovieData(tmdbId, cachedData = null) { + try { + const tmdbData = await fetchTmdbDetails( + TMDB_API_URL, + TMDB_API_KEY, + tmdbId, + "movie", + "fr-FR", + ); + + if (!tmdbData) { + if (cachedData) return cachedData; + return { + success: false, + error: "Film non trouve sur TMDB", + tmdb_id: tmdbId, + }; + } + + const tmdbYear = tmdbData.release_date + ? new Date(tmdbData.release_date).getFullYear() + : null; + + const titlesToTry = [tmdbData.title, tmdbData.original_title].filter( + (t, i, arr) => t && arr.indexOf(t) === i, + ); + + // Gather candidate slugs across titles (dedup, keep order). + const candidates = []; + const seen = new Set(); + for (const title of titlesToTry) { + let slugs = []; + try { + slugs = await searchCinestream(title); + } catch (err) { + console.log(`[CINESTREAM SEARCH] "${title}": ${err.message}`); + } + for (const slug of slugs) { + if (seen.has(slug)) continue; + seen.add(slug); + candidates.push(slug); + } + } + + if (candidates.length === 0) + return { + success: false, + error: "Film non trouve sur CineStream", + tmdb_id: tmdbId, + titles_tried: titlesToTry, + }; + + // Bump year-matches first (stable) — tmdbid is the real match, this just + // minimises wasted film-page fetches on common titles. + const ranked = tmdbYear + ? candidates + .map((slug, i) => ({ slug, i, yearHit: yearFromSlug(slug) === tmdbYear })) + .sort((a, b) => (b.yearHit ? 1 : 0) - (a.yearHit ? 1 : 0) || a.i - b.i) + .map((c) => c.slug) + : candidates; + + // Confirm the right film by tmdbid. + let matched = null; + for (const slug of ranked.slice(0, MAX_FILM_PAGE_FETCHES)) { + let film; + try { + film = await fetchCinestreamFilm(slug); + } catch (err) { + console.log(`[CINESTREAM FILM] "${slug}": ${err.message}`); + continue; + } + if (film.tmdbid === Number(tmdbId)) { + matched = film; + break; + } + } + + if (!matched) + return { + success: false, + error: "Film non trouve sur CineStream", + tmdb_id: tmdbId, + titles_tried: titlesToTry, + }; + + if (!matched.players.length) + return { + success: false, + error: "Aucun lecteur video trouve", + tmdb_id: tmdbId, + cinestream_url: matched.url, + }; + + // Resolve each player[index] -> embed url (parallel). Index = the + // /player/{tmdbid}/{index} index, so map over the array as-is. + const embeds = await Promise.all( + matched.players.map((p, index) => + fetchCinestreamEmbed(matched.tmdbid, index).then((url) => ({ + name: p.name, + url, + })), + ), + ); + + const players = []; + for (const e of embeds) { + if (!e.url) continue; + const type = /^vostfr/i.test((e.name || "").trim()) ? "VOSTFR" : "VF"; + const domainMatch = e.url.match(/https?:\/\/(?:www\.)?([^/]+)/); + players.push({ + name: domainMatch ? domainMatch[1] : e.name, + url: e.url, + episode: 1, + type, + }); + } + + if (players.length === 0) + return { + success: false, + error: "Aucun lecteur video trouve", + tmdb_id: tmdbId, + cinestream_url: matched.url, + }; + + const categorized = categorize(players); + return { + success: true, + tmdb_id: tmdbId, + title: tmdbData.title, + original_title: tmdbData.original_title, + source: "cinestream", + // Key kept as wiflix_url for response-shape compat with the wiflix route. + wiflix_url: matched.url, + players: { vf: categorized.vf, vostfr: categorized.vostfr }, + cache_timestamp: new Date().toISOString(), + }; + } catch (error) { + console.error(`[CINESTREAM MOVIE] ${tmdbId}: ${error.message}`); + if (cachedData) return cachedData; + return { + success: false, + error: "Erreur lors de la recuperation des donnees CineStream", + message: error.message, + tmdb_id: tmdbId, + }; + } +} + +module.exports = { fetchCinestreamMovieData }; diff --git a/API/Mainapi/routes/coflix.js b/API/Mainapi/routes/coflix.js index 0347688..63a6360 100644 --- a/API/Mainapi/routes/coflix.js +++ b/API/Mainapi/routes/coflix.js @@ -201,9 +201,12 @@ async function searchCoflixByTitle(title, mediaType, releaseYear) { return filteredResults; } catch (error) { - console.error( - `Erreur lors de la recherche sur Coflix pour ${title}: ${deps.formatCoflixError(error)}`, - ); + // Global coflix.band 429 is announced once by makeCoflixRequest's cooldown — don't spam per title. + if (!error?.coflixSiteRateLimited) { + console.error( + `Erreur lors de la recherche sur Coflix pour ${title}: ${deps.formatCoflixError(error)}`, + ); + } throw error; } } @@ -680,7 +683,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) { (ep) => parseInt(ep.number) === parseInt(episodeNumber), ); if (episode && episode.links) { - episodeUrl = episode.links.startsWith("https://coflix.date") + episodeUrl = episode.links.startsWith("https://coflix.trade") ? `${episode.links}` : episode.links; } @@ -688,7 +691,7 @@ async function getTvDataFromCoflix(url, seasonNumber, episodeNumber) { } if (!episodeUrl) { - episodeUrl = `https://coflix.date/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`; + episodeUrl = `https://coflix.trade/episode/${seriesSlug}-${seasonNumber}x${episodeNumber}/`; } try { diff --git a/API/Mainapi/routes/cpasmal.js b/API/Mainapi/routes/cpasmal.js index 800a6ff..4ada450 100644 --- a/API/Mainapi/routes/cpasmal.js +++ b/API/Mainapi/routes/cpasmal.js @@ -22,30 +22,24 @@ let deps = { CPASMAL_BASE_URL: '', TMDB_API_URL: '', TMDB_API_KEY: '', - axiosCpasmalRequest: async () => { throw new Error('cpasmal not configured'); }, - DARKINO_PROXIES: [], - getDarkinoHttpProxyAgent: () => null, + makeCpasmalRequest: async () => { throw new Error('cpasmal not configured'); }, getFromCacheNoExpiration: async () => null, shouldUpdateCache: async () => true }; /** - * Crée une fonction de requête scopée qui utilise toujours le même proxy HTTP (DARKINO_PROXIES). - * Garantit que tout le scraping d'un même film/série passe par la même IP. - * Un proxy est obligatoire — si aucun n'est disponible, une erreur est levée. + * Toutes les requêtes Cpasmal passent par CycleTLS (JA3 Chrome) : Cloudflare + * bot-challenge les requêtes axios + proxies datacenter avec un 403 (GET comme + * POST). makeCpasmalRequest tourne un vrai JA3 Chrome sur le pool ProxyScrape. + * Adapte la config axios-style { method, url, data, headers } du routeur vers + * la signature de makeCpasmalRequest et renvoie un objet axios-like { data }. */ function _createScopedRequest() { - const proxies = deps.DARKINO_PROXIES; - if (!proxies || proxies.length === 0) { - throw new Error('Cpasmal: aucun proxy HTTP configuré (DARKINO_PROXIES vide)'); - } - const proxy = proxies[Math.floor(Math.random() * proxies.length)]; - const agents = deps.getDarkinoHttpProxyAgent(proxy); - if (!agents) { - throw new Error(`Cpasmal: impossible de créer l'agent proxy pour ${proxy.host}:${proxy.port}`); - } - agents._label = `${proxy.host}:${proxy.port}`; - return (config) => deps.axiosCpasmalRequest({ ...config, _cpasmalAgents: agents }); + return (config) => deps.makeCpasmalRequest(config.url, { + method: config.method || 'get', + body: config.data || '', + headers: config.headers || {}, + }); } function configure(injected) { @@ -189,15 +183,30 @@ function _scoreCpasmalResults($, items, title, year, type, normalize) { // Run a single cpasmal search query across multiple pages, return { bestMatch, bestScore } async function _runCpasmalSearch(searchQuery, title, year, type, normalize, maxPages, requestFn) { - const doRequest = requestFn || deps.axiosCpasmalRequest; + const doRequest = requestFn || _createScopedRequest(); let bestMatch = null; let bestScore = -1; let page = 1; while (page <= maxPages) { try { - const searchUrl = `${deps.CPASMAL_BASE_URL}/index.php?do=search&subaction=search&search_start=${page}&full_search=0&story=${encodeURIComponent(searchQuery)}`; - const response = await doRequest({ method: 'get', url: searchUrl }); + // POST form (DLE classic): Cloudflare WAF 403s the GET ?do=search querystring, POST body passes. + const params = new URLSearchParams(); + params.append('do', 'search'); + params.append('subaction', 'search'); + params.append('search_start', String(page)); + params.append('full_search', '0'); + params.append('story', searchQuery); + const response = await doRequest({ + method: 'post', + url: `${deps.CPASMAL_BASE_URL}/index.php`, + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': deps.CPASMAL_BASE_URL, + 'Referer': `${deps.CPASMAL_BASE_URL}/` + } + }); const $ = cheerio.load(response.data); const items = $('div.thumb'); @@ -223,7 +232,7 @@ async function _runCpasmalSearch(searchQuery, title, year, type, normalize, maxP } async function searchCpasmal(title, year, type, requestFn) { - const doRequest = requestFn || deps.axiosCpasmalRequest; + const doRequest = requestFn || _createScopedRequest(); // Prepare search query: normalize spaces and keep colons let searchQuery = title.replace(/\u00A0/g, ' ').replace(/\s+/g, ' ').trim(); @@ -246,8 +255,22 @@ async function searchCpasmal(title, year, type, requestFn) { // === Strategy 3: If still no good match, try full_search=1 === if (bestScore < 20) { try { - const fullSearchUrl = `${deps.CPASMAL_BASE_URL}/index.php?do=search&subaction=search&search_start=1&full_search=1&story=${encodeURIComponent(searchQuery)}`; - const response = await doRequest({ method: 'get', url: fullSearchUrl }); + const params = new URLSearchParams(); + params.append('do', 'search'); + params.append('subaction', 'search'); + params.append('search_start', '1'); + params.append('full_search', '1'); + params.append('story', searchQuery); + const response = await doRequest({ + method: 'post', + url: `${deps.CPASMAL_BASE_URL}/index.php`, + data: params.toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Origin': deps.CPASMAL_BASE_URL, + 'Referer': `${deps.CPASMAL_BASE_URL}/` + } + }); const $ = cheerio.load(response.data); const items = $('div.thumb'); if (items.length > 0) { @@ -267,7 +290,7 @@ async function searchCpasmal(title, year, type, requestFn) { // Helper to extract links from a movie page async function extractMovieLinks(url, requestFn) { - const doRequest = requestFn || deps.axiosCpasmalRequest; + const doRequest = requestFn || _createScopedRequest(); if (process.env.DEBUG_CPASMAL) console.time(`[Cpasmal] ExtractMovieLinks ${url}`); try { const response = await doRequest({ method: 'get', url: url }); @@ -353,7 +376,7 @@ async function extractMovieLinks(url, requestFn) { // Helper to extract links from a series episode async function extractSeriesLinks(seriesUrl, seasonNumber, episodeNumber, requestFn) { - const doRequest = requestFn || deps.axiosCpasmalRequest; + const doRequest = requestFn || _createScopedRequest(); if (process.env.DEBUG_CPASMAL) console.time(`[Cpasmal] ExtractSeriesLinks ${seriesUrl}`); try { const response = await doRequest({ method: 'get', url: seriesUrl }); diff --git a/API/Mainapi/routes/darkiworld.js b/API/Mainapi/routes/darkiworld.js index 8b4063a..eaebd90 100644 --- a/API/Mainapi/routes/darkiworld.js +++ b/API/Mainapi/routes/darkiworld.js @@ -1,9 +1,17 @@ /** * DarkiWorld routes module. - * Extracted from server.js -- handles DarkiWorld download links, decoding, + * Extracted from server.js — handles DarkiWorld download links, decoding, * seasons and episodes retrieval. * * Mounted at /api/darkiworld (paths below are relative to that prefix). + * + * Under the hydracker freeze (2026-05-15), the list of links and the decode + * step come exclusively from the local sqlite snapshots under + * `Mainapi/darkino-backups/`. No outbound calls to hydracker.com are made + * from this file; the only upstream surface that remains is the seasons + * metadata endpoints in `/seasons/:titleId` and `/episodes/:titleId/:seasonNumber`. + * + * See: docs/superpowers/specs/2026-05-15-hydracker-freeze-sqlite-source-design.md */ const express = require('express'); @@ -13,13 +21,106 @@ const fsp = require('fs').promises; const { generateCacheKey } = require('../utils/cacheManager'); const { getAuthIfValid } = require('../middleware/auth'); const { getPool: getMovixPool } = require('../mysqlPool'); -const hydrackerQueue = require('../utils/hydrackerQueue'); +const darkiworldSqlite = require('../utils/darkiworldSqlite'); +const hydrackerLiveModule = require('../utils/hydrackerLive'); +const { redis: redisClient } = require('../config/redis'); +const axios = require('axios'); -// TTL pour les échecs de /decode (ex. "Lien d'embed invalide" persistant côté -// upstream). On stocke un marker `{ failed: true, failedAt }` au lieu du -// cachedData habituel, pour servir directement un 404 pendant ce délai sans -// re-taper hydracker.com à chaque clic. -const DECODE_FAILED_TTL_MS = 2 * 60 * 60 * 1000; +// IDs to watch — any decode hit on these fires a Discord alert. +const SCRAPER_WATCHLIST = new Set(['17084892']); + +// Comma-separated IPs in SCRAPER_BLOCKED_IPS env var get poison 200 on /decode. +// Reload needs restart; use CIDR not supported (exact match only). +function getRequestIp(req) { + return ( + req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.socket?.remoteAddress || + '' + ); +} + +function isBlockedScraper(req) { + const raw = process.env.SCRAPER_BLOCKED_IPS || ''; + if (!raw) return false; + const ip = getRequestIp(req); + return raw.split(',').map(s => s.trim()).includes(ip); +} + +function poisonDecodeResponse(id) { + return { + success: true, + id: String(id), + provider: 'direct', + embed_url: { + lien: 'https://t.me/movix_site', + taille: null, + created_at: null, + }, + metadata: { size: null, upload_date: null }, + source: 'mirror', + }; +} + +async function fireScraperWebhook(req, id) { + const webhookUrl = process.env.DISCORD_SCRAPER_WEBHOOK; + if (!webhookUrl) return; + const ip = + req.headers['cf-connecting-ip'] || + req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.socket?.remoteAddress || + 'unknown'; + const fields = [ + { name: 'ID', value: String(id), inline: true }, + { name: 'title_id', value: String(req.query.title_id || '-'), inline: true }, + { name: 'IP', value: ip, inline: true }, + { name: 'User-Agent', value: String(req.headers['user-agent'] || '-'), inline: false }, + { name: 'Origin', value: String(req.headers['origin'] || '-'), inline: true }, + { name: 'Referer', value: String(req.headers['referer'] || '-'), inline: true }, + { name: 'Sec-Fetch-Site', value: String(req.headers['sec-fetch-site'] || '-'), inline: true }, + { name: 'Sec-Fetch-Mode', value: String(req.headers['sec-fetch-mode'] || '-'), inline: true }, + { name: 'Accept-Language', value: String(req.headers['accept-language'] || '-'), inline: true }, + { name: 'CF-IPCountry', value: String(req.headers['cf-ipcountry'] || '-'), inline: true }, + { name: 'X-Forwarded-For (full)', value: String(req.headers['x-forwarded-for'] || '-'), inline: false }, + ]; + const embed = { + title: `🔍 Scraper Watch — decode/${id}`, + color: 0xff4444, + fields, + timestamp: new Date().toISOString(), + }; + try { + await axios.post(webhookUrl, { embeds: [embed] }, { timeout: 5000 }); + } catch (e) { + console.warn('[scraperWatch] webhook failed:', e.message); + } +} + +// One-shot boot log: surface live-fallback configuration so operators can +// confirm at process start whether the live path will engage on sqlite miss. +// +// HYDRACKER_LIVE_ENABLED is the master kill-switch for outbound lien fetches. +// true → cache+sqlite miss falls through to hydracker.com /api/v1/content/liens +// (decode) and /api/v1/titles/.../content/liens (list). hydracker +// returns the raw host link (raw_url/directDL) directly. +// false → cache+sqlite are the only sources. Every miss returns sqlite_miss +// without any outbound HTTP. No new liens are discovered. +(() => { + const enabled = process.env.HYDRACKER_LIVE_ENABLED === 'true'; + const cookiesLen = (process.env.DARKIWORLD_COOKIES || '').length; + const xsrfLen = (process.env.DARKIWORLD_XSRF_TOKEN || '').length; + const concurrency = parseInt(process.env.HYDRACKER_LIVE_CONCURRENCY, 10) || 6; + const timeoutMs = parseInt(process.env.HYDRACKER_LIVE_TIMEOUT_MS, 10) || 20000; + console.log( + `[hydrackerLive][boot] enabled=${enabled} ` + + `mode=${enabled ? 'cache+sqlite+hydracker_live' : 'cache+sqlite_only'} ` + + `concurrency=${concurrency} timeout=${timeoutMs}ms ` + + `cookies_len=${cookiesLen} xsrf_len=${xsrfLen} pid=${process.pid}`, + ); + if (enabled && cookiesLen === 0) { + console.warn('[hydrackerLive][boot] DARKIWORLD_COOKIES is empty — every hydracker fetch will return live_hydracker_error'); + } +})(); const HOST_ICON_MAP = { '1fichier': '/hosts/1fichier.svg', @@ -30,8 +131,29 @@ const HOST_ICON_MAP = { 'Dropbox': '/hosts/dropbox.svg', }; -// Hydracker uploaders to filter out (unreliable / spam). Hardcoded; not env-driven. -const BLOCKED_DARKIWORLD_USERS = new Set(['Guest']); +// Master kill-switch for outbound lien discovery. +// Returns a configured live instance only when HYDRACKER_LIVE_ENABLED=true. +// When null, both `decode` (resolveLien) and `download` (listLiensForTitle) +// fall back to cache+sqlite only — no new liens are fetched from hydracker.com. +let _hydrackerLive = null; +function getHydrackerLive() { + if (process.env.HYDRACKER_LIVE_ENABLED !== 'true') return null; + if (_hydrackerLive) return _hydrackerLive; + console.log(`[hydrackerLive][init] building live instance on first decode request (pid=${process.pid})`); + _hydrackerLive = hydrackerLiveModule.createHydrackerLive({ + redis: redisClient, + axios, + cookies: process.env.DARKIWORLD_COOKIES || '', + xsrf: process.env.DARKIWORLD_XSRF_TOKEN || '', + concurrency: parseInt(process.env.HYDRACKER_LIVE_CONCURRENCY, 10) || 6, + timeoutMs: parseInt(process.env.HYDRACKER_LIVE_TIMEOUT_MS, 10) || 20000, + cacheDir: DOWNLOAD_CACHE_DIR, + cacheGet: (dir, key) => getFromCacheNoExpiration(dir, key), + cacheSet: (dir, key, val) => saveToCache(dir, key, val), + cacheKeyFor: (id) => generateCacheKey(`darkiworld_decode_v2_${id}`), + }); + return _hydrackerLive; +} async function resolveMovixUsername(userId, authType) { try { @@ -105,15 +227,12 @@ async function fetchMovixDownloadLinks(type, id, season, episode) { // --------------------------------------------------------------------------- let DARKINO_MAINTENANCE; let DOWNLOAD_CACHE_DIR; -let darkiHeaders; -let axiosDarkinoRequest; let getFromCacheNoExpiration; let saveToCache; let shouldUpdateCache; -let shouldUpdateCache24h; +// Seasons routes still use axiosDarkinoRequest + refreshDarkinoSessionIfNeeded. +let axiosDarkinoRequest; let refreshDarkinoSessionIfNeeded; -let redis; -let shouldUpdateCache48h; /** * Inject runtime dependencies that still live in server.js. @@ -121,15 +240,11 @@ let shouldUpdateCache48h; function configure(deps) { if (deps.DARKINO_MAINTENANCE !== undefined) DARKINO_MAINTENANCE = deps.DARKINO_MAINTENANCE; if (deps.DOWNLOAD_CACHE_DIR) DOWNLOAD_CACHE_DIR = deps.DOWNLOAD_CACHE_DIR; - if (deps.darkiHeaders) darkiHeaders = deps.darkiHeaders; - if (deps.axiosDarkinoRequest) axiosDarkinoRequest = deps.axiosDarkinoRequest; if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration; if (deps.saveToCache) saveToCache = deps.saveToCache; if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache; - if (deps.shouldUpdateCache24h) shouldUpdateCache24h = deps.shouldUpdateCache24h; + if (deps.axiosDarkinoRequest) axiosDarkinoRequest = deps.axiosDarkinoRequest; if (deps.refreshDarkinoSessionIfNeeded) refreshDarkinoSessionIfNeeded = deps.refreshDarkinoSessionIfNeeded; - if (deps.redis) redis = deps.redis; - if (deps.shouldUpdateCache48h) shouldUpdateCache48h = deps.shouldUpdateCache48h; } function parsePositiveInt(value, fallback) { @@ -253,91 +368,6 @@ async function fetchSeasonsCountResponse(titleId, page, perPage) { return buildSyntheticSeasonsResponse(titleId, page, perPage, seasonPageResult.data); } -// --------------------------------------------------------------------------- -// findAllEntriesForEpisode -- paginate DarkiWorld API to find all entries -// Utilise le nouvel endpoint authentifié `/api/v1/titles/{X}/content/liens`. -// Auth = cookies + XSRF token injectés par buildDarkinoRequestHeaders depuis -// les env DARKIWORLD_COOKIES / DARKIWORLD_XSRF_TOKEN (voir app.js darkiHeaders). -// --------------------------------------------------------------------------- -async function findAllEntriesForEpisode({ titleId, seasonId, episodeId, perPage = 100, maxPages = 10 }) { - // Rafraîchir les cookies avant de commencer la pagination - try { - await refreshDarkinoSessionIfNeeded(); - } catch (_) { /* session refresh non-fatal */ } - - let page = 1; - let foundEntries = []; - let shouldContinue = true; - - while (shouldContinue && page <= maxPages) { - const url = `/api/v1/titles/${titleId}/content/liens?perPage=${perPage}&page=${page}&loader=linksdl&season=${seasonId}&filters=&paginate=preferLengthAware`; - try { - const resp = await axiosDarkinoRequest({ - method: 'get', - url: url, - headers: darkiHeaders - }); - - const data = resp.data?.pagination?.data || []; - - // Chercher toutes les entrées correspondant à l'épisode ET les liens de saison complète - const matching = data.filter(entry => - entry.host && - ( - // Liens d'épisode spécifique - (entry.episode_id == episodeId || entry.episode == episodeId || entry.episode_number == episodeId) || - // Liens de saison complète (full_saison = 1) - entry.full_saison == 1 - ) - ); - - if (matching.length > 0) { - foundEntries = [...foundEntries, ...matching]; - } - - // Pagination intelligente - const nextPage = resp.data?.pagination?.next_page; - if (!nextPage) { - shouldContinue = false; - } else { - page = nextPage; - } - } catch (_) { - shouldContinue = false; - } - } - - return foundEntries; -} - -// --------------------------------------------------------------------------- -// partitionLinksByDecodeCache — orders darkiworld links so that entries with -// a successful decode cache file on disk appear first. Cache file presence is -// verified via getFromCacheNoExpiration; only `success === true` payloads -// count (failed markers and missing files do NOT count as "cached"). -// Movix links are passed through untouched (caller prepends them). -// --------------------------------------------------------------------------- -async function partitionLinksByDecodeCache(links) { - if (!Array.isArray(links) || links.length === 0) return links || []; - const probes = await Promise.all(links.map(async (link) => { - if (link?.id == null) return { link, available: false }; - try { - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${link.id}`); - const payload = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); - return { link, available: payload?.success === true }; - } catch (_) { - return { link, available: false }; - } - })); - const available = []; - const rest = []; - for (const { link, available: ok } of probes) { - if (ok) available.push(link); - else rest.push(link); - } - return [...available, ...rest]; -} - // --------------------------------------------------------------------------- // GET /download/:type/:id // Récupérer tous les liens d'amélioration DarkiWorld pour un film ou un épisode @@ -349,253 +379,87 @@ router.get('/download/:type/:id', async (req, res) => { const { type, id } = req.params; const { season, episode, tmdbId } = req.query; - // Validation if (!['movie', 'tv'].includes(type)) { - return res.status(400).json({ - success: false, - error: 'Type invalide. Utilisez "movie" ou "tv"' - }); + return res.status(400).json({ success: false, error: 'Type invalide. Utilisez "movie" ou "tv"' }); } - if (type === 'tv' && (!season || !episode)) { - return res.status(400).json({ - success: false, - error: 'Pour les séries, les paramètres season et episode sont requis' - }); + return res.status(400).json({ success: false, error: 'Pour les séries, les paramètres season et episode sont requis' }); } - // Generate cache key — v2 invalide les caches sans champ `lien` direct. - const cacheKey = generateCacheKey(`darkiworld_download_v2_${type}_${id}${type === 'tv' ? `_${season}_${episode}` : ''}`); + const cacheKey = generateCacheKey( + `darkiworld_download_v2_${type}_${id}${type === 'tv' ? `_${season}_${episode}` : ''}` + ); - // Movix admin links are stored by TMDB id, while the path :id is the - // DarkiWorld title id used for the upstream DarkiWorld API call. Use the - // tmdbId query param when provided; fall back to the path id (legacy - // clients without tmdbId will still work but won't get Movix links). const movixLookupId = tmdbId ? String(tmdbId) : id; - - // Fetch Movix admin links (always, regardless of DarkiWorld cache state) const movixLinks = await fetchMovixDownloadLinks(type, movixLookupId, season, episode); - // Check if results are in cache without expiration (stale-while-revalidate) + // Disk cache holds the sqlite-derived rows only (sqlite is static so + // caching forever is safe). Live rows come from a separate Redis-cached + // layer (titleListCacheTtl ~5min) so they refresh as hydracker keeps + // adding new liens past the sqlite freeze cutoff. + let darkiList; const cachedData = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); - let dataReturned = false; - - if (cachedData) { - // Re-sort by disk decode cache presence on every read so previously - // decoded entries (from past clicks / past prewarms) bubble to the top - // even though the list cache itself is frozen between writes. - const cachedAll = Array.isArray(cachedData?.all) ? cachedData.all.map(r => ({ ...r, source: r.source || 'darkiworld' })) : []; - const sortedCachedAll = await partitionLinksByDecodeCache(cachedAll); - res.status(200).json({ ...cachedData, all: [...movixLinks, ...sortedCachedAll], movixCount: movixLinks.length }); - dataReturned = true; - } - - // Determine refresh staleness once and reuse below. shouldUpdateCache - // returns true if the file is missing OR older than 40 min. - const cacheNeedsUpdate = await shouldUpdateCache(DOWNLOAD_CACHE_DIR, cacheKey); - - // Vérifier si l'utilisateur a accès premium (optionnel) - const auth = await getAuthIfValid(req); - const darkiworld_premium = auth && auth.userType === 'premium'; - - // Pre-warm decode cache via hydracker's new /download endpoint. Fires on - // cache miss AND on stale cache (>40min) — the new /download endpoint is - // separate from the /decode endpoint that gets rate-limited, so prewarm - // can keep the decode cache hot for the curated subset even during a - // rate-limit window. Best-effort: a failure here is a no-op for the - // response — the queue still handles uncached ids on click. - const prewarmPromise = cacheNeedsUpdate - ? hydrackerQueue.prewarmDecodeCache({ - type, id, season, episode, - deps: { - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded, - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - saveToCache, - blockedUsers: BLOCKED_DARKIWORLD_USERS - } - }).catch((e) => { - console.warn(`[hydracker] prewarm launcher caught: ${e?.message || e}`); - return { warmed: 0, warmedIds: new Set() }; - }) - : Promise.resolve({ warmed: 0, warmedIds: new Set() }); - - let allEnhancementLinks = []; - - if (type === 'movie') { - // Pour les films - try { - await refreshDarkinoSessionIfNeeded(); - // 1. Récupérer tous les liens pour le film via le nouvel endpoint - // authentifié /api/v1/titles/{id}/content/liens. Auth (cookies + XSRF - // token) injectée par buildDarkinoRequestHeaders depuis les env - // DARKIWORLD_COOKIES / DARKIWORLD_XSRF_TOKEN. - const liensResp = await axiosDarkinoRequest({ - method: 'get', - url: `/api/v1/titles/${id}/content/liens?perPage=100&loader=linksdl&filters=&paginate=preferLengthAware` - }); - - const rawEntries = liensResp.data?.pagination?.data || []; - const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user)); - - // Traiter directement les entrées sans faire de requête de décodage - const enhancementSources = allEntries.map(entry => { - if (!entry) return null; - - const hostInfo = entry.host; - const provider = hostInfo?.name || 'unknown'; - - return { - id: entry.id, - language: (entry?.langues_compact && entry.langues_compact.length > 0) - ? entry.langues_compact.map(l => l.name).join(', ') - : undefined, - quality: entry?.qual?.qual, - sub: (entry?.subs_compact && entry.subs_compact.length > 0) - ? entry.subs_compact.map(s => s.name).join(', ') - : undefined, - provider: provider, - host_id: hostInfo?.id_host, - host_name: hostInfo?.name, - size: entry?.taille, - upload_date: entry?.created_at, - host_icon: hostInfo?.icon, - view: entry?.view - }; - }); - - allEnhancementLinks = enhancementSources.filter(Boolean); - } catch (_) { /* upstream failure leaves allEnhancementLinks empty */ } - + if (cachedData && Array.isArray(cachedData.all)) { + darkiList = cachedData.all.map((r) => ({ ...r, source: r.source || 'darkiworld' })); } else { - // Pour les séries (épisodes) + const sqliteList = darkiworldSqlite.listByTitle({ + type, + titleId: id, + season: type === 'tv' ? Number(season) : undefined, + episode: type === 'tv' ? Number(episode) : undefined, + }); + darkiList = sqliteList.map((r) => ({ ...r, source: 'darkiworld' })); + // Background disk-cache the sqlite portion. Live portion is NOT + // persisted to disk — Redis 5min cache + freshness on every request. + (async () => { + try { + if (sqliteList.length > 0) { + await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, { + success: true, + all: sqliteList.map((r) => ({ ...r, source: 'darkiworld' })), + }); + } + } catch (_) { /* silent */ } + })(); + } + + // Live supplement: hydracker keeps adding liens past the sqlite freeze + // date. Fetch the live listing (Redis-cached 5min) and merge any rows + // that aren't already in the sqlite result set (dedup by lien id). + let liveLinks = []; + const live = getHydrackerLive(); + if (live && typeof live.listLiensForTitle === 'function') { try { - // 1. Paginer intelligemment pour trouver l'épisode - const rawEntries = await findAllEntriesForEpisode({ - titleId: id, - seasonId: parseInt(season), - episodeId: parseInt(episode), - perPage: 100, - maxPages: 10 + const live0 = Date.now(); + const liveRows = await live.listLiensForTitle(id, { + type, + season: type === 'tv' ? Number(season) : undefined, + episode: type === 'tv' ? Number(episode) : undefined, }); - - const allEntries = rawEntries.filter(e => !BLOCKED_DARKIWORLD_USERS.has(e?.id_user)); - - // Traiter directement les entrées sans faire de requête de décodage - const enhancementSources = allEntries.map(entry => { - if (!entry) return null; - - const hostInfo = entry.host; - const provider = hostInfo?.name || 'unknown'; - - return { - id: entry.id, - language: (entry?.langues_compact && entry.langues_compact.length > 0) - ? entry.langues_compact.map(l => l.name).join(', ') - : undefined, - quality: entry?.qual?.qual, - sub: (entry?.subs_compact && entry.subs_compact.length > 0) - ? entry.subs_compact.map(s => s.name).join(', ') - : undefined, - provider: provider, - host_id: hostInfo?.id_host, - host_name: hostInfo?.name, - size: entry?.taille, - upload_date: entry?.created_at, - episode_id: entry?.episode_id, - episode_number: entry?.episode_number, - host_icon: hostInfo?.icon, - view: entry?.view, - saison: entry?.saison, - episode: entry?.episode, - full_saison: entry?.full_saison - }; - }); - - allEnhancementLinks = enhancementSources.filter(Boolean); - } catch (_) { /* upstream failure leaves allEnhancementLinks empty */ } - } - - // Wait for the prewarm to settle so the disk decode cache is hot before - // we respond — without this await the client could click a link before - // the pre-warmed entry was written and would needlessly hit the queue. - const prewarmResult = await prewarmPromise; - if (prewarmResult?.warmed) { - console.log(`[hydracker] prewarm ${type}/${id} warmed=${prewarmResult.warmed}`); - } - - // Sort by disk decode cache presence: anything with an existing - // success-shaped payload at darkiworld_decode_v2_{id} (whether from a - // prior queue decode, a prior decodeRequestSync, or this request's - // prewarm) bubbles to the top. The prewarm just completed above so its - // writes are already on disk and counted here. - const orderedEnhancementLinks = await partitionLinksByDecodeCache(allEnhancementLinks); - - const taggedDarkiLinks = orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })); - const responseData = { - success: true, - all: [...movixLinks, ...taggedDarkiLinks], - movixCount: movixLinks.length - }; - - // Si on n'a pas encore retourné de données, retourner maintenant - if (!dataReturned) { - res.json(responseData); - } - - // Background update du cache — reuses cacheNeedsUpdate computed above - // so we don't restat the file twice per request. - (async () => { - try { - if (!cacheNeedsUpdate) { - return; // Cache encore frais (<40 min), pas de réécriture. - } - - // Si on a des données, sauvegarder dans le cache - if (orderedEnhancementLinks && orderedEnhancementLinks.length > 0) { - // Store only DarkiWorld entries in cache — Movix links are fetched fresh each request - const darkiOnlyData = { - success: true, - all: orderedEnhancementLinks.map(r => ({ ...r, source: 'darkiworld' })) - }; - await saveToCache(DOWNLOAD_CACHE_DIR, cacheKey, darkiOnlyData); - } - } catch (cacheError) { - // Silent fail on cache save + const darkiIds = new Set(darkiList.map((r) => r.id)); + liveLinks = liveRows + .filter((r) => !darkiIds.has(r.id)) + .map((r) => ({ ...r, source: 'hydracker-live' })); + console.log( + `[download] id=${id} type=${type} sqlite=${darkiList.length} ` + + `live=${liveRows.length} new_from_live=${liveLinks.length} dt=${Date.now() - live0}ms`, + ); + } catch (e) { + console.warn(`[download] live listLiensForTitle threw id=${id}: ${e?.message}`); } - })(); + } + + return res.status(200).json({ + success: true, + all: [...movixLinks, ...darkiList, ...liveLinks], + movixCount: movixLinks.length, + }); } catch (error) { - // Si Darkino retourne 500, ne pas créer/mettre à jour le cache et renvoyer le cache existant si présent - if (error.response && error.response.status >= 500) { - try { - const fallbackCache = await getFromCacheNoExpiration(DOWNLOAD_CACHE_DIR, cacheKey); - if (fallbackCache) { - const fbAll = Array.isArray(fallbackCache?.all) ? fallbackCache.all.map(r => ({ ...r, source: r.source || 'darkiworld' })) : []; - return res.status(200).json({ ...fallbackCache, all: [...movixLinks, ...fbAll], movixCount: movixLinks.length }); - } - } catch (_) { } - } - if (!res.headersSent) { - // If Movix links exist, still return them with a warning - let movixFallback = []; - try { - const fallbackLookupId = req.query.tmdbId ? String(req.query.tmdbId) : req.params.id; - movixFallback = await fetchMovixDownloadLinks(type, fallbackLookupId, req.query.season, req.query.episode); - } catch (_) { /* ignore */ } - if (movixFallback.length > 0) { - return res.status(200).json({ - success: true, - all: movixFallback, - movixCount: movixFallback.length, - warning: 'DarkiWorld unavailable' - }); - } res.status(500).json({ success: false, - error: 'Erreur lors de la récupération des liens d\'amélioration DarkiWorld', + error: 'Erreur lors de la récupération des liens DarkiWorld', message: error.message }); } @@ -608,64 +472,81 @@ router.get('/download/:type/:id', async (req, res) => { // Params: id (ID du lien DarkiWorld) // --------------------------------------------------------------------------- router.get('/decode/:id', async (req, res) => { + const t0 = Date.now(); + const { id } = req.params; + const { title_id, debug: debugQuery } = req.query; + const debugMode = debugQuery === '1' || debugQuery === 'true'; + const live = getHydrackerLive(); + console.log( + `[decode] start id=${id} title_id=${title_id || '-'} live_wired=${!!live} ` + + `pid=${process.pid}`, + ); try { - const { id } = req.params; if (!id) return res.status(400).json({ success: false, error: 'ID du lien requis' }); + if (SCRAPER_WATCHLIST.has(String(id))) { + fireScraperWebhook(req, id).catch(() => {}); + } + + if (isBlockedScraper(req)) { + console.log(`[decode] poison id=${id} ip=${getRequestIp(req)}`); + return res.status(200).json(poisonDecodeResponse(id)); + } + if (DARKINO_MAINTENANCE) { + console.log(`[decode] short-circuit maintenance id=${id}`); return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' }); } - const result = hydrackerQueue.BATCHING_ENABLED - ? await hydrackerQueue.decodeRequest(id, { - redis, - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - getFromCacheNoExpiration - }) - : await hydrackerQueue.decodeRequestSync(id, { - cacheDir: DOWNLOAD_CACHE_DIR, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - }); + const result = await darkiworldSqlite.decodeLink(id, { + cacheDir: DOWNLOAD_CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, + hydrackerLive: live, + }); - if (result.payload) return res.status(200).json(result.payload); + const dt = Date.now() - t0; + if (result.payload) { + console.log( + `[decode] ok id=${id} provider=${result.payload.provider || '?'} ` + + `source=${result.payload.source || '?'} host=${result.payload?.metadata?.host || '?'} dt=${dt}ms`, + ); + if (debugMode) { + return res.status(200).json({ + ...result.payload, + _debug: { id, title_id: title_id || null, live_wired: !!live, dt_ms: dt, env_enabled: process.env.HYDRACKER_LIVE_ENABLED === 'true' }, + }); + } + return res.status(200).json(result.payload); + } if (result.failed) { - return res.status(404).json({ + console.log( + `[decode] failed id=${id} debug=${result.failed.debug || '?'} ` + + `status=${result.failed.status ?? '-'} dt=${dt}ms`, + ); + const body = { success: false, error: result.failed.error || 'Lien non trouvé ou inaccessible', id: result.failed.id || id, - debug: result.failed.debug || '' - }); + debug: result.failed.debug || '', + }; + if (debugMode) { + body._debug = { + title_id: title_id || null, + live_wired: !!live, + dt_ms: dt, + env_enabled: process.env.HYDRACKER_LIVE_ENABLED === 'true', + marker_status: result.failed.status ?? null, + marker_failedAt: result.failed.failedAt ?? null, + }; + } + return res.status(404).json(body); } - if (result.queued) { - return res.status(202).json({ - status: 'queued', - queue_size: result.queue_size, - id - }); - } - if (result.rateLimited) { - return res.status(503).json({ - success: false, - error: 'rate_limited', - retry_at: result.retryAt - }); - } - if (result.unavailable) { - return res.status(503).json({ - success: false, - error: 'queue_unavailable', - message: 'Infrastructure indisponible, réessaie plus tard' - }); - } - return res.status(500).json({ success: false, error: 'Unknown decode result' }); } catch (error) { + console.error(`[decode] throw id=${id} msg=${error?.message} stack=${error?.stack?.split('\n')[1]?.trim() || '-'}`); if (!res.headersSent) { res.status(500).json({ success: false, @@ -863,4 +744,3 @@ router.get('/episodes/:titleId/:seasonNumber', async (req, res) => { module.exports = router; module.exports.configure = configure; -module.exports.findAllEntriesForEpisode = findAllEntriesForEpisode; diff --git a/API/Mainapi/routes/download.js b/API/Mainapi/routes/download.js index 2b0d77c..73bdbfc 100644 --- a/API/Mainapi/routes/download.js +++ b/API/Mainapi/routes/download.js @@ -21,13 +21,8 @@ const EMPTY_RESULT_CACHE_TTL_MS = 2 * 60 * 60 * 1000; // --------------------------------------------------------------------------- let DARKINO_MAINTENANCE; let DARKINOS_CACHE_DIR; -let darkiworld_premium = false; -let darkiHeaders; -let axiosDarkinoRequest; let getFromCacheNoExpiration; let saveToCache; -let shouldUpdateCache; -let refreshDarkinoSessionIfNeeded; /** * Inject runtime dependencies that still live in app.js. @@ -35,13 +30,8 @@ let refreshDarkinoSessionIfNeeded; function configure(deps) { if (deps.DARKINO_MAINTENANCE !== undefined) DARKINO_MAINTENANCE = deps.DARKINO_MAINTENANCE; if (deps.DARKINOS_CACHE_DIR) DARKINOS_CACHE_DIR = deps.DARKINOS_CACHE_DIR; - if (deps.darkiworld_premium !== undefined) darkiworld_premium = deps.darkiworld_premium; - if (deps.darkiHeaders) darkiHeaders = deps.darkiHeaders; - if (deps.axiosDarkinoRequest) axiosDarkinoRequest = deps.axiosDarkinoRequest; if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration; if (deps.saveToCache) saveToCache = deps.saveToCache; - if (deps.shouldUpdateCache) shouldUpdateCache = deps.shouldUpdateCache; - if (deps.refreshDarkinoSessionIfNeeded) refreshDarkinoSessionIfNeeded = deps.refreshDarkinoSessionIfNeeded; } // --------------------------------------------------------------------------- @@ -209,52 +199,6 @@ const deduplicateSourcesWithPreference = (sources = []) => { return [...byKey.values()]; }; -// --------------------------------------------------------------------------- -// findDarkiboxEntriesForEpisode -- paginate Darkibox API to find entries -// --------------------------------------------------------------------------- -async function findDarkiboxEntriesForEpisode({ titleId, seasonId, episodeId, perPage = 100, maxPages = 10 }) { - // Rafraichir les cookies avant de commencer la pagination - try { - await refreshDarkinoSessionIfNeeded(); - } catch (e) { - console.warn('Erreur lors du rafraichissement des cookies Darkino:', e.message); - } - let page = 1; - let foundEntries = []; - let shouldContinue = true; - while (shouldContinue && page <= maxPages) { - const url = `/api/v1/liens?perPage=${perPage}&page=${page}&title_id=${titleId}&loader=linksdl&season=${seasonId}&filters=&paginate=preferLengthAware`; - try { - const resp = await axiosDarkinoRequest({ - method: 'get', - url: url, - headers: darkiHeaders - }); - const data = resp.data?.pagination?.data || []; - // Cherche les entrees correspondant a l'episode - const matching = data.filter(entry => - entry.host && entry.host.id_host === 2 && entry.host.name === 'darkibox' && - (entry.episode_id == episodeId || entry.episode == episodeId || entry.episode_number == episodeId) - ); - if (matching.length > 0) { - foundEntries = matching; - break; - } - // Pagination intelligente - const nextPage = resp.data?.pagination?.next_page; - if (!nextPage) { - shouldContinue = false; - } else { - page = nextPage; - } - } catch (error) { - console.error(`[DARKIBOX] Erreur lors de la recherche des liens (page ${page}) ${buildLogContext({ titleId, seasonId, episodeId })}`, summarizeErrorForLog(error)); - shouldContinue = false; - } - } - return foundEntries; -} - // =========================================================================== // ROUTES (mounted at /api, so paths are relative) // =========================================================================== @@ -272,7 +216,6 @@ router.get('/films/download/:id', async (req, res) => { const cachedData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey); const M3U8_CACHE_EXPIRY = 8 * 60 * 60 * 1000; // 8 hours in milliseconds - let dataReturned = false; if (cachedData && cachedData.sources !== undefined) { const now = Date.now(); // Short-circuit: empty result cached recently -- skip ~20s re-extraction @@ -342,73 +285,10 @@ router.get('/films/download/:id', async (req, res) => { const filteredSources = dedupedSources.filter(source => source.m3u8); // Retourner les sources dedupliquees et filtrees res.status(200).json({ sources: filteredSources }); - dataReturned = true; - (async () => { - try { - const shouldUpdate_ = await shouldUpdateCache(DARKINOS_CACHE_DIR, cacheKey); - if (!shouldUpdate_) { - return; - } - - await refreshDarkinoSessionIfNeeded(); - const response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${id}/download` }); - let freshSources = response.data.alternative_videos || []; - if (response.data.video) { - freshSources.unshift(response.data.video); - } - if (freshSources.length > 0) { - const basicSources = freshSources.map(source => ({ - src: source.src, - language: source.language, - quality: source.quality, - sub: source.sub - })); - const currentCacheData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey) || {}; - if (JSON.stringify(basicSources) !== JSON.stringify(currentCacheData.sources)) { - await saveToCache(DARKINOS_CACHE_DIR, cacheKey, { sources: basicSources }); - } - } - } catch (refreshError) { - } - })(); return; } - // Si pas de cache valide, comportement normal (requete Darkino) - const maxRetries = 3; - let retryCount = 0; - let response; - let success = false; - await refreshDarkinoSessionIfNeeded(); - while (!success && retryCount < maxRetries) { - try { - response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${id}/download` }); - success = true; - } catch (error) { - if (error.response?.data?.message === "Il y a eu un probleme. Veuillez reessayer plus tard.") { - throw error; - } - - // Arreter immediatement sur les erreurs 500/403 - if (error.response && (error.response.status === 500 || error.response.status === 403)) { - throw error; - } - - retryCount++; - if (retryCount >= maxRetries) { - throw error; - } - if (!error.response) { - const delay = Math.min(2000 * Math.pow(1.5, retryCount), 5000) + (Math.random() * 500); - await new Promise(resolve => setTimeout(resolve, delay)); - } else { - throw error; - } - } - } - let sources = response.data.alternative_videos || []; - if (response.data.video) { - sources.unshift(response.data.video); - } + // Hydracker freeze: no upstream fallback. Cache-only. + const sources = []; const basicSources = sources.map(source => ({ src: source.src, language: source.language, @@ -416,53 +296,6 @@ router.get('/films/download/:id', async (req, res) => { sub: source.sub })); - // --- DARKIBOX ENHANCEMENT START --- - if (darkiworld_premium) { - try { - // 1. Fetch all links for the film - const darkiboxLiensResp = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/liens?perPage=100&title_id=${id}&loader=linksdl&season=1&filters=&paginate=preferLengthAware` }); - const darkiboxEntries = (darkiboxLiensResp.data?.pagination?.data || []).filter( - entry => entry.host && entry.host.id_host === 2 && entry.host.name === 'darkibox' - ); - const darkiboxIds = darkiboxEntries.map(entry => entry.id); - if (darkiboxIds.length > 0) { - // 2. POST to download-premium with all darkibox IDs - const darkiboxDownloadResp = await axiosDarkinoRequest({ method: 'post', url: `/api/v1/download-premium/${darkiboxIds.join(',')}` }); - const darkiboxLinks = (darkiboxDownloadResp.data?.liens || []).filter(l => l.lien && l.lien.includes('darkibox.com')); - // 3. For each link, extract m3u8 - const darkiboxSources = await Promise.all(darkiboxLinks.map(async (lienObj) => { - let idMatch = lienObj.lien.match(/(?:\/d\/|\/)([a-z0-9]{12,})/i); - let darkiboxId = idMatch ? idMatch[1] : null; - let m3u8Url = null; - let embedUrl = null; - if (darkiboxId) { - embedUrl = `https://darkibox.com/embed-${darkiboxId}.html`; - const m3u8Result = await extractM3u8Url(embedUrl); - m3u8Url = m3u8Result?.url || null; - } - const meta = darkiboxEntries.find(e => e.id === lienObj.id); - return m3u8Url ? { - src: embedUrl, - m3u8: m3u8Url, - language: (meta?.langues_compact && meta.langues_compact.length > 0) ? meta.langues_compact.map(l => l.name).join(', ') : undefined, - quality: meta?.qual?.qual, - sub: (meta?.subs_compact && meta.subs_compact.length > 0) ? meta.subs_compact.map(s => s.name).join(', ') : undefined, - provider: 'darkibox' - } : null; - })); - const validDarkiboxSources = darkiboxSources.filter(Boolean); - for (const src of validDarkiboxSources) { - if (!basicSources.some(s => s.src === src.src)) { - basicSources.push(src); - } - } - } - } catch (err) { - console.error('[DARKIBOX] Error enhancing darkibox links:', err.message); - } - } - // --- DARKIBOX ENHANCEMENT END --- - // Extract and cache m3u8 URLs let sourcesWithM3u8 = await Promise.all( basicSources.map(async (source, idx) => { @@ -563,7 +396,6 @@ router.get('/series/download/:titleId/season/:seasonId/episode/:episodeId', asyn const cachedData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey); const M3U8_CACHE_EXPIRY = 8 * 60 * 60 * 1000; - let dataReturned = false; if (cachedData && cachedData.sources !== undefined) { const now = Date.now(); // Short-circuit: empty result cached recently -- skip ~20s re-extraction @@ -627,126 +459,17 @@ router.get('/series/download/:titleId/season/:seasonId/episode/:episodeId', asyn const shouldForceLiveRefetch = false; if (!shouldForceLiveRefetch) { res.status(200).json({ sources: filteredSources }); - dataReturned = true; - - (async () => { - try { - if (!shouldRefreshCacheNow) { - return; - } - - await refreshDarkinoSessionIfNeeded(); - const response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${titleId}/season/${seasonId}/episode/${episodeId}/download`, headers: darkiHeaders }); - let freshSources = response.data.alternative_videos || []; - if (response.data.video) { - freshSources.unshift(response.data.video); - } - // Filtrer les sources pour l'episode demande - freshSources = freshSources.filter(source => { - return !source.episode || source.episode.toString() === episodeId.toString(); - }); - if (freshSources.length > 0) { - const basicSources = freshSources.map(source => ({ - src: source.src, - language: source.language, - quality: source.quality, - sub: source.sub - })); - - const currentCacheData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey) || {}; - if (JSON.stringify(basicSources) !== JSON.stringify(currentCacheData.sources)) { - await saveToCache(DARKINOS_CACHE_DIR, cacheKey, { sources: basicSources }); - } - } - } catch (_refreshError) { - } - })(); return; } } - // Si pas de cache valide, comportement normal (requete Darkino) - const maxRetries = 3; - let retryCount = 0; - let response; - let success = false; - await refreshDarkinoSessionIfNeeded(); - while (!success && retryCount < maxRetries) { - try { - response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${titleId}/season/${seasonId}/episode/${episodeId}/download`, headers: darkiHeaders }); - success = true; - } catch (error) { - if (error.response?.data?.message === "Il y a eu un probleme. Veuillez reessayer plus tard.") { - throw error; - } - - // Arreter immediatement sur les erreurs 500/403 - if (error.response && (error.response.status === 500 || error.response.status === 403)) { - throw error; - } - - retryCount++; - if (retryCount >= maxRetries) { - throw error; - } - const delay = Math.min(2000 * Math.pow(2, retryCount), 30000) + (Math.random() * 1000); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - let sources = response.data.alternative_videos || []; - if (response.data.video) { - sources.unshift(response.data.video); - } - // Filtrer les sources pour l'episode demande - sources = sources.filter(source => { - return !source.episode || source.episode.toString() === episodeId.toString(); - }); + // Hydracker freeze: no upstream fallback. Cache-only. + const sources = []; const basicSources = sources.map(source => ({ src: source.src, language: source.language, quality: source.quality, sub: source.sub })); - // --- DARKIBOX ENHANCEMENT START --- - if (darkiworld_premium) { - try { - const darkiboxEntries = await findDarkiboxEntriesForEpisode({ titleId, seasonId, episodeId, perPage: 100, maxPages: 10 }); - const darkiboxIds = darkiboxEntries.map(entry => entry.id); - if (darkiboxIds.length > 0) { - const darkiboxDownloadResp = await axiosDarkinoRequest({ method: 'post', url: `/api/v1/download-premium/${darkiboxIds.join(',')}` }); - const darkiboxLinks = (darkiboxDownloadResp.data?.liens || []).filter(l => l.lien && l.lien.includes('darkibox.com')); - const darkiboxSources = await Promise.all(darkiboxLinks.map(async (lienObj) => { - let idMatch = lienObj.lien.match(/(?:\/d\/|\/)([a-z0-9]{12,})/i); - let darkiboxId = idMatch ? idMatch[1] : null; - let m3u8Url = null; - let embedUrl = null; - if (darkiboxId) { - embedUrl = `https://darkibox.com/embed-${darkiboxId}.html`; - const m3u8Result = await extractM3u8Url(embedUrl); - m3u8Url = m3u8Result?.url || null; - } - const meta = darkiboxEntries.find(e => e.id === lienObj.id); - return m3u8Url ? { - src: embedUrl, - m3u8: m3u8Url, - language: (meta?.langues_compact && meta.langues_compact.length > 0) ? meta.langues_compact.map(l => l.name).join(', ') : undefined, - quality: meta?.qual?.qual, - sub: (meta?.subs_compact && meta.subs_compact.length > 0) ? meta.subs_compact.map(s => s.name).join(', ') : undefined, - provider: 'darkibox' - } : null; - })); - const validDarkiboxSources = darkiboxSources.filter(Boolean); - for (const src of validDarkiboxSources) { - if (!basicSources.some(s => s.src === src.src)) { - basicSources.push(src); - } - } - } - } catch (err) { - console.error('[DARKIBOX] Error enhancing darkibox links (series):', err.message); - } - } - // --- DARKIBOX ENHANCEMENT END --- - // Extract and cache m3u8 URLs let sourcesWithM3u8 = await Promise.all( basicSources.map(async (source, sourceIndex) => { @@ -865,155 +588,24 @@ router.get('/series/download/:titleId/season/:seasonId/episode/:episodeId', asyn // --------------------------------------------------------------------------- // GET /darkino/download-premium/:id // --------------------------------------------------------------------------- -router.get('/darkino/download-premium/:id', async (req, res) => { - try { - const { id } = req.params; - const response = await axiosDarkinoRequest({ method: 'post', url: `/api/v1/download-premium/${id}` }); - res.status(response.status).json(response.data); - } catch (error) { - console.error('Erreur lors de la requete download-premium:', error.response?.status || error.message); - if (error.response) { - res.status(error.response.status).json({ error: error.response.data || 'Erreur lors de la requete download-premium' }); - } else { - res.status(500).json({ error: 'Erreur lors de la requete download-premium' }); - } - } +router.get('/darkino/download-premium/:id', async (_req, res) => { + // Hydracker freeze (2026-05-15): upstream decode is disabled. + res.status(410).json({ + success: false, + error: 'gone', + message: 'Decode upstream désactivé. Utilise /api/darkiworld/decode/:id.' + }); }); // --------------------------------------------------------------------------- -// GET /titles/:id/download -- download links via title ID +// GET /titles/:id/download -- FROZEN: returns 410 Gone // --------------------------------------------------------------------------- -router.get('/titles/:id/download', async (req, res) => { - if (DARKINO_MAINTENANCE) { - return res.status(200).json({ error: 'Service Darkino temporairement indisponible (maintenance)' }); - } - try { - const { id } = req.params; - - // Generate cache key - const cacheKey = generateCacheKey(`titles_download_${id}`); - - // Check if results are in cache - const cachedData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey); - const M3U8_CACHE_EXPIRY = 8 * 60 * 60 * 1000; - - let dataReturned = false; - if (cachedData && cachedData.sources !== undefined) { - const now = Date.now(); - const needM3u8Refresh = !cachedData.m3u8Timestamp || (now - cachedData.m3u8Timestamp > M3U8_CACHE_EXPIRY); - let sourcesWithM3u8 = cachedData.sourcesWithM3u8 || []; - - if (needM3u8Refresh) { - sourcesWithM3u8 = await Promise.all( - cachedData.sources.map(async (source) => { - const m3u8Result = await extractM3u8Url(source.src); - if (m3u8Result) { - return { - ...source, - m3u8: m3u8Result.url, - quality: m3u8Result.quality || source.quality - }; - } - return { ...source, m3u8: null }; - }) - ); - // Mise a jour du cache avec les URLs m3u8 rafraichies - await saveToCache(DARKINOS_CACHE_DIR, cacheKey, { - sources: cachedData.sources, - sourcesWithM3u8: sourcesWithM3u8, - m3u8Timestamp: now - }); - } - - // Deduplication des sources par m3u8 - const seenM3u8 = new Set(); - const seenSrc = new Set(); - const dedupedSources = []; - for (const source of sourcesWithM3u8) { - const key = source.m3u8 || source.src; - if (!key) continue; - if (!seenM3u8.has(key)) { - seenM3u8.add(key); - dedupedSources.push(source); - } - } - const finalSources = []; - for (const source of dedupedSources) { - if (!seenSrc.has(source.src)) { - seenSrc.add(source.src); - finalSources.push(source); - } - } - const filteredSources = finalSources.filter(source => source.m3u8 !== null); - res.status(200).json({ sources: filteredSources }); - dataReturned = true; - (async () => { - try { - const shouldUpdate_ = await shouldUpdateCache(DARKINOS_CACHE_DIR, cacheKey); - if (!shouldUpdate_) { - return; - } - - await refreshDarkinoSessionIfNeeded(); - const response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${id}/download` }); - let freshSources = response.data.alternative_videos || []; - if (response.data.video) { - freshSources.unshift(response.data.video); - } - if (freshSources.length > 0) { - const basicSources = freshSources.map(source => ({ - src: source.src, - language: source.language, - quality: source.quality, - sub: source.sub - })); - const currentCacheData = await getFromCacheNoExpiration(DARKINOS_CACHE_DIR, cacheKey) || {}; - if (JSON.stringify(basicSources) !== JSON.stringify(currentCacheData.sources)) { - await saveToCache(DARKINOS_CACHE_DIR, cacheKey, { sources: basicSources }); - } - } - } catch (refreshError) { - } - })(); - return; - } - // Si pas de cache valide, comportement normal (requete Darkino) - const maxRetries = 3; - let retryCount = 0; - let response; - let success = false; - await refreshDarkinoSessionIfNeeded(); - while (!success && retryCount < maxRetries) { - try { - response = await axiosDarkinoRequest({ method: 'get', url: `/api/v1/titles/${id}/download` }); - success = true; - } catch (error) { - if (error.response?.data?.message === "Il y a eu un probleme. Veuillez reessayer plus tard.") { - throw error; - } - - if (error.response && (error.response.status === 500 || error.response.status === 403)) { - throw error; - } - - retryCount++; - if (retryCount >= maxRetries) { - throw error; - } - if (!error.response) { - const delay = Math.min(2000 * Math.pow(1.5, retryCount), 5000) + (Math.random() * 500); - await new Promise(resolve => setTimeout(resolve, delay)); - } else { - throw error; - } - } - } - // ... existing code (process response.data) ... - - } catch (error) { - console.error(`Erreur lors de la recuperation des liens de telechargement:`, error); - res.status(500).json({ error: 'Erreur lors de la recuperation des liens de telechargement' }); - } +router.get('/titles/:id/download', async (_req, res) => { + res.status(410).json({ + success: false, + error: 'gone', + message: 'Hydracker /titles/{id}/download désactivé. Utilise /api/darkiworld/download/:type/:id.' + }); }); // --------------------------------------------------------------------------- @@ -1059,4 +651,3 @@ router.delete('/series/download/:titleId/season/:seasonId/episode/:episodeId/cac // --------------------------------------------------------------------------- module.exports = router; module.exports.configure = configure; -module.exports.findDarkiboxEntriesForEpisode = findDarkiboxEntriesForEpisode; diff --git a/API/Mainapi/routes/dragiv.js b/API/Mainapi/routes/dragiv.js deleted file mode 100644 index 73daefa..0000000 --- a/API/Mainapi/routes/dragiv.js +++ /dev/null @@ -1,518 +0,0 @@ -/** - * Dragiv routes module. - * Extracted from server.js -- handles Dragiv movie source search and retrieval - * (films only). - * - * Mounted at /api/dragiv (paths below are relative to that prefix). - */ - -const express = require('express'); -const router = express.Router(); -const path = require('path'); -const fsp = require('fs').promises; -const axios = require('axios'); -const { CACHE_DIR, generateCacheKey } = require('../utils/cacheManager'); -const { redis } = require('../config/redis'); -const { fetchTmdbDetails } = require('../utils/tmdbCache'); - -// === DRAGIV CONFIGURATION === -const DRAGIV_BASE = 'https://kimpav.com'; -const DRAGIV_KEY = 'ph4p9rv4jpx7mg'; - -// Deduplication des requêtes Dragiv en cours -const ongoingDragivRequests = new Map(); -const DRAGIV_REQUEST_TIMEOUT = 30000; // 30s timeout par requête -const DRAGIV_STALE_CLEANUP_MS = 10 * 60 * 1000; // 10 min max pour une entrée - -const getOrCreateDragivRequest = async (cacheKey, requestFunction) => { - if (ongoingDragivRequests.has(cacheKey)) { - const existing = ongoingDragivRequests.get(cacheKey); - let dedupTimer; - return Promise.race([ - existing.promise.finally(() => clearTimeout(dedupTimer)), - new Promise((_, reject) => { dedupTimer = setTimeout(() => reject(new Error('Dragiv dedup timeout')), DRAGIV_REQUEST_TIMEOUT); }) - ]); - } - - const requestPromise = (async () => { - let timeoutTimer; - try { - return await Promise.race([ - requestFunction().finally(() => clearTimeout(timeoutTimer)), - new Promise((_, reject) => { timeoutTimer = setTimeout(() => reject(new Error('Dragiv request timeout')), DRAGIV_REQUEST_TIMEOUT); }) - ]); - } finally { - clearTimeout(timeoutTimer); - ongoingDragivRequests.delete(cacheKey); - } - })(); - - ongoingDragivRequests.set(cacheKey, { promise: requestPromise, createdAt: Date.now() }); - return requestPromise; -}; - -// Nettoyage périodique des requêtes Dragiv bloquées -setInterval(() => { - const now = Date.now(); - let cleaned = 0; - for (const [key, entry] of ongoingDragivRequests) { - if (now - entry.createdAt > DRAGIV_STALE_CLEANUP_MS) { - ongoingDragivRequests.delete(key); - cleaned++; - } - } - if (cleaned > 0) console.log(`[DRAGIV DEDUP] Nettoyage: ${cleaned} requêtes expirées supprimées`); -}, 5 * 60 * 1000).unref(); - -// --------------------------------------------------------------------------- -// Dependencies injected via configure() -// --------------------------------------------------------------------------- -let getFromCacheNoExpiration; - -/** - * Inject runtime dependencies that still live in server.js. - */ -function configure(deps) { - if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration; -} - -// --------------------------------------------------------------------------- -// parseDragivMovieList -- parse movie entries from Dragiv HTML -// --------------------------------------------------------------------------- -function parseDragivMovieList(html) { - // Le HTML est structuré : <a href="/key/b/kimpav/ID">\n\n Titre (Année) <font...>HD</font>\n</a> - const movieRegex = /href="(\/ph4p9rv4jpx7mg\/b\/kimpav\/(\d+))"[^>]*>\s*([\s\S]*?)<\/a>/g; - const matches = [...html.matchAll(movieRegex)]; - - const movies = []; - const seen = new Set(); - - for (const match of matches) { - const moviePath = match[1]; - const movieId = match[2]; - // Nettoyer le titre : retirer les balises HTML et les espaces superflus - const rawTitle = match[3].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); - - if (!seen.has(movieId) && rawTitle.length > 0) { - seen.add(movieId); - movies.push({ - id: movieId, - title: rawTitle, - url: `${DRAGIV_BASE}${moviePath}`, - path: moviePath - }); - } - } - - return movies; -} - -// --------------------------------------------------------------------------- -// searchDragivMovies -- search movies on Dragiv via POST -// --------------------------------------------------------------------------- -async function searchDragivMovies(query) { - const homeUrl = `${DRAGIV_BASE}/${DRAGIV_KEY}/home/kimpav`; - - const response = await axios.post(homeUrl, `searchword=${encodeURIComponent(query)}`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': homeUrl, - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cookie': 'g=true' - }, - maxRedirects: 5, - timeout: 15000 - }); - - return parseDragivMovieList(response.data); -} - -// --------------------------------------------------------------------------- -// fetchDragivHomeMovies -- retrieve movie list from Dragiv homepage (fallback) -// --------------------------------------------------------------------------- -async function fetchDragivHomeMovies() { - const homeUrl = `${DRAGIV_BASE}/${DRAGIV_KEY}/home/kimpav`; - - const response = await axios.get(homeUrl, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': DRAGIV_BASE, - 'Cookie': 'g=true' - }, - timeout: 15000 - }); - - return parseDragivMovieList(response.data); -} - -// --------------------------------------------------------------------------- -// fetchDragivMovieData -- retrieve sources for a Dragiv movie by internal ID -// --------------------------------------------------------------------------- -async function fetchDragivMovieData(movieId) { - const movieUrl = `${DRAGIV_BASE}/${DRAGIV_KEY}/b/kimpav/${movieId}`; - - const response = await axios.get(movieUrl, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': `${DRAGIV_BASE}/${DRAGIV_KEY}/home/kimpav`, - 'Cookie': 'g=true' - }, - timeout: 15000 - }); - - const html = response.data; - - const titleMatch = html.match(/<title>([^<]+)<\/title>/i); - const title = titleMatch ? titleMatch[1].replace(/^(Dragiv|Kimpav)\s*-\s*/i, '').trim() : 'Unknown'; - - const iframeMatch = html.match(/<iframe[^>]+src="(https:\/\/sharecloudy\.com\/[^"]+)"/i); - if (!iframeMatch) { - return { success: false, error: 'No iframe found', id: movieId, title }; - } - - const iframeUrl = iframeMatch[1]; - - const iframeResponse = await axios.get(iframeUrl, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': movieUrl - }, - timeout: 15000 - }); - - const iframeHtml = iframeResponse.data; - - const m3u8Match = iframeHtml.match(/file:\s*"(https:\/\/[^"]+\.m3u8[^"]*)"/i); - if (!m3u8Match) { - return { success: false, error: 'No m3u8 found in iframe', id: movieId, title, iframeUrl }; - } - - const m3u8Url = m3u8Match[1]; - - // Extraire les qualités disponibles - const qualityRegex = /{\s*file:\s*"([^"]+\.m3u8[^"]*)"\s*,\s*label:\s*"([^"]+)"/g; - const qualityMatches = [...iframeHtml.matchAll(qualityRegex)]; - - const qualities = qualityMatches.length > 0 - ? qualityMatches.map(m => ({ url: m[1], quality: m[2] })) - : [{ url: m3u8Url, quality: 'HD' }]; - - return { - success: true, - id: movieId, - title, - movieUrl, - iframeUrl, - m3u8: m3u8Url, - qualities, - provider: 'sharecloudy.com', - referer: iframeUrl, - note: 'Le m3u8 nécessite le header Referer pour fonctionner' - }; -} - -// --------------------------------------------------------------------------- -// calculateDragivMatchScore -- compute title match score using Jaccard similarity -// --------------------------------------------------------------------------- -function calculateDragivMatchScore(dragivTitle, searchTitle, tmdbTitle, tmdbOriginalTitle) { - // Retirer les tags de qualité/statut des titres Dragiv (HD, VOSTFR, NEW, etc.) - const cleanTags = (str) => str - .replace(/\b(HD|VOSTFR|VF|NEW|CAM|TS|TC|DVDRIP|BDRIP|WEBRIP|HDTV)\b/gi, '') - .replace(/\+\d+/g, '') - .trim(); - - const normalize = (str) => cleanTags(str).toLowerCase() - .replace(/[:\-–—'"]/g, ' ') - .replace(/[^\w\s\u00C0-\u024F]/g, '') - .replace(/\s+/g, ' ') - .trim(); - - const removeYear = (str) => str.replace(/\(?\d{4}\)?/g, '').replace(/\s+/g, ' ').trim(); - // Ne pas traiter les années (4 chiffres >= 1900) comme des tokens de suite - const isYear = (token) => /^\d{4}$/.test(token) && parseInt(token) >= 1900; - const sequelTokenRegex = /^(\d+|i|ii|iii|iv|v|vi|vii|viii|ix|x)$/i; - const getSequelTokens = (str) => { - if (!str) return new Set(); - return new Set( - str - .split(' ') - .map(token => token.trim().toLowerCase()) - .filter(token => sequelTokenRegex.test(token) && !isYear(token)) - ); - }; - const hasSequelMismatch = (candidateTitleOnly, referenceTitleOnly) => { - const candidateTokens = getSequelTokens(candidateTitleOnly); - const referenceTokens = getSequelTokens(referenceTitleOnly); - - if (candidateTokens.size === 0 && referenceTokens.size === 0) return false; - if (candidateTokens.size === 0 || referenceTokens.size === 0) return true; - - for (const token of candidateTokens) { - if (referenceTokens.has(token)) return false; - } - return true; - }; - - const dragivNorm = normalize(dragivTitle); - const searchNorm = normalize(searchTitle); - const tmdbNorm = normalize(tmdbTitle); - const tmdbOrigNorm = normalize(tmdbOriginalTitle || ''); - - const dragivTitleOnly = removeYear(dragivNorm); - const tmdbTitleOnly = removeYear(tmdbNorm); - const tmdbOrigTitleOnly = removeYear(tmdbOrigNorm); - - // Bloque les faux positifs de suites: ex. "Zootopie" vs "Zootopie 2" - const mismatchWithTmdb = hasSequelMismatch(dragivTitleOnly, tmdbTitleOnly); - const mismatchWithTmdbOriginal = tmdbOrigTitleOnly.length > 0 - ? hasSequelMismatch(dragivTitleOnly, tmdbOrigTitleOnly) - : true; - - if (mismatchWithTmdb && mismatchWithTmdbOriginal) return 0; - - // Match exact (titre complet ou sans année) - if (dragivNorm === searchNorm || dragivNorm === tmdbNorm) return 1.0; - if (tmdbOrigNorm.length > 0 && dragivNorm === tmdbOrigNorm) return 1.0; - if (dragivTitleOnly.length > 0 && tmdbTitleOnly.length > 0 && dragivTitleOnly === tmdbTitleOnly) return 1.0; - if (tmdbOrigTitleOnly.length > 0 && dragivTitleOnly === tmdbOrigTitleOnly) return 1.0; - - // Jaccard similarity sur les mots (plus robuste que substring includes) - const getWords = (str) => { - const words = str.split(' ').filter(w => w.length > 1); - return new Set(words); - }; - - const jaccard = (setA, setB) => { - if (setA.size === 0 || setB.size === 0) return 0; - let intersectionSize = 0; - for (const item of setA) { - if (setB.has(item)) intersectionSize++; - } - const unionSize = new Set([...setA, ...setB]).size; - return intersectionSize / unionSize; - }; - - const dragivWords = getWords(dragivTitleOnly); - const tmdbWords = getWords(tmdbTitleOnly); - const tmdbOrigWords = tmdbOrigTitleOnly.length > 0 ? getWords(tmdbOrigTitleOnly) : new Set(); - - let score = Math.max( - jaccard(dragivWords, tmdbWords), - tmdbOrigWords.size > 0 ? jaccard(dragivWords, tmdbOrigWords) : 0 - ); - - // Bonus année uniquement si le titre a déjà un bon score - if (score >= 0.5) { - const yearMatch = dragivTitle.match(/\((\d{4})\)/); - const searchYearMatch = searchTitle.match(/\((\d{4})\)/); - if (yearMatch && searchYearMatch && yearMatch[1] === searchYearMatch[1]) { - score = Math.min(score + 0.1, 1.0); - } - } - - return score; -} - -// --------------------------------------------------------------------------- -// fetchDragivByTmdbId -- search Dragiv for a movie by TMDB ID -// --------------------------------------------------------------------------- -async function fetchDragivByTmdbId(tmdbId) { - try { - // 1. Récupérer les infos TMDB (cached via Redis) - const tmdbMovie = await fetchTmdbDetails('https://api.themoviedb.org/3', process.env.TMDB_API_KEY, tmdbId, 'movie', 'fr-FR'); - - if (!tmdbMovie || !tmdbMovie.title) { - return { success: false, error: 'TMDB movie not found', tmdb_id: tmdbId }; - } - - const year = tmdbMovie.release_date ? tmdbMovie.release_date.split('-')[0] : null; - const searchTitle = year ? `${tmdbMovie.title} (${year})` : tmdbMovie.title; - - // 2. Rechercher sur Dragiv par nom complet du film (POST search) - let allMovies = await searchDragivMovies(tmdbMovie.title); - - // Fallback: si la recherche ne retourne rien, essayer avec le titre original - if (allMovies.length === 0 && tmdbMovie.original_title && tmdbMovie.original_title !== tmdbMovie.title) { - allMovies = await searchDragivMovies(tmdbMovie.original_title); - } - - // Fallback ultime: homepage - if (allMovies.length === 0) { - allMovies = await fetchDragivHomeMovies(); - } - - // 3. Trouver le meilleur match - let bestMatch = null; - let bestScore = 0; - - for (const movie of allMovies) { - const score = calculateDragivMatchScore(movie.title, searchTitle, tmdbMovie.title, tmdbMovie.original_title); - if (score > bestScore) { - bestScore = score; - bestMatch = movie; - } - } - - // 4. Si trouvé avec un bon score, récupérer les sources (seuil strict pour éviter faux positifs) - if (bestMatch && bestScore >= 0.8) { - const movieData = await fetchDragivMovieData(bestMatch.id); - - // Vérification post-fetch : le titre de la page Dragiv doit aussi correspondre au film TMDB - if (movieData.success && movieData.title) { - const postFetchScore = calculateDragivMatchScore(movieData.title, searchTitle, tmdbMovie.title, tmdbMovie.original_title); - if (postFetchScore < 0.5) { - return { - success: false, - error: 'Movie not found on Dragiv (post-fetch title mismatch)', - tmdb_id: parseInt(tmdbId), - searched_title: searchTitle, - best_match: { title: bestMatch.title, score: bestScore }, - page_title: movieData.title, - page_score: postFetchScore - }; - } - } - - if (movieData.success) { - return { - success: true, - tmdb_id: parseInt(tmdbId), - tmdb: { - id: tmdbMovie.id, - title: tmdbMovie.title, - original_title: tmdbMovie.original_title, - overview: tmdbMovie.overview, - release_date: tmdbMovie.release_date, - vote_average: tmdbMovie.vote_average, - poster_path: tmdbMovie.poster_path ? `https://image.tmdb.org/t/p/w500${tmdbMovie.poster_path}` : null, - backdrop_path: tmdbMovie.backdrop_path ? `https://image.tmdb.org/t/p/original${tmdbMovie.backdrop_path}` : null, - genres: tmdbMovie.genres || [], - runtime: tmdbMovie.runtime - }, - dragiv: { - id: movieData.id, - title: movieData.title, - movieUrl: movieData.movieUrl, - iframeUrl: movieData.iframeUrl, - m3u8: movieData.m3u8, - qualities: movieData.qualities, - provider: movieData.provider, - referer: movieData.referer - }, - match_score: bestScore, - source: 'dragiv' - }; - } - } - - // 5. Pas trouvé - return { - success: false, - error: 'Movie not found on Dragiv', - tmdb_id: parseInt(tmdbId), - searched_title: searchTitle, - best_match: bestMatch ? { title: bestMatch.title, score: bestScore } : null - }; - - } catch (error) { - return { success: false, error: error.message, tmdb_id: tmdbId }; - } -} - -// --------------------------------------------------------------------------- -// updateDragivCache -- background cache update -// --------------------------------------------------------------------------- -async function updateDragivCache(cacheKey, tmdbid) { - try { - const newData = await fetchDragivByTmdbId(tmdbid); - if (newData && newData.success) { - await fsp.writeFile(path.join(CACHE_DIR.DRAGIV, `${cacheKey}.json`), JSON.stringify(newData), 'utf-8'); - // cache updated - } - } catch (error) { - // Ne pas toucher au cache existant en cas d'erreur - } -} - -// --------------------------------------------------------------------------- -// GET /movie/:tmdbid -// Chercher un film Dragiv par TMDB ID -// --------------------------------------------------------------------------- -router.get('/movie/:tmdbid', async (req, res) => { - const { tmdbid } = req.params; - const cacheKey = generateCacheKey(`dragiv_movie_${tmdbid}`); - - try { - // 1. Try cache - const cachedData = await getFromCacheNoExpiration(CACHE_DIR.DRAGIV, cacheKey); - if (cachedData) { - if (cachedData.notFound) { - res.status(404).json({ error: 'Movie not found on Dragiv (Cached)' }); - } else { - res.json(cachedData); - } - - // Background update si ancien (20 min) - const cacheFilePath = path.join(CACHE_DIR.DRAGIV, `${cacheKey}.json`); - try { - const stats = await fsp.stat(cacheFilePath); - const age = Date.now() - stats.mtime.getTime(); - if (age > 20 * 60 * 1000) { - updateDragivCache(cacheKey, tmdbid).catch(() => {}); - } - } catch (e) { /* ignore */ } - return; - } - - // 2. Fetch fresh avec deduplication - const data = await getOrCreateDragivRequest(cacheKey, () => fetchDragivByTmdbId(tmdbid)); - - if (!data || !data.success) { - const notFoundData = { notFound: true, tmdbId: tmdbid, timestamp: Date.now() }; - await fsp.writeFile(path.join(CACHE_DIR.DRAGIV, `${cacheKey}.json`), JSON.stringify(notFoundData), 'utf-8'); - return res.status(404).json({ error: 'Movie not found on Dragiv' }); - } - - await fsp.writeFile(path.join(CACHE_DIR.DRAGIV, `${cacheKey}.json`), JSON.stringify(data), 'utf-8'); - res.json(data); - - } catch (error) { - if (error.message && error.message.includes('not found')) { - return res.status(404).json({ error: error.message }); - } - res.status(500).json({ error: 'Internal Server Error' }); - } -}); - -// --------------------------------------------------------------------------- -// GET /movie/:tmdbid/clear-cache -// Vider le cache Dragiv d'un film -// --------------------------------------------------------------------------- -router.get('/movie/:tmdbid/clear-cache', async (req, res) => { - const { tmdbid } = req.params; - const cacheKey = generateCacheKey(`dragiv_movie_${tmdbid}`); - const cacheFilePath = path.join(CACHE_DIR.DRAGIV, `${cacheKey}.json`); - - try { - // Supprimer le fichier JSON + la clé Redis - const memKey = `${CACHE_DIR.DRAGIV}:${cacheKey}`; - await Promise.allSettled([ - fsp.unlink(cacheFilePath), - redis.del(memKey) - ]); - res.json({ success: true, message: `Cache cleared for movie ${tmdbid}` }); - } catch (error) { - res.status(500).json({ error: 'Internal Server Error' }); - } -}); - -// DRAGIV source loaded (films only) - -module.exports = router; -module.exports.configure = configure; -module.exports.searchDragivMovies = searchDragivMovies; -module.exports.fetchDragivHomeMovies = fetchDragivHomeMovies; -module.exports.fetchDragivMovieData = fetchDragivMovieData; -module.exports.calculateDragivMatchScore = calculateDragivMatchScore; -module.exports.fetchDragivByTmdbId = fetchDragivByTmdbId; -module.exports.updateDragivCache = updateDragivCache; diff --git a/API/Mainapi/routes/francetv.js b/API/Mainapi/routes/francetv.js index 4ea8750..c93a592 100644 --- a/API/Mainapi/routes/francetv.js +++ b/API/Mainapi/routes/francetv.js @@ -429,6 +429,63 @@ router.get('/episodes', async (req, res) => { } }); +// --------------------------------------------------------------------------- +// fetchSeasonEpisodesViaDeepPage -- récupère TOUS les épisodes d'une saison +// via l'API JSON publique deep-page (celle du bouton "Afficher plus"). +// La page programme n'embarque côté serveur qu'UNE carte par saison ; le +// reste est chargé côté client via ce même endpoint. +// seasonPath: "/france-4/sous-les-mers/saison-4" -> slug "france-4_sous-les-mers_saison-4" +// --------------------------------------------------------------------------- +async function fetchSeasonEpisodesViaDeepPage(seasonPath, seasonNum) { + const slug = seasonPath.replace(/^\/+|\/+$/g, '').split('/').join('_'); + const episodes = []; + const seen = new Set(); + let page = 0; + + for (let i = 0; i < 20; i++) { // garde-fou : 20 pages x 100 = 2000 épisodes max + const apiUrl = `${FTV_BASE}/api/deep-page/?slug=${encodeURIComponent(slug)}&type=season&page=${page}` + + '&filter=only-visible&filter=with-no-vod&filter=only-replay&size=100&sort=episode%3Aasc'; + const { data } = await axios.get(apiUrl, { + headers: { ...FTV_BROWSER_HEADERS, 'Accept': 'application/json' }, + proxy: false, + timeout: 15000, + }); + + const items = Array.isArray(data?.result) ? data.result : []; + for (const item of items) { + const c = item?.content || {}; + const t = item?.tracking || {}; + if (c.type !== 'video' || !c.url) continue; + if (seen.has(c.url)) continue; + seen.add(c.url); + + const title = c.title || ''; + const epNumMatch = title.match(/E(\d+)/); + + episodes.push({ + title, + program: '', + description: c.description || '', + url: `${FTV_BASE}${c.url}`, + thumbnail: c.images?.base?.x2 || c.images?.base?.x1 || null, + contentId: c.id || t.content_id || null, + videoId: t.video_factory_id || null, + season: seasonNum, + episode: epNumMatch ? parseInt(epNumMatch[1]) : null, + csa: c.csa || null, + duration: c.duration || null, + }); + } + + const next = data?.cursor?.next; + if (next === null || next === undefined || next === page) break; + page = next; + } + + episodes.sort((a, b) => (a.episode || 0) - (b.episode || 0)); + return episodes; +} + // --------------------------------------------------------------------------- // GET /info?url=https://www.france.tv/france-5/le-monde-de-jamy/... // Récupère les informations d'une page film (player) ou série (programme). @@ -685,6 +742,26 @@ router.get('/info', async (req, res) => { } } + // La page programme n'embarque qu'UNE carte par saison dans le payload + // RSC serveur (le reste est lazy-loadé). On complète chaque saison via + // l'API deep-page ; en cas d'échec on garde les épisodes extraits du RSC. + await Promise.all(seasons.map(async (s) => { + const firstEp = s.episodes[0]; + if (!firstEp || !firstEp.url) return; + try { + const epPath = new URL(firstEp.url).pathname; // /france-4/.../saison-4/123-x.html + const seasonPath = epPath.replace(/\/[^/]*$/, ''); // /france-4/.../saison-4 + if (!seasonPath) return; + const fullEpisodes = await fetchSeasonEpisodesViaDeepPage(seasonPath, s.number); + if (fullEpisodes.length > s.episodes.length) { + s.episodes = fullEpisodes; + s.episodeCount = fullEpisodes.length; + } + } catch (e) { + console.warn(`[FTV] deep-page failed for ${s.name}: ${e.message}`); + } + })); + // If no seasons found with the split approach, try the card-based approach from /episodes if (seasons.length === 0) { const fallbackEpisodes = []; diff --git a/API/Mainapi/routes/fstream.js b/API/Mainapi/routes/fstream.js index 53d9570..80dd4a2 100644 --- a/API/Mainapi/routes/fstream.js +++ b/API/Mainapi/routes/fstream.js @@ -45,9 +45,22 @@ const fstreamCookies = { 'dle_skin': 'VFV25', 'dle_newpm': '0', '__cf_logged_in': '1', - 'CF_VERIFIED_DEVICE_ae9bb95a6761c08a92f916b7ed7d2c4a985eb220591d1410240412c516f37b0c': '1756239054' + 'CF_VERIFIED_DEVICE_ae9bb95a6761c08a92f916b7ed7d2c4a985eb220591d1410240412c516f37b0c': '1756239054', + // Anti-bot "fsschal": le challenge JS de FStream pose juste ce cookie statique puis recharge. + // Sans lui, search.php / episodes_p.php / film_api.php renvoient la page "Verification..." (1466b) + // au lieu des donnees. Les fichiers /static/ en sont exempts. Valeur overridable si elle tourne. + 'fsschal': process.env.FSTREAM_FSSCHAL || '1' }; +// Construit le header Cookie complet depuis fstreamCookies, pour les requetes axios +// directes (episodes_p, film_api, static) qui ne passent pas par withOptionalFStreamCookies. +function buildFStreamCookieHeader() { + return Object.entries(fstreamCookies) + .filter(([, v]) => v !== '' && v != null) + .map(([k, v]) => `${k}=${v}`) + .join('; '); +} + let fstreamRequestCounter = 0; const MAX_REQUESTS_PER_SESSION = 5; @@ -356,6 +369,62 @@ async function fetchFStreamSeasonSearchResults(tmdbId, serieTitle) { } } +// get_seasons.php (ajax related-seasons de DLE) est toujours vivant cote upstream, +// contrairement a l'hypothese de fetchFStreamSeasonSearchResults. serie_tag = s-<tmdbId> +// (l'id TMDB brut), news_id = page id d'une saison deja trouvee via search. Retourne +// TOUTES les saisons de la serie (id, title, full_url), y compris celles que search.php +// ne liste pas (ex: Mayans MC -> search ne voit que S4/S5, get_seasons rend S1/S2/S3/S5). +// Fallback quand la saison demandee est absente des resultats search mais existe cote FStream. +async function fetchFStreamSeasonsAjax(tmdbId, newsId, baseUrl) { + if (!tmdbId || !newsId) return []; + const apiUrl = `${baseUrl}/engine/ajax/get_seasons.php?serie_tag=s-${tmdbId}&news_id=${newsId}`; + + const proxies = getShuffledAllProxies(); + const maxAttempts = Math.min(proxies.length, 3); + + for (let i = 0; i < maxAttempts; i++) { + const entry = proxies[i]; + const agents = getAgentForProxy(entry); + try { + const response = await axios({ + method: 'get', + url: apiUrl, + timeout: 8000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Referer': `${baseUrl}/`, + 'Accept': 'application/json, text/javascript, */*', + 'X-Requested-With': 'XMLHttpRequest', + 'Cookie': buildFStreamCookieHeader() + }, + ...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {}) + }); + + if (response.status === 429) continue; + if (response.status !== 200 || !response.data) return []; + + const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + if (!Array.isArray(data)) return []; + + return data.map(item => { + const seasonMatch = (item.title || '').match(/Saison\s+(\d+)/i); + const seasonNumber = seasonMatch ? parseInt(seasonMatch[1], 10) : null; + const rawUrl = item.full_url || ''; + const link = !rawUrl ? null + : rawUrl.startsWith('http') ? rawUrl + : `${baseUrl}/${rawUrl.replace(/^\//, '')}`; + return { title: item.title || `Saison ${seasonNumber}`, originalTitle: item.title || '', link, seasonNumber, year: null }; + }).filter(r => r.link && r.seasonNumber); + } catch (error) { + if (error.response?.status === 429) continue; + console.error(`[FStream] Erreur get_seasons.php (proxy ${entry.type} #${i}): ${error.message}`); + continue; + } + } + + return []; +} + // === Scraping Functions === async function scrapeFStreamRecentMovies() { try { @@ -653,6 +722,116 @@ function getAgentForProxy(entry) { return getDarkinoHttpProxyAgent(entry.proxy); } +// Parse le payload episodes FStream (shape commune {vf,vostfr,vo,info}) en map normalisee. +// Partage entre la source statique (<base>/static/series) et l'API dynamique (episodes_p.php). +function parseEpisodesPayload(data) { + if (!data || typeof data !== 'object') return null; + + const episodes = {}; + const langMap = { vf: 'VF', vostfr: 'VOSTFR', vo: 'VOENG' }; + + for (const [langKey, langLabel] of Object.entries(langMap)) { + const langData = data[langKey]; + if (!langData || typeof langData !== 'object') continue; + + for (const [epNum, providers] of Object.entries(langData)) { + const epNumber = parseInt(epNum); + if (isNaN(epNumber) || epNumber === 0) continue; + + if (!episodes[epNumber]) { + episodes[epNumber] = { + number: epNumber, + title: `Episode ${epNumber}`, + languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } + }; + } + + for (const [provider, url] of Object.entries(providers)) { + if (!url || typeof url !== 'string' || !url.startsWith('http')) continue; + let displayName = provider; + if (provider === 'premium') displayName = 'Premium'; + else if (provider === 'vidzy') displayName = 'Vidzy'; + else if (provider === 'uqload') displayName = 'Uqload'; + else if (provider === 'netu') displayName = 'Netu'; + else if (provider === 'voe') displayName = 'Voe'; + else displayName = provider.charAt(0).toUpperCase() + provider.slice(1); + + const exists = episodes[epNumber].languages[langLabel].some(p => p.url === url); + if (!exists) { + episodes[epNumber].languages[langLabel].push({ url, type: 'embed', quality: 'HD', player: displayName }); + } + } + } + } + + // Enrichir avec les infos (titres, synopsis) si disponibles + if (data.info && typeof data.info === 'object') { + for (const [epNum, info] of Object.entries(data.info)) { + const epNumber = parseInt(epNum); + if (episodes[epNumber] && info.title) { + episodes[epNumber].title = info.title; + } + } + } + + if (Object.keys(episodes).length > 0) return episodes; + return null; +} + +// Source statique prioritaire: <base>/static/series/<id>.js (JSON fige, frais, inclut premium). +// Meme domaine que le lien de recherche (base url), pas un host distinct. +async function fetchEpisodesFromStaticJs(pageUrl) { + const pageId = extractPageIdFromUrl(pageUrl); + if (!pageId) return null; + + const baseUrl = extractBaseUrlFromLink(pageUrl); + const apiUrl = `${baseUrl}/static/series/${pageId}.js?v=${Date.now()}`; + + const proxies = getShuffledAllProxies(); + const maxAttempts = Math.min(proxies.length, 3); + let lastError = null; + + for (let i = 0; i < maxAttempts; i++) { + const entry = proxies[i]; + const agents = getAgentForProxy(entry); + + try { + const response = await axios({ + method: 'get', + url: apiUrl, + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Referer': pageUrl, + 'Accept': 'application/json, text/javascript, */*', + 'Cookie': buildFStreamCookieHeader() + }, + ...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {}) + }); + + if (response.status === 429) { + console.log(`[FStream] static series.js: 429 avec proxy ${entry.type} #${i}, retry...`); + continue; + } + if (response.status !== 200 || !response.data) return null; + + const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; + return parseEpisodesPayload(data); + } catch (error) { + lastError = error; + if (error.response?.status === 429) { + console.log(`[FStream] static series.js: 429 avec proxy ${entry.type} #${i}, retry...`); + continue; + } + console.error(`[FStream] Erreur static series.js (proxy ${entry.type} #${i}): ${error.message}`); + continue; + } + } + + if (lastError) console.error(`[FStream] static series.js: tous les proxies ont echoue. Derniere erreur: ${lastError.message}`); + return null; +} + async function fetchEpisodesFromApi(pageUrl) { const pageId = extractPageIdFromUrl(pageUrl); if (!pageId) return null; @@ -676,7 +855,8 @@ async function fetchEpisodesFromApi(pageUrl) { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Referer': pageUrl, - 'Accept': 'application/json, text/plain, */*' + 'Accept': 'application/json, text/plain, */*', + 'Cookie': buildFStreamCookieHeader() }, ...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {}) }); @@ -688,59 +868,7 @@ async function fetchEpisodesFromApi(pageUrl) { if (response.status !== 200 || !response.data) return null; const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data; - if (!data || typeof data !== 'object') return null; - - const episodes = {}; - const langMap = { vf: 'VF', vostfr: 'VOSTFR', vo: 'VOENG' }; - - for (const [langKey, langLabel] of Object.entries(langMap)) { - const langData = data[langKey]; - if (!langData || typeof langData !== 'object') continue; - - for (const [epNum, providers] of Object.entries(langData)) { - const epNumber = parseInt(epNum); - if (isNaN(epNumber) || epNumber === 0) continue; - - if (!episodes[epNumber]) { - episodes[epNumber] = { - number: epNumber, - title: `Episode ${epNumber}`, - languages: { VF: [], VOSTFR: [], VOENG: [], Default: [] } - }; - } - - for (const [provider, url] of Object.entries(providers)) { - if (!url || typeof url !== 'string' || !url.startsWith('http')) continue; - let displayName = provider; - if (provider === 'premium') displayName = 'Premium'; - else if (provider === 'vidzy') displayName = 'Vidzy'; - else if (provider === 'uqload') displayName = 'Uqload'; - else if (provider === 'netu') displayName = 'Netu'; - else if (provider === 'voe') displayName = 'Voe'; - else displayName = provider.charAt(0).toUpperCase() + provider.slice(1); - - const exists = episodes[epNumber].languages[langLabel].some(p => p.url === url); - if (!exists) { - episodes[epNumber].languages[langLabel].push({ url, type: 'embed', quality: 'HD', player: displayName }); - } - } - } - } - - // Enrichir avec les infos (titres, synopsis) si disponibles - if (data.info && typeof data.info === 'object') { - for (const [epNum, info] of Object.entries(data.info)) { - const epNumber = parseInt(epNum); - if (episodes[epNumber] && info.title) { - episodes[epNumber].title = info.title; - } - } - } - - if (Object.keys(episodes).length > 0) { - return episodes; - } - return null; + return parseEpisodesPayload(data); } catch (error) { lastError = error; if (error.response?.status === 429) { @@ -779,7 +907,8 @@ async function fetchMoviePlayersFromApi(pageUrl) { headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'Referer': pageUrl, - 'Accept': 'application/json, text/plain, */*' + 'Accept': 'application/json, text/plain, */*', + 'Cookie': buildFStreamCookieHeader() }, ...(agents ? { httpAgent: agents.httpAgent || agents, httpsAgent: agents.httpsAgent || agents, proxy: false } : {}) }); @@ -844,8 +973,14 @@ async function fetchMoviePlayersFromApi(pageUrl) { // === High-level wrappers: API-first, HTML fallback === // Bypass le fetch HTML (souvent 429) en appelant l'API directe d'abord async function getSeriesPlayersForUrl(pageUrl) { - // 1. API directe (pas de login, pool proxy combine) - const apiEpisodes = await fetchEpisodesFromApi(pageUrl); + // 1. Source statique <base>/static/series/<id>.js (prioritaire: fraiche, premium, inclut tous les eps) + let apiEpisodes = await fetchEpisodesFromStaticJs(pageUrl); + + // 2. Fallback: API dynamique episodes_p.php (si la statique 404/echoue ou host a tourne) + if (!apiEpisodes || Object.keys(apiEpisodes).length === 0) { + apiEpisodes = await fetchEpisodesFromApi(pageUrl); + } + if (apiEpisodes && Object.keys(apiEpisodes).length > 0) { const organizedPlayers = { VF: [], VOSTFR: [], VOENG: [], Default: [] }; let totalPlayers = 0; @@ -860,8 +995,8 @@ async function getSeriesPlayersForUrl(pageUrl) { return { organized: organizedPlayers, episodes: apiEpisodes, total: totalPlayers, fstreamReleaseDate: null, fromApi: true }; } - // 2. Fallback: fetch HTML - console.log(`[FStream] getSeriesPlayersForUrl: API echouee, fallback HTML pour ${pageUrl}`); + // 3. Fallback: fetch HTML (scrape direct de la page french-stream.one) + console.log(`[FStream] getSeriesPlayersForUrl: sources JSON echouees, fallback HTML pour ${pageUrl}`); const contentResponse = await axiosFStreamRequest({ method: 'get', url: pageUrl }); if (contentResponse.status !== 200) return { organized: { VF: [], VOSTFR: [], VOENG: [], Default: [] }, episodes: {}, total: 0, fstreamReleaseDate: null }; return await extractFStreamPlayers(contentResponse.data, true, pageUrl); @@ -1693,6 +1828,16 @@ router.get('/tv/:id/season/:season', async (req, res) => { } } + // Fallback get_seasons.php: search ne liste pas toujours toutes les saisons. + if (!bestResult) { + const newsIdSource = filteredResults.find(r => extractPageIdFromUrl(r.link)); + if (newsIdSource) { + const ajaxSeasons = await fetchFStreamSeasonsAjax(id, extractPageIdFromUrl(newsIdSource.link), extractBaseUrlFromLink(newsIdSource.link)); + const ajaxMatch = ajaxSeasons.find(r => r.seasonNumber === requestedSeason); + if (ajaxMatch) bestResult = ajaxMatch; + } + } + if (!bestResult) { // Background update: ne pas écraser le cache avec une erreur return; @@ -1824,6 +1969,20 @@ router.get('/tv/:id/season/:season', async (req, res) => { } } + // Fallback get_seasons.php: search.php ne liste pas toujours toutes les saisons. + // On a au moins une saison trouvee -> son page id sert de news_id, serie_tag = s-<tmdbId>. + if (!bestResult) { + const newsIdSource = filteredResults.find(r => extractPageIdFromUrl(r.link)); + if (newsIdSource) { + const ajaxSeasons = await fetchFStreamSeasonsAjax(id, extractPageIdFromUrl(newsIdSource.link), extractBaseUrlFromLink(newsIdSource.link)); + const ajaxMatch = ajaxSeasons.find(r => r.seasonNumber === requestedSeason); + if (ajaxMatch) { + bestResult = ajaxMatch; + console.log(`[FSTREAM TV] Fallback get_seasons.php reussi: saison ${requestedSeason} pour "${tmdbDetails.title}"`); + } + } + } + if (!bestResult) { const availableSeasons = filteredResults .filter(r => r.seasonNumber && r.title.toLowerCase().includes(tmdbDetails.title.toLowerCase().split(/[:\-]/)[0].trim().toLowerCase())) diff --git a/API/Mainapi/routes/j1f.js b/API/Mainapi/routes/j1f.js new file mode 100644 index 0000000..0546315 --- /dev/null +++ b/API/Mainapi/routes/j1f.js @@ -0,0 +1,377 @@ +/** + * 1jour1film (1J1F) source — Dooplay/WordPress scraper. + * Mount point: app.use('/api/j1f', require('./routes/j1f')) + * + * Endpoints (TMDB id in, players out — same contract as fstream/wiflix): + * GET /movie/:id + * GET /tv/:id/season/:season (optional ?episode=N) + * + * Flow + * ---- + * 0. Domain rotates. Resolve the live base from the stable /go/ entry + * (TARGET_URL = "..."), cached + env-overridable (J1F_BASE_URL). + * 1. Search via the nonce-free WP listing {base}/?s={query} -> /films/ + + * /tvshows/ links (slug carries the year). Matched against TMDB title+year. + * 2. The real source list is NOT in the plain HTML — it ships base64-encoded + * inside <script defer src="data:text/javascript;base64,...">. Decode those: + * - Movies: `var J1F_SRV = [{label,url,type,source}, ...]` + * - Series: `var j1fEpsData = [{num,label,servers:[{label,url,type}], ...}]` + * (the season's /saisons/{slug}/ page; pick the link whose slug carries the + * season number). + * 3. Each source has a `source` tag: + * - "manual" -> 1J1F's own players (totocoutouno, bysezoxexe, ...) — UNIQUE. + * - "frembed"/"vidsrc"/"videasy" -> generic TMDB-id aggregators that Movix + * already exposes as their own sources. We DROP these (J1F_DROP_SOURCES) + * so 1J1F doesn't just duplicate frembed; only the unique players remain. + * Kept players are split VF/VOSTFR by label. + * + * When a title yields no unique (manual) player, we still cache the negative + * result (sentinel) so the frontend marks 1J1F "checked, nothing extra" and we + * don't re-scrape on every hit. + * + * All fetches go through make1j1fRequest (CycleTLS JA3-Chrome + ProxyScrape + * rotation) which clears the Cloudflare shield. Works from datacenter IPs — + * verified: the J1F_SRV/j1fEpsData blobs are in the page for any client, just + * base64-wrapped. + */ + +const express = require('express'); +const router = express.Router(); +const cheerio = require('cheerio'); + +const { fetchTmdbDetails } = require('../utils/tmdbCache'); +const { make1j1fRequest } = require('../utils/proxyManager'); +const { + CACHE_DIR, + generateCacheKey, + getFromCacheNoExpiration, + saveToCache, +} = require('../utils/cacheManager'); +const { calculateTitleSimilarity } = require('./coflix'); // pure fn, no configure() needed + +const TMDB_API_KEY = process.env.TMDB_API_KEY || ''; +const TMDB_API_URL = 'https://api.themoviedb.org/3'; + +// --- Env-overridable knobs (domain rotates; markup may drift) --- +const GO_URL = process.env.J1F_GO_URL || 'https://1jour1film2026.site/go/'; +const BASE_OVERRIDE = (process.env.J1F_BASE_URL || '').trim().replace(/\/+$/, ''); +// Source tags to DROP — generic aggregators Movix already has as separate +// sources. Anything not listed (e.g. "manual") is kept as a unique 1J1F player. +const DROP_SOURCES = new Set( + (process.env.J1F_DROP_SOURCES || 'frembed,vidsrc,videasy') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), +); +// Series episode servers carry no `source` tag, so generic aggregators are +// dropped by host here too (vsembed=vidsrc, videasy, frembed). +const DROP_HOST_RE = new RegExp(process.env.J1F_DROP_HOSTS || 'vsembed|videasy|frembed|vidsrc', 'i'); +const SRV_VAR = process.env.J1F_SRV_VAR || 'J1F_SRV'; // movie source array +const EPS_VAR = process.env.J1F_EPS_VAR || 'j1fEpsData'; // series episodes array +const SIMILARITY_THRESHOLD = parseFloat(process.env.J1F_SIMILARITY || '0.7'); +const BASE_TTL_MS = 6 * 60 * 60 * 1000; // re-resolve domain every 6h +const REFRESH_MS = 40 * 60 * 1000; // serve cache fresh within this window + +const hostOf = (u) => { + const m = (u || '').match(/^https?:\/\/(?:www\.)?([^/]+)/); + return m ? m[1] : u || 'embed'; +}; +const toBody = (r) => (typeof r.data === 'string' ? r.data : JSON.stringify(r.data)); +// VOSTFR only when the label is VOSTFR-only; "VF + VOSTFR" (dual) stays VF. +const langOf = (label) => + /vostfr/i.test(label || '') && !/\bvf\b/i.test(label || '') ? 'VOSTFR' : 'VF'; +const yearFromSlug = (slug) => { + const m = slug.match(/-(\d{4})(?:[-/]|$)/); + return m ? parseInt(m[1], 10) : null; +}; + +// === Domain resolution (cached) === +let cachedBase = null; +let cachedBaseAt = 0; +async function resolveBase() { + if (BASE_OVERRIDE) return BASE_OVERRIDE; + if (cachedBase && Date.now() - cachedBaseAt < BASE_TTL_MS) return cachedBase; + const res = await make1j1fRequest(GO_URL, { timeout: 20 }); + const m = toBody(res).match(/TARGET_URL\s*=\s*"([^"]+)"/); + if (!m) { + if (cachedBase) return cachedBase; // keep last good on a bad /go/ fetch + throw new Error('[1J1F] TARGET_URL introuvable sur /go/'); + } + cachedBase = m[1].replace(/\\\//g, '/').replace(/\/+$/, ''); + cachedBaseAt = Date.now(); + return cachedBase; +} + +// In-flight scrapes keyed by cache key, so concurrent cold requests for the same +// title kick off only ONE background scrape. +const inFlight = new Map(); + +// Non-blocking cache — never make the user wait on the slow CycleTLS+proxy scrape: +// - fresh cache -> return it +// - stale cache -> return stale now, refresh in background (stale-while-revalidate) +// - cold (no cache) -> return { pending:true } immediately, scrape in background +// The background job caches the real result; the client gets it on a subsequent +// request (the frontend retries shortly after a `pending` response). Negative +// results are cached too (sentinel) so frembed-only titles don't re-scrape. +function startBackgroundScrape(key, fetcher) { + if (inFlight.has(key)) return inFlight.get(key); + const job = (async () => { + try { + const fresh = await fetcher(); + fresh._ts = Date.now(); + await saveToCache(CACHE_DIR.J1F, key, fresh); + return fresh; + } finally { + inFlight.delete(key); + } + })(); + inFlight.set(key, job); + job.catch(() => {}); // next request retries; avoid unhandled rejection + return job; +} + +async function withCache(key, fetcher) { + const cached = await getFromCacheNoExpiration(CACHE_DIR.J1F, key); + if (cached && cached._ts && Date.now() - cached._ts < REFRESH_MS) return cached; + + // Stale or cold: trigger a background refresh/scrape (deduped), don't await it. + startBackgroundScrape(key, fetcher); + + if (cached) return cached; // stale-while-revalidate: serve stale immediately + return { success: false, pending: true, tmdb_id: undefined }; // cold: tell client to retry shortly +} + +// Decode every <script ... src="data:text/javascript;base64,...">. The real +// player data (J1F_SRV / j1fEpsData) lives base64-wrapped in these. +function decodeDataScripts(html) { + const out = []; + const re = /data:text\/javascript;base64,([A-Za-z0-9+/=]+)/g; + let m; + while ((m = re.exec(html)) !== null) { + try { + out.push(Buffer.from(m[1], 'base64').toString('utf-8')); + } catch { + /* skip undecodable */ + } + } + return out; +} + +// Pull `var <name> = [ ... ];` (a JSON array) out of the decoded data: scripts. +function extractJsArray(html, varName) { + const re = new RegExp(`(?:var|let|const)\\s+${varName}\\s*=\\s*(\\[[\\s\\S]*?\\])\\s*;`); + for (const js of decodeDataScripts(html)) { + const m = js.match(re); + if (m) { + try { + return JSON.parse(m[1]); + } catch { + /* malformed — try the next script */ + } + } + } + return null; +} + +// Keep only unique (non-generic) servers, split VF/VOSTFR by label. +function splitUniqueServers(servers) { + const vf = []; + const vostfr = []; + for (const s of servers || []) { + if (!s || !s.url) continue; + if (DROP_SOURCES.has(String(s.source || '').toLowerCase())) continue; // drop by `source` tag (movies) + if (DROP_HOST_RE.test(s.url)) continue; // drop by host (series servers have no `source` tag) + const entry = { + name: hostOf(s.url), + url: s.url, + type: s.type === 'mp4' ? 'mp4' : 'iframe', + label: s.label || '', + source: s.source || 'manual', + }; + (langOf(s.label) === 'VOSTFR' ? vostfr : vf).push(entry); + } + return { vf, vostfr }; +} + +// === Search: {base}/?s={query} -> [{url, slug, type, year}] === +async function searchJ1F(base, query) { + const url = `${base}/?s=${encodeURIComponent(query)}`; + const res = await make1j1fRequest(url, { timeout: 15 }); + const $ = cheerio.load(toBody(res)); + const seen = new Set(); + const out = []; + $('a[href*="/films/"], a[href*="/tvshows/"]').each((_, a) => { + const href = $(a).attr('href') || ''; + const m = href.match(/\/(films|tvshows)\/([^/"?#]+)\/?/); + if (!m) return; + const slug = m[2]; + if (seen.has(slug)) return; + seen.add(slug); + out.push({ + url: href.split('#')[0], + slug, + type: m[1] === 'films' ? 'movie' : 'tv', + year: yearFromSlug(slug), + }); + }); + return out; +} + +// Best search hit for a TMDB title/year. Title is reconstructed from the slug. +function pickBest(results, mediaType, titles, year) { + let best = null; + let bestScore = 0; + for (const r of results.filter((x) => x.type === mediaType)) { + const slugTitle = r.slug + .replace(/-(streaming|vf|vostfr|hd|fhd|complete?|netflix|serie|saison|film|episode|\d{4}|[a-z]\d+)\b/gi, ' ') + .replace(/-/g, ' ') + .trim(); + let score = 0; + for (const t of titles) score = Math.max(score, calculateTitleSimilarity(t, slugTitle)); + if (year && r.year === year) score += 0.15; + if (score > bestScore) { + bestScore = score; + best = r; + } + } + return bestScore >= SIMILARITY_THRESHOLD ? best : null; +} + +async function findOnJ1F(base, mediaType, tmdb) { + const titles = [ + mediaType === 'movie' ? tmdb.title : tmdb.name, + mediaType === 'movie' ? tmdb.original_title : tmdb.original_name, + ].filter((t, i, a) => t && a.indexOf(t) === i); + const dateStr = mediaType === 'movie' ? tmdb.release_date : tmdb.first_air_date; + const year = dateStr ? parseInt(String(dateStr).slice(0, 4), 10) : null; + + for (const t of titles) { + let results = []; + try { + results = await searchJ1F(base, t); + } catch (e) { + console.log(`[1J1F SEARCH] "${t}": ${e.message}`); + continue; + } + const hit = pickBest(results, mediaType, titles, year); + if (hit) return hit; + } + return null; +} + +// === Movie === +async function fetchMovie(base, tmdbId) { + const tmdb = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, 'movie', 'fr-FR'); + if (!tmdb) return { success: false, error: 'Film non trouve sur TMDB', tmdb_id: tmdbId }; + + const hit = await findOnJ1F(base, 'movie', tmdb); + if (!hit) return { success: false, error: 'Film non trouve sur 1jour1film', tmdb_id: tmdbId }; + + const res = await make1j1fRequest(hit.url, { timeout: 15 }); + const srv = extractJsArray(toBody(res), SRV_VAR); + if (!srv) { + console.warn(`[1J1F MOVIE] ${tmdbId}: ${SRV_VAR} introuvable sur ${hit.url}`); + return { success: false, error: 'Sources introuvables', tmdb_id: tmdbId, j1f_url: hit.url }; + } + + const { vf, vostfr } = splitUniqueServers(srv); + if (vf.length === 0 && vostfr.length === 0) { + // Only generic aggregators (frembed/vidsrc/videasy) — nothing 1J1F adds. + return { success: false, error: 'Aucune source unique (generiques uniquement)', tmdb_id: tmdbId, j1f_url: hit.url }; + } + return { + success: true, + tmdb_id: tmdbId, + title: tmdb.title, + original_title: tmdb.original_title, + source: '1jour1film', + j1f_url: hit.url, + players: { vf, vostfr }, + cache_timestamp: new Date().toISOString(), + }; +} + +// === Series === +async function fetchSeason(base, tmdbId, seasonNum, episodeNum) { + const tmdb = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, 'tv', 'fr-FR'); + if (!tmdb) return { success: false, error: 'Serie non trouvee sur TMDB', tmdb_id: tmdbId }; + + const hit = await findOnJ1F(base, 'tv', tmdb); + if (!hit) return { success: false, error: 'Serie non trouvee sur 1jour1film', tmdb_id: tmdbId }; + + // tvshow page -> the /saisons/ link whose slug carries this season number. + const showRes = await make1j1fRequest(hit.url, { timeout: 15 }); + const $show = cheerio.load(toBody(showRes)); + let seasonUrl = null; + $show('a[href*="/saisons/"]').each((_, a) => { + const href = $show(a).attr('href') || ''; + const m = href.match(/saison-(\d+)/i); + if (m && parseInt(m[1], 10) === Number(seasonNum)) seasonUrl = href.split('#')[0]; + }); + if (!seasonUrl) { + return { success: false, error: `Saison ${seasonNum} introuvable`, tmdb_id: tmdbId, j1f_url: hit.url }; + } + + const seasonRes = await make1j1fRequest(seasonUrl, { timeout: 15 }); + const eps = extractJsArray(toBody(seasonRes), EPS_VAR); + if (!eps || !Array.isArray(eps)) { + console.warn(`[1J1F TV] ${tmdbId} S${seasonNum}: ${EPS_VAR} introuvable sur ${seasonUrl}`); + return { success: false, error: 'Episodes introuvables', tmdb_id: tmdbId, j1f_url: seasonUrl }; + } + + // Shape matches wiflix's TV response: `episodes` keyed by episode number, + // each { vf:[{name,url,...}], vostfr:[...] } — the whole season is returned + // and the frontend slices the current episode. `?episode=` still narrows it. + const wanted = episodeNum ? eps.filter((e) => Number(e.num) === Number(episodeNum)) : eps; + const episodes = {}; + for (const e of wanted) { + const { vf, vostfr } = splitUniqueServers(e.servers); + if (!vf.length && !vostfr.length) continue; // drop generic-only episodes + episodes[String(e.num)] = { vf, vostfr, label: e.label || '' }; + } + + if (Object.keys(episodes).length === 0) { + return { success: false, error: 'Aucune source unique (generiques uniquement)', tmdb_id: tmdbId, j1f_url: seasonUrl }; + } + return { + success: true, + tmdb_id: tmdbId, + title: tmdb.name, + source: '1jour1film', + j1f_url: seasonUrl, + season: Number(seasonNum), + episodes, + cache_timestamp: new Date().toISOString(), + }; +} + +// === Routes === +router.get('/movie/:id', async (req, res) => { + const { id } = req.params; + try { + const base = await resolveBase(); + const data = await withCache(generateCacheKey({ src: 'j1f', t: 'movie', id }), () => + fetchMovie(base, id), + ); + res.json(data); + } catch (err) { + console.error(`[1J1F MOVIE] ${id}: ${err.message}`); + res.status(200).json({ success: false, error: 'Erreur 1jour1film', tmdb_id: id }); + } +}); + +router.get('/tv/:id/season/:season', async (req, res) => { + const { id, season } = req.params; + const { episode } = req.query; + try { + const base = await resolveBase(); + const key = generateCacheKey({ src: 'j1f', t: 'tv', id, season, episode: episode || '' }); + const data = await withCache(key, () => fetchSeason(base, id, season, episode)); + res.json(data); + } catch (err) { + console.error(`[1J1F TV] ${id} S${season}: ${err.message}`); + res.status(200).json({ success: false, error: 'Erreur 1jour1film', tmdb_id: id }); + } +}); + +module.exports = router; diff --git a/API/Mainapi/routes/oauth.js b/API/Mainapi/routes/oauth.js index fca5a65..b13b2a4 100644 --- a/API/Mainapi/routes/oauth.js +++ b/API/Mainapi/routes/oauth.js @@ -26,7 +26,7 @@ const { const { readUserData, writeUserData, readProfileData, writeProfileData, withProfileSyncLock } = require('./sync'); const { recordEvent: recordOAuthAppEvent, grantVip: grantVipFromAppBalance } = require('../utils/oauthClientsDb'); const { verifyAccessKey } = require('../checkVip'); -const { ensureSafeProfileId, getProfileFilePath } = require('../utils/syncPolicy'); +const { ensureSafeProfileId, getProfileFilePath, validateProfileName, SyncPolicyError } = require('../utils/syncPolicy'); const { v4: uuidv4 } = require('uuid'); const { createVipInvoice, @@ -994,13 +994,23 @@ router.get('/profiles/:profileId', async (req, res) => { router.post('/profiles', async (req, res) => { try { const tokenRecord = await getOauthTokenAuth(req, ['profile.manage']); - const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const rawName = typeof req.body?.name === 'string' ? req.body.name : ''; const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : ''; - if (!name || !avatar) { + if (!rawName || !avatar) { return sendOauthJsonError(res, 400, 'invalid_request', 'name et avatar sont requis'); } + let safeName; + try { + safeName = validateProfileName(rawName); + } catch (e) { + if (e instanceof SyncPolicyError) { + return sendOauthJsonError(res, e.status, 'invalid_request', e.message); + } + throw e; + } + if (!avatar.startsWith('/avatars/')) { return sendOauthJsonError(res, 400, 'invalid_request', 'avatar doit commencer par /avatars/'); } @@ -1019,7 +1029,7 @@ router.post('/profiles', async (req, res) => { const newProfile = { id: uuidv4(), - name, + name: safeName, avatar, ageRestriction, createdAt: new Date().toISOString(), @@ -1049,11 +1059,11 @@ router.put('/profiles/:profileId', async (req, res) => { try { const tokenRecord = await getOauthTokenAuth(req, ['profile.manage']); const profileId = ensureSafeProfileId(req.params.profileId); - const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const rawName = typeof req.body?.name === 'string' ? req.body.name : ''; const avatar = typeof req.body?.avatar === 'string' ? req.body.avatar.trim() : ''; const ageRestriction = req.body?.ageRestriction; - if (!name && !avatar && ageRestriction === undefined) { + if (!rawName && !avatar && ageRestriction === undefined) { return sendOauthJsonError(res, 400, 'invalid_request', 'name, avatar ou ageRestriction requis'); } @@ -1061,6 +1071,18 @@ router.put('/profiles/:profileId', async (req, res) => { return sendOauthJsonError(res, 400, 'invalid_request', 'avatar doit commencer par /avatars/'); } + let safeName = null; + if (rawName) { + try { + safeName = validateProfileName(rawName); + } catch (e) { + if (e instanceof SyncPolicyError) { + return sendOauthJsonError(res, e.status, 'invalid_request', e.message); + } + throw e; + } + } + const userData = await readUserData(tokenRecord.userType, tokenRecord.userId); const profiles = userData.profiles || []; const profileIndex = profiles.findIndex((p) => p.id === profileId); @@ -1069,7 +1091,7 @@ router.put('/profiles/:profileId', async (req, res) => { return sendOauthJsonError(res, 404, 'not_found', 'Profil introuvable'); } - if (name) profiles[profileIndex].name = name; + if (safeName) profiles[profileIndex].name = safeName; if (avatar) profiles[profileIndex].avatar = avatar; if (ageRestriction !== undefined) { const validAgeRestrictions = [0, 7, 12, 16, 18]; diff --git a/API/Mainapi/routes/profiles.js b/API/Mainapi/routes/profiles.js index 5797a35..8038e59 100644 --- a/API/Mainapi/routes/profiles.js +++ b/API/Mainapi/routes/profiles.js @@ -15,7 +15,9 @@ const { ensureSafeProfileId, getOwnedProfile, getProfileFilePath, - sanitizeProfileData + sanitizeProfileData, + SyncPolicyError, + validateProfileName } = require('../utils/syncPolicy'); // Lazy imports from sync module to avoid circular dependencies @@ -59,6 +61,13 @@ router.post('/', async (req, res) => { if (avatar && !avatar.startsWith('/avatars/') && avatar !== '') { return res.status(400).json({ error: 'Invalid avatar URL. Must be a local path starting with /avatars/' }); } + let safeName; + try { + safeName = validateProfileName(name); + } catch (e) { + if (e instanceof SyncPolicyError) return res.status(e.status).json({ error: e.message, code: e.code }); + throw e; + } const validAgeRestrictions = [0, 7, 12, 16, 18]; const ageRestrictionValue = validAgeRestrictions.includes(Number(ageRestriction)) ? Number(ageRestriction) : 0; @@ -71,7 +80,7 @@ router.post('/', async (req, res) => { const profileId = uuidv4(); const newProfile = { id: profileId, - name: name.trim(), + name: safeName, avatar, ageRestriction: ageRestrictionValue, createdAt: new Date().toISOString(), @@ -113,7 +122,14 @@ router.put('/:profileId', async (req, res) => { const profileIndex = profiles.findIndex(p => p.id === profileId); if (profileIndex === -1) return res.status(404).json({ error: 'Profile not found' }); - if (name) profiles[profileIndex].name = name.trim(); + if (name) { + try { + profiles[profileIndex].name = validateProfileName(name); + } catch (e) { + if (e instanceof SyncPolicyError) return res.status(e.status).json({ error: e.message, code: e.code }); + throw e; + } + } if (avatar) profiles[profileIndex].avatar = avatar; if (ageRestriction !== undefined) { const validAgeRestrictions = [0, 7, 12, 16, 18]; diff --git a/API/Mainapi/routes/proxy.js b/API/Mainapi/routes/proxy.js deleted file mode 100644 index 4d653cf..0000000 --- a/API/Mainapi/routes/proxy.js +++ /dev/null @@ -1,313 +0,0 @@ -/** - * Proxy route. - * Extracted from server.js -- raw HTML proxy with domain-specific headers. - * Mount point: app.use('/proxy', router) - */ - -const express = require('express'); -const router = express.Router(); -const axios = require('axios'); -const crypto = require('crypto'); -const path = require('path'); -const fsp = require('fs').promises; - -const { safeWriteJsonFile } = require('../utils/safeFile'); -const { CACHE_DIR } = require('../utils/cacheManager'); - -// === Routes === - -// GET /* - Proxy route for raw HTML (replaces old /proxy route) -router.get(/^\/(.*)/, async (req, res) => { - try { - // Extract the target URL after / - let targetUrl = req.url.slice(1); // Remove leading '/' - - // Decode the URL recursively if it's encoded (handles double/triple encoding) - try { - let decoded = targetUrl; - let previousDecoded = ''; - // Keep decoding until the URL doesn't change anymore (handles multiple encodings) - while (decoded !== previousDecoded) { - previousDecoded = decoded; - try { - decoded = decodeURIComponent(decoded); - } catch (e) { - // If decoding fails, break the loop - break; - } - } - targetUrl = decoded; - } catch (decodeError) { - // If decoding fails, use the original URL - console.warn('Failed to decode URL:', targetUrl, decodeError.message); - } - - // Fix recursive proxying issue - remove any localhost/proxy/ patterns - const localhostProxyPattern = /localhost(:\d+)?\/proxy\//i; - if (localhostProxyPattern.test(targetUrl)) { - console.log(`Detected recursive proxy request in: ${targetUrl}`); - targetUrl = targetUrl.replace(localhostProxyPattern, ''); - console.log(`Corrected to: ${targetUrl}`); - } - - // Check if the URL starts with http(s)://, if not, prepend https:// - if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) { - targetUrl = 'https://' + targetUrl; - } - - // === TVDIRECT SPECIAL HANDLING === - if (targetUrl.includes('tvdirect.ddns')) { - try { - const cacheKey = crypto.createHash('md5').update(targetUrl).digest('hex'); - // Ensure directory exists - await fsp.mkdir(CACHE_DIR.TVDIRECT, { recursive: true }); - const cacheFile = path.join(CACHE_DIR.TVDIRECT, `${cacheKey}.json`); - - // Helper to fetch external resource - const fetchTvDirect = async () => { - console.log(`[TVDIRECT] Fetching ${targetUrl}`); - const resp = await axios.get(targetUrl, { - headers: { - 'User-Agent': 'stremio', - 'Accept': '*/*' - }, - responseType: 'text', - timeout: 15000 - }); - return { - data: resp.data, - headers: resp.headers, - timestamp: Date.now() - }; - }; - - // Try to read from cache - let cachedEntry = null; - try { - const fileContent = await fsp.readFile(cacheFile, 'utf8'); - cachedEntry = JSON.parse(fileContent); - } catch (e) { /* No cache or invalid */ } - - const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours - const now = Date.now(); - - if (cachedEntry && cachedEntry.data) { - // Serve cached response - if (cachedEntry.headers && cachedEntry.headers['content-type']) { - res.setHeader('Content-Type', cachedEntry.headers['content-type']); - } - res.setHeader('Access-Control-Allow-Origin', '*'); - res.send(cachedEntry.data); - - // Check if stale for background update - if (now - cachedEntry.timestamp > CACHE_TTL) { - console.log(`[TVDIRECT] Cache stale for ${targetUrl}, updating in background...`); - // Background update (no await) - fetchTvDirect().then(async (newData) => { - await safeWriteJsonFile(cacheFile, newData); - console.log(`[TVDIRECT] Background update success for ${targetUrl}`); - }).catch(err => console.error(`[TVDIRECT] Background update failed: ${err.message}`)); - } - return; // Exit route - } - - // No cache, fetch synchronously - const newData = await fetchTvDirect(); - await safeWriteJsonFile(cacheFile, newData); - - if (newData.headers && newData.headers['content-type']) { - res.setHeader('Content-Type', newData.headers['content-type']); - } - res.setHeader('Access-Control-Allow-Origin', '*'); - res.send(newData.data); - return; // Exit route - } catch (error) { - console.error(`[TVDIRECT] Error handling request:`, error); - return res.status(502).json({ error: 'TVDirect proxy failed', details: error.message }); - } - } - - // Préparer les headers à forwarder - let refererOrigin; - let targetHost; - try { - const urlObj = new URL(targetUrl); - refererOrigin = urlObj.origin; - targetHost = urlObj.host; - } catch (urlError) { - // Si l'URL est invalide, utiliser une valeur par défaut - console.warn('Invalid URL for referer:', targetUrl, urlError.message); - refererOrigin = 'https://vmwesa.online'; - targetHost = 'vmwesa.online'; - } - - // Headers spécifiques pour vmwesa/vidmoly et certains CDN (ex: getromes.space) - const isVmwesa = /vmwesa\.online|vidmoly|getromes\.space/i.test(targetUrl); - - // Headers spécifiques pour dropcdn - const isDropcdn = /dropcdn/i.test(targetUrl); - - // Headers spécifiques pour serversicuro - const isServersicuro = /serversicuro/i.test(targetUrl); - - // Headers spécifiques pour coflix - const isCoflix = /coflix\.(bet|si|boo|io)/i.test(targetUrl); - - const headers = isCoflix ? { - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', - 'Accept-Language': 'fr-FR,fr;q=0.7', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache', - 'Priority': 'u=0, i', - 'Referer': 'https://coflix.date/', - 'Sec-CH-UA': '"Brave";v="141", "Not?A_Brand";v="8", "Chromium";v="141"', - 'Sec-CH-UA-Mobile': '?0', - 'Sec-CH-UA-Platform': '"Windows"', - 'Sec-Fetch-Dest': 'iframe', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Storage-Access': 'none', - 'Sec-GPC': '1', - 'Upgrade-Insecure-Requests': '1', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36' - } : isVmwesa ? { - 'Accept': 'application/vnd.apple.mpegurl,application/x-mpegURL,*/*', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - 'Accept-Language': 'fr-FR,fr;q=0.8', - 'Connection': 'keep-alive', - 'Host': targetHost, - 'Origin': 'https://vidmoly.net', - 'Referer': 'https://vidmoly.net/', - 'Sec-CH-UA': '"Not;A=Brand";v="99", "Brave";v="139", "Chromium";v="139"', - 'Sec-CH-UA-Mobile': '?0', - 'Sec-CH-UA-Platform': '"Windows"', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-GPC': '1', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' - } : isDropcdn ? { - 'Accept': '*/*', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - 'Accept-Language': 'fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7', - 'Connection': 'keep-alive', - 'Host': targetHost, - 'Origin': 'https://dropload.tv', - 'Referer': 'https://dropload.tv/', - 'Sec-CH-UA': '"Not;A=Brand";v="99", "Brave";v="139", "Chromium";v="139"', - 'Sec-CH-UA-Mobile': '?0', - 'Sec-CH-UA-Platform': '"Windows"', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-GPC': '1', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' - } : isServersicuro ? { - 'Accept': '*/*', - 'Accept-Encoding': 'gzip, deflate, br, zstd', - 'Accept-Language': 'fr-FR,fr;q=0.8', - 'Connection': 'keep-alive', - 'Host': targetHost, - 'Origin': 'https://supervideo.cc', - 'Referer': 'https://supervideo.cc/', - 'Sec-CH-UA': '"Not;A=Brand";v="99", "Brave";v="139", "Chromium";v="139"', - 'Sec-CH-UA-Mobile': '?0', - 'Sec-CH-UA-Platform': '"Windows"', - 'Sec-Fetch-Dest': 'empty', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-GPC': '1', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' - } : { - 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Referer': refererOrigin, - 'Accept': req.headers['accept'] || '*/*', - 'Accept-Encoding': req.headers['accept-encoding'] || 'gzip, deflate, br', - 'Connection': 'keep-alive', - }; - // Forward Range header if present (important for video streaming) - if (req.headers['range']) { - headers['Range'] = req.headers['range']; - } - - // Détecter si c'est un .m3u8 (playlist HLS) - const isM3U8 = targetUrl.toLowerCase().includes('.m3u8') || (req.headers.accept && req.headers.accept.includes('application/vnd.apple.mpegurl')); - - // Faire la requête distante avec l'agent de streaming pour le proxy - const response = await axios({ - method: 'get', - url: targetUrl, - responseType: isM3U8 ? 'text' : 'stream', - headers, - timeout: 30000, - maxRedirects: 5, - validateStatus: status => true, // On gère nous-même les codes d'erreur - decompress: true // Disable automatic decompression for better performance - }); - - // Copier les headers utiles - Object.entries(response.headers).forEach(([key, value]) => { - // Éviter certains headers problématiques - if (!['transfer-encoding', 'connection', 'content-encoding'].includes(key.toLowerCase())) { - res.setHeader(key, value); - } - }); - - // CORS headers - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS', 'DELETE'); - res.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Range'); - - // Si c'est un .m3u8, retourner le contenu original sans modification - if (isM3U8 && typeof response.data === 'string') { - const contentType = response.headers['content-type'] || ''; - const bodyText = response.data; - - // Retourner le contenu M3U8 original sans modification - if (contentType) res.setHeader('content-type', contentType); - res.status(response.status).send(bodyText); - return; - } - - // Gestion du code de retour (206 pour Range, sinon code d'origine) - res.status(response.status); - - // Détruire le stream upstream si le client se déconnecte pour éviter les fuites mémoire - const upstream = response.data; - res.on('close', () => { - if (!upstream.destroyed) { - upstream.destroy(); - } - }); - - upstream.on('error', (err) => { - console.error('Proxy upstream error:', err.message); - if (!res.headersSent) { - res.status(502).json({ error: 'Upstream stream error', message: err.message }); - } - if (!upstream.destroyed) upstream.destroy(); - }); - - upstream.pipe(res); - } catch (error) { - console.error('Proxy error:', error.message); - if (error.response) { - res.status(error.response.status).json({ - error: `Target server responded with ${error.response.status}`, - message: error.message - }); - } else if (error.request) { - res.status(504).json({ - error: 'Gateway Timeout', - message: 'No response received from target server' - }); - } else { - res.status(500).json({ - error: 'Internal Server Error', - message: error.message - }); - } - } -}); - -module.exports = router; diff --git a/API/Mainapi/routes/purstream.js b/API/Mainapi/routes/purstream.js index 42b6821..ca46b33 100644 --- a/API/Mainapi/routes/purstream.js +++ b/API/Mainapi/routes/purstream.js @@ -42,7 +42,7 @@ function configure(deps) { /** Wrap une URL m3u8 dans le proxy cinep si VIP et PROXY_SERVER_URL configuré */ function wrapSourceUrl(url, isVip) { if (isVip && PROXY_SERVER_URL && url) { - // PROXY_SERVER_URL = "https://proxy.movix.tax/proxy" → on veut la base sans /proxy + // PROXY_SERVER_URL = "https://proxy.movix.cloud/proxy" → on veut la base sans /proxy const serverBase = PROXY_SERVER_URL.replace(/\/proxy\/?$/, '').replace(/\/+$/, ''); return `${serverBase}/cinep-proxy?url=${encodeURIComponent(url)}`; } diff --git a/API/Mainapi/routes/tmdb.js b/API/Mainapi/routes/tmdb.js index d61bfe9..9d6d537 100644 --- a/API/Mainapi/routes/tmdb.js +++ b/API/Mainapi/routes/tmdb.js @@ -485,7 +485,9 @@ router.get('/tmdb/:type/:id', async (req, res) => { } } catch (updateError) { - if (updateError && updateError.isAxiosError) { + if (updateError && updateError.coflixSiteRateLimited) { + // Coflix global 429 — announced once by the cooldown; stay silent here. + } else if (updateError && updateError.isAxiosError) { const url = updateError.config && updateError.config.url ? updateError.config.url : ''; console.error( `Erreur lors de la mise a jour du cache TMDB ${id} (${type}): [AxiosError] ${updateError.code || ''} ${updateError.message} ${url}` diff --git a/API/Mainapi/routes/topstream.js b/API/Mainapi/routes/topstream.js deleted file mode 100644 index a5cb726..0000000 --- a/API/Mainapi/routes/topstream.js +++ /dev/null @@ -1,486 +0,0 @@ -/** - * Top-Stream routes module. - * Scrapes top-stream.plus for movie/series search and embed extraction. - * - * Mounted at /api/topstream (paths below are relative to that prefix). - */ - -const express = require('express'); -const router = express.Router(); -const path = require('path'); -const fsp = require('fs').promises; -const axios = require('axios'); -const cheerio = require('cheerio'); -const { generateCacheKey } = require('../utils/cacheManager'); -const { fetchTmdbDetails } = require('../utils/tmdbCache'); - -const TOPSTREAM_BASE_URL = 'https://top-stream.plus'; -const BYPASS403_SERVER_URL = process.env.BYPASS403_SERVER_URL || ''; - -const topstreamHeaders = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', - 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Referer': TOPSTREAM_BASE_URL, - 'DNT': '1', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1' -}; - -// --------------------------------------------------------------------------- -// Dependencies injected via configure() -// --------------------------------------------------------------------------- -let TMDB_API_KEY; -let TMDB_API_URL; -let getFromCacheNoExpiration; -let saveToCache; -let shouldUpdateCache24h; - -function configure(deps) { - if (deps.TMDB_API_KEY) TMDB_API_KEY = deps.TMDB_API_KEY; - if (deps.TMDB_API_URL) TMDB_API_URL = deps.TMDB_API_URL; - if (deps.getFromCacheNoExpiration) getFromCacheNoExpiration = deps.getFromCacheNoExpiration; - if (deps.saveToCache) saveToCache = deps.saveToCache; - if (deps.shouldUpdateCache24h) shouldUpdateCache24h = deps.shouldUpdateCache24h; -} - -// --------------------------------------------------------------------------- -// Helper: build proxied embed URL -// --------------------------------------------------------------------------- -function proxyEmbedUrl(originalLink) { - const topstreamMatch = originalLink.match(/https?:\/\/(?:www\.)?top-stream\.plus(\/embed\/\d+)/); - if (topstreamMatch && BYPASS403_SERVER_URL) { - return `${BYPASS403_SERVER_URL}/proxy/https://top-stream.plus${topstreamMatch[1]}`; - } - return originalLink; -} - -// --------------------------------------------------------------------------- -// Helper: fetch a page from top-stream -// --------------------------------------------------------------------------- -async function fetchPage(url) { - const response = await axios.get(url, { - headers: topstreamHeaders, - timeout: 15000, - decompress: true - }); - if (response.status === 403) { - console.error('[TOPSTREAM] 403 Forbidden:', url); - } - return response.data; -} - -// --------------------------------------------------------------------------- -// Helper: resolve TMDB ID to title -// --------------------------------------------------------------------------- -async function getTmdbTitle(tmdbId, mediaType) { - const endpoint = mediaType === 'tv' ? 'tv' : 'movie'; - const data = await fetchTmdbDetails(TMDB_API_URL, TMDB_API_KEY, tmdbId, endpoint, 'fr-FR'); - if (!data) return { title: '', originalTitle: '', year: '' }; - const title = data.title || data.name || ''; - const originalTitle = data.original_title || data.original_name || ''; - const year = (data.release_date || data.first_air_date || '').substring(0, 4); - return { title, originalTitle, year }; -} - -// --------------------------------------------------------------------------- -// searchTopStream -- search for movies/series -// --------------------------------------------------------------------------- -async function searchTopStream(query) { - const searchUrl = `${TOPSTREAM_BASE_URL}/search/${encodeURIComponent(query)}`; - const html = await fetchPage(searchUrl); - const $ = cheerio.load(html); - - const results = []; - - $('div.relative.group.overflow-hidden').each((_, el) => { - const card = $(el); - const link = card.find('a').first(); - const href = link.attr('href') || ''; - - let type = 'movie'; - let slug = ''; - if (href.includes('/movie/')) { - type = 'movie'; - slug = href.split('/movie/').pop(); - } else if (href.includes('/tv-show/')) { - type = 'tv'; - slug = href.split('/tv-show/').pop(); - } else { - return; - } - - const img = card.find('img').first(); - const image = img.attr('src') || img.attr('data-src') || ''; - const title = img.attr('alt') || ''; - - const ratingEl = card.find('.absolute.right-3.top-3 span.text-xs'); - const rating = ratingEl.text().trim() || null; - - const infoDiv = card.find('.pt-4'); - const allSpans = infoDiv.find('span'); - - let duration = null; - let year = null; - let genre = null; - let typeLabel = null; - - allSpans.each((_, span) => { - const text = $(span).text().trim(); - if (text.match(/\d+h\d+|min/)) { - duration = text; - } else if (text.match(/^\d{4}$/)) { - year = parseInt(text); - } else if ($(span).hasClass('text-xxs')) { - typeLabel = text; - } else if (!duration && !year && text.length > 0) { - if (!genre) genre = text; - } - }); - - const h3Title = infoDiv.find('h3').text().trim() || title; - - results.push({ - title: h3Title, - slug, - type, - image, - rating: rating ? parseFloat(rating) : null, - year, - duration, - genre, - typeLabel - }); - }); - - return results; -} - -// --------------------------------------------------------------------------- -// getTopStreamEmbed -- extract embed URL from movie or episode page -// --------------------------------------------------------------------------- -async function getTopStreamEmbed(pageUrl) { - const html = await fetchPage(pageUrl); - const $ = cheerio.load(html); - - const videos = []; - let watchComponentFound = false; - let cover = null; - - $('*').each((_, el) => { - if (watchComponentFound) return; - const snapshot = $(el).attr('wire:snapshot'); - if (!snapshot) return; - - try { - const data = JSON.parse(snapshot); - if (data.memo && data.memo.name === 'watch-component') { - watchComponentFound = true; - cover = data.data?.cover || null; - - const videosData = data.data?.videos; - if (Array.isArray(videosData)) { - const extractLinks = (obj) => { - if (!obj) return; - if (Array.isArray(obj)) { - obj.forEach(item => extractLinks(item)); - } else if (typeof obj === 'object' && obj.link) { - videos.push({ - label: obj.label || 'Stream', - type: obj.type || 'mp4', - link: proxyEmbedUrl(obj.link) - }); - } - }; - extractLinks(videosData); - } - } - } catch (err) { - // skip - } - }); - - return { videos, cover }; -} - -// --------------------------------------------------------------------------- -// findAndGetEmbed -- search TopStream by title, find best match, get embed -// --------------------------------------------------------------------------- -async function findAndGetEmbed(tmdbId, mediaType, season, episode) { - const { title, originalTitle, year } = await getTmdbTitle(tmdbId, mediaType); - if (!title && !originalTitle) return null; - - const searchType = mediaType === 'tv' ? 'tv' : 'movie'; - let results = await searchTopStream(title); - let matchResults = results.filter(r => r.type === searchType); - - if (matchResults.length === 0 && originalTitle && originalTitle !== title) { - results = await searchTopStream(originalTitle); - matchResults = results.filter(r => r.type === searchType); - } - - if (matchResults.length === 0) return null; - - let bestMatch = matchResults[0]; - if (year) { - const yearNum = parseInt(year); - const yearMatch = matchResults.find(r => r.year === yearNum); - if (yearMatch) bestMatch = yearMatch; - } - - let pageUrl; - if (mediaType === 'tv') { - pageUrl = `${TOPSTREAM_BASE_URL}/episode/${bestMatch.slug}/${season}-${episode}`; - } else { - pageUrl = `${TOPSTREAM_BASE_URL}/movie/${bestMatch.slug}`; - } - - const embedResult = await getTopStreamEmbed(pageUrl); - return { - ...embedResult, - slug: bestMatch.slug, - matchedTitle: bestMatch.title, - matchedYear: bestMatch.year - }; -} - -// --------------------------------------------------------------------------- -// GET /film/:tmdbId -// --------------------------------------------------------------------------- -router.get('/film/:tmdbId', async (req, res) => { - const { tmdbId } = req.params; - const cacheKey = generateCacheKey(`topstream_film_${tmdbId}`); - const cacheDir = path.join(__dirname, '..', 'cache', 'topstream'); - - try { - await fsp.mkdir(cacheDir, { recursive: true }); - - const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (cachedData) { - res.json(cachedData); - const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey); - if (shouldUpdate) { - (async () => { - try { - const result = await findAndGetEmbed(tmdbId, 'movie'); - if (result) { - await saveToCache(cacheDir, cacheKey, { success: true, tmdbId, type: 'movie', ...result }); - } - } catch (err) { - if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background film update:', tmdbId); - } - })(); - } - return; - } - - const result = await findAndGetEmbed(tmdbId, 'movie'); - - if (!result || result.videos.length === 0) { - const data = { success: false, tmdbId, error: 'Film non trouvé sur TopStream' }; - await saveToCache(cacheDir, cacheKey, data); - return res.status(404).json(data); - } - - const data = { success: true, tmdbId, type: 'movie', ...result }; - await saveToCache(cacheDir, cacheKey, data); - res.json(data); - - } catch (error) { - if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden film:', tmdbId); - else console.error('[TOPSTREAM] Film error:', tmdbId, error.message); - res.status(500).json({ success: false, error: 'Erreur lors de la récupération du film', details: error.message }); - } -}); - -// --------------------------------------------------------------------------- -// GET /tv/:tmdbId?season=X&episode=Y -// --------------------------------------------------------------------------- -router.get('/tv/:tmdbId', async (req, res) => { - const { tmdbId } = req.params; - const season = parseInt(req.query.season); - const episode = parseInt(req.query.episode); - - if (!season || !episode) { - return res.status(400).json({ success: false, error: 'Les paramètres ?season= et ?episode= sont requis.' }); - } - - const cacheKey = generateCacheKey(`topstream_tv_${tmdbId}_${season}_${episode}`); - const cacheDir = path.join(__dirname, '..', 'cache', 'topstream'); - - try { - await fsp.mkdir(cacheDir, { recursive: true }); - - const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (cachedData) { - res.json(cachedData); - const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey); - if (shouldUpdate) { - (async () => { - try { - const result = await findAndGetEmbed(tmdbId, 'tv', season, episode); - if (result) { - await saveToCache(cacheDir, cacheKey, { success: true, tmdbId, type: 'tv', season, episode, ...result }); - } - } catch (err) { - if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background TV update:', tmdbId, 'S' + season + 'E' + episode); - } - })(); - } - return; - } - - const result = await findAndGetEmbed(tmdbId, 'tv', season, episode); - - if (!result || result.videos.length === 0) { - const data = { success: false, tmdbId, season, episode, error: 'Épisode non trouvé sur TopStream' }; - await saveToCache(cacheDir, cacheKey, data); - return res.status(404).json(data); - } - - const data = { success: true, tmdbId, type: 'tv', season, episode, ...result }; - await saveToCache(cacheDir, cacheKey, data); - res.json(data); - - } catch (error) { - if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden TV:', tmdbId, 'S' + season + 'E' + episode); - else console.error('[TOPSTREAM] TV error:', tmdbId, error.message); - res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'épisode', details: error.message }); - } -}); - -// --------------------------------------------------------------------------- -// GET /search?q=... (conservé pour usage direct) -// --------------------------------------------------------------------------- -router.get('/search', async (req, res) => { - const query = req.query.q; - if (!query) { - return res.status(400).json({ success: false, error: 'Le paramètre ?q= est requis.' }); - } - - const cacheKey = generateCacheKey(`topstream_search_${query.toLowerCase().trim()}`); - const cacheDir = path.join(__dirname, '..', 'cache', 'topstream'); - - try { - await fsp.mkdir(cacheDir, { recursive: true }); - - const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (cachedData) { - res.json(cachedData); - const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey); - if (shouldUpdate) { - (async () => { - try { - const results = await searchTopStream(query); - await saveToCache(cacheDir, cacheKey, { success: true, results }); - } catch (err) { - if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background search:', query); - } - })(); - } - return; - } - - const results = await searchTopStream(query); - const data = { success: true, results }; - await saveToCache(cacheDir, cacheKey, data); - res.json(data); - - } catch (error) { - if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden search:', query); - else console.error('[TOPSTREAM] Search error:', query, error.message); - res.status(500).json({ success: false, error: 'Erreur lors de la recherche', details: error.message }); - } -}); - -// --------------------------------------------------------------------------- -// GET /movie/:slug (conservé pour usage direct par slug) -// --------------------------------------------------------------------------- -router.get('/movie/:slug', async (req, res) => { - const { slug } = req.params; - const cacheKey = generateCacheKey(`topstream_movie_${slug}`); - const cacheDir = path.join(__dirname, '..', 'cache', 'topstream'); - - try { - await fsp.mkdir(cacheDir, { recursive: true }); - - const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (cachedData) { - res.json(cachedData); - const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey); - if (shouldUpdate) { - (async () => { - try { - const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/movie/${slug}`); - await saveToCache(cacheDir, cacheKey, { success: true, slug, type: 'movie', ...result }); - } catch (err) { - if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background movie:', slug); - } - })(); - } - return; - } - - const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/movie/${slug}`); - const data = { success: true, slug, type: 'movie', ...result }; - await saveToCache(cacheDir, cacheKey, data); - res.json(data); - - } catch (error) { - if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden movie:', slug); - else console.error('[TOPSTREAM] Movie error:', slug, error.message); - res.status(500).json({ success: false, error: 'Erreur lors de la récupération du film', details: error.message }); - } -}); - -// --------------------------------------------------------------------------- -// GET /episode/:slug/:season-:episode (conservé pour usage direct par slug) -// --------------------------------------------------------------------------- -router.get('/episode/:slug/:seasonEpisode', async (req, res) => { - const { slug, seasonEpisode } = req.params; - - const match = seasonEpisode.match(/^(\d+)-(\d+)$/); - if (!match) { - return res.status(400).json({ success: false, error: 'Format invalide. Utilisez :season-:episode (ex: 5-1).' }); - } - - const season = parseInt(match[1]); - const episode = parseInt(match[2]); - const cacheKey = generateCacheKey(`topstream_episode_${slug}_${season}_${episode}`); - const cacheDir = path.join(__dirname, '..', 'cache', 'topstream'); - - try { - await fsp.mkdir(cacheDir, { recursive: true }); - - const cachedData = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (cachedData) { - res.json(cachedData); - const shouldUpdate = await shouldUpdateCache24h(cacheDir, cacheKey); - if (shouldUpdate) { - (async () => { - try { - const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/episode/${slug}/${season}-${episode}`); - await saveToCache(cacheDir, cacheKey, { success: true, slug, type: 'tv', season, episode, ...result }); - } catch (err) { - if (err.response?.status === 403) console.error('[TOPSTREAM] 403 background episode:', slug, 'S' + season + 'E' + episode); - } - })(); - } - return; - } - - const result = await getTopStreamEmbed(`${TOPSTREAM_BASE_URL}/episode/${slug}/${season}-${episode}`); - const data = { success: true, slug, type: 'tv', season, episode, ...result }; - await saveToCache(cacheDir, cacheKey, data); - res.json(data); - - } catch (error) { - if (error.response?.status === 403) console.error('[TOPSTREAM] 403 Forbidden episode:', slug, 'S' + season + 'E' + episode); - else console.error('[TOPSTREAM] Episode error:', slug, error.message); - res.status(500).json({ success: false, error: 'Erreur lors de la récupération de l\'épisode', details: error.message }); - } -}); - -module.exports = router; -module.exports.configure = configure; diff --git a/API/Mainapi/routes/wiflix.js b/API/Mainapi/routes/wiflix.js index 4e198b2..dc6df10 100644 --- a/API/Mainapi/routes/wiflix.js +++ b/API/Mainapi/routes/wiflix.js @@ -18,17 +18,21 @@ const { } = require("../utils/cacheManager"); const { makeWiflixRequest, + makeWiflixSearchRequest, } = require("../utils/proxyManager"); const { acquireRedisLock } = require("../utils/redisLock"); const { fetchTmdbDetails, fetchTmdbSeason, - fetchTmdbFrenchReleaseYear, } = require("../utils/tmdbCache"); +// Movies are scraped from cinestream.info (flemmix's bot-shield is painful for +// films); TV stays on flemmix below. Same return shape, so the route + cache +// here consume it unchanged. +const { fetchCinestreamMovieData } = require("./cinestream"); const TMDB_API_KEY = process.env.TMDB_API_KEY || ""; const TMDB_API_URL = "https://api.themoviedb.org/3"; -const WIFLIX_BASE_URL = "https://flemmix.farm"; +const WIFLIX_BASE_URL = "https://flemmix.golf"; // === Cache helpers (local, since getFromCacheNoExpiration is not yet in cacheManager) === const getFromCacheNoExpiration = async (cacheDir, key) => { @@ -143,8 +147,9 @@ async function searchWiflixMovie(title, baseUrl = WIFLIX_BASE_URL) { let responseBody = null; try { - const res = await makeWiflixRequest(searchUrl, { - method: 'POST', + // Cookie handshake: GET homepage to harvest the bot-shield session cookie, + // then POST the search with it + h_check=25 on the same proxy. + const res = await makeWiflixSearchRequest(baseUrl + "/", searchUrl, { data: payload, headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -165,7 +170,10 @@ async function searchWiflixMovie(title, baseUrl = WIFLIX_BASE_URL) { } const $ = cheerio.load(responseBody); - const movBlocks = $("#dle-content div.mov"); + // Template has no #dle-content wrapper. Real results are div.mov; the + // "Populaires" reco block (#no-results-rec) is hidden when results exist — + // exclude it exactly like the site's own JS result counter does. + const movBlocks = $("div.mov").not("#no-results-rec .mov"); if (movBlocks.length === 0) return { url: null, debugHtml: responseBody }; let bestMatch = null; @@ -352,9 +360,10 @@ async function extractWiflixPlayers(pageUrl) { $episodeDiv.find("a[onclick]").each((linkIndex, linkElement) => { const $link = $(linkElement); const onclick = $link.attr("onclick"); - const $span = $link.find("span.clichost"); + // Template dropped the .clichost class (like the movie page) — match any span. + const $span = $link.find("span"); if (onclick && $span.length) { - const match = onclick.match(/loadVideo\('(.+?)'\)/); + const match = onclick.match(/loadVideo\('([^']+)'/); if (match && match[1]) { let processedUrl = match[1]; const name = $span.text().trim(); @@ -395,7 +404,7 @@ async function extractWiflixPlayers(pageUrl) { const onclick = $link.attr("onclick"); const $span = $link.find("span"); if (onclick && $span.length) { - const match = onclick.match(/loadVideo\('(.+?)'\)/); + const match = onclick.match(/loadVideo\('([^']+)'/); if (match && match[1]) { let processedUrl = match[1]; const name = $span.text().trim(); @@ -441,129 +450,9 @@ async function extractWiflixPlayers(pageUrl) { } } -function categorizeWiflixPlayers(players) { - const vf = []; - const vostfr = []; - players.forEach((player) => { - if (player.type === "VOSTFR") vostfr.push(player); - else vf.push(player); - }); - return { vf, vostfr }; -} - // === Data Fetching === -async function fetchWiflixMovieData(tmdbId, cachedData = null) { - try { - const tmdbData = await fetchTmdbDetails( - TMDB_API_URL, - TMDB_API_KEY, - tmdbId, - "movie", - "fr-FR", - ); - - if (!tmdbData) { - if (cachedData) return cachedData; - return { - success: false, - error: "Film non trouve sur TMDB", - tmdb_id: tmdbId, - }; - } - - const originalData = tmdbData; - const titlesToTry = [ - tmdbData.title, - originalData.original_title, - originalData.title, - ] - .filter(Boolean) - .filter((title, index, arr) => arr.indexOf(title) === index); - - let movieUrl = null; - let searchDebugHtml = null; - for (const title of titlesToTry) { - const searchResult = await searchWiflixMovie(title); - if (searchResult.url) { movieUrl = searchResult.url; break; } - searchDebugHtml = searchResult.debugHtml; - } - - if (!movieUrl) - return { - success: false, - error: "Film non trouve sur Wiflix", - tmdb_id: tmdbId, - titles_tried: titlesToTry, - debugHtml: searchDebugHtml, - }; - - const extractionResult = await extractWiflixPlayers(movieUrl); - const players = extractionResult.players; - const wiflixReleaseYear = extractionResult.releaseYear; - - if (players.length === 0) - return { - success: false, - error: "Aucun lecteur video trouve", - tmdb_id: tmdbId, - wiflix_url: movieUrl, - debugHtml: extractionResult.debugHtml, - }; - - if (wiflixReleaseYear) { - const frReleaseYear = await fetchTmdbFrenchReleaseYear( - TMDB_API_URL, - TMDB_API_KEY, - tmdbId, - ); - const tmdbReleaseYear = - frReleaseYear || - (tmdbData.release_date - ? new Date(tmdbData.release_date).getFullYear() - : null); - if (tmdbReleaseYear && wiflixReleaseYear !== tmdbReleaseYear) { - console.log( - `[WIFLIX MOVIE] Date mismatch: TMDB ${tmdbReleaseYear}${frReleaseYear ? " (FR)" : ""} vs Wiflix ${wiflixReleaseYear} pour ${tmdbData.title}`, - ); - return { - success: false, - error: "Film non disponible sur Wiflix (date de sortie differente)", - tmdb_id: tmdbId, - wiflix_url: movieUrl, - tmdb_release_year: tmdbReleaseYear, - wiflix_release_year: wiflixReleaseYear, - }; - } - } - - const categorizedPlayers = categorizeWiflixPlayers(players); - return { - success: true, - tmdb_id: tmdbId, - title: tmdbData.title, - original_title: originalData.original_title, - wiflix_url: movieUrl, - players: { vf: categorizedPlayers.vf, vostfr: categorizedPlayers.vostfr }, - cache_timestamp: new Date().toISOString(), - }; - } catch (error) { - console.error( - `[WIFLIX MOVIE] Erreur dans fetchWiflixMovieData: ${error.message}`, - ); - if (cachedData) { - console.log( - `[WIFLIX] Cache preserve malgre l'erreur pour movie ${tmdbId}`, - ); - return cachedData; - } - return { - success: false, - error: "Erreur lors de la recuperation des donnees Wiflix", - message: error.message, - tmdb_id: tmdbId, - }; - } -} +// Movie data now comes from cinestream.info (see ./cinestream + updateWiflixCache). +// flemmix is still scraped for TV below. async function fetchWiflixTvData(tmdbId, season, cachedData = null) { try { @@ -701,7 +590,7 @@ const updateWiflixCache = async ( let newData; if (type === "movie") - newData = await fetchWiflixMovieData(tmdbId, existingCache); + newData = await fetchCinestreamMovieData(tmdbId, existingCache); else if (type === "tv") newData = await fetchWiflixTvData(tmdbId, season, existingCache); else throw new Error(`Type non supporte: ${type}`); diff --git a/API/Mainapi/server.js b/API/Mainapi/server.js index c08e4c0..e7efbc5 100644 --- a/API/Mainapi/server.js +++ b/API/Mainapi/server.js @@ -90,6 +90,61 @@ if (cluster.isPrimary ?? cluster.isMaster) { ╚═══════════════════════════════════════════════════════╝ `); + // === WORKER RECYCLE (anti-leak mitigation) ================================ + // Root cause of the multi-day RSS climb is not yet confirmed. Recycling each + // worker one-by-one on a long interval caps how far any slow leak can grow + // before that worker's memory is reclaimed by a fresh fork. Each worker gets + // the same graceful 'shutdown' message the SIGTERM path sends, so in-flight + // requests drain via server.close() first; the cluster.on('exit') handler + // above then forks the replacement. + // + // Tunables (env): + // WORKER_RECYCLE_INTERVAL_MS default 12h — set 0 to disable entirely + // WORKER_RECYCLE_STAGGER_MS default 5min — gap between each worker so + // capacity never drops hard + const WORKER_RECYCLE_INTERVAL_MS = (() => { + const raw = process.env.WORKER_RECYCLE_INTERVAL_MS; + if (raw === undefined || raw === '') return 12 * 60 * 60 * 1000; + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 12 * 60 * 60 * 1000; + })(); + const WORKER_RECYCLE_STAGGER_MS = + parseInt(process.env.WORKER_RECYCLE_STAGGER_MS, 10) || (5 * 60 * 1000); + + if (WORKER_RECYCLE_INTERVAL_MS > 0) { + console.log( + `🔁 Worker recycle: every ${Math.round(WORKER_RECYCLE_INTERVAL_MS / 3600000)}h, ` + + `stagger ${Math.round(WORKER_RECYCLE_STAGGER_MS / 60000)}min`, + ); + const recycleTimer = setInterval(() => { + if (isShuttingDown) return; + const workers = Object.values(cluster.workers || {}); + if (workers.length === 0) return; + console.log(`🔁 Worker recycle cycle starting — ${workers.length} workers`); + workers.forEach((worker, idx) => { + setTimeout(() => { + if (isShuttingDown || worker.isDead()) return; + try { + console.log(`🔁 Recycling worker pid=${worker.process.pid}`); + worker.send('shutdown'); + // Force-kill fallback if the worker's graceful 15s server.close + + // resource cleanup hasn't exited it within 35s. + setTimeout(() => { + try { + if (!worker.isDead()) worker.kill('SIGKILL'); + } catch (_) { /* ignore */ } + }, 35000); + } catch (e) { + console.warn(`🔁 Recycle worker ${worker.process?.pid} failed: ${e.message}`); + } + }, idx * WORKER_RECYCLE_STAGGER_MS); + }); + }, WORKER_RECYCLE_INTERVAL_MS); + recycleTimer.unref(); + } else { + console.log('🔁 Worker recycle disabled (WORKER_RECYCLE_INTERVAL_MS=0)'); + } + // Le master ne fait RIEN d'autre — pas de require express, mysql, redis, etc. return; } @@ -101,6 +156,9 @@ if (cluster.isPrimary ?? cluster.isMaster) { process.env.UV_THREADPOOL_SIZE = 8; // 8 threads libuv par worker (6 workers x 8 = 48 threads total) const http = require('http'); +const https = require('https'); +const v8 = require('v8'); +const path = require('path'); const { app, appReady } = require('./app'); const { redis } = require('./config/redis'); const { shutdownCycleTLS, refreshProxyScrapeProxies } = require('./utils/proxyManager'); @@ -108,6 +166,70 @@ const { getPool } = require('./mysqlPool'); const PORT = 25565; +// =========================================================================== +// === MEMORY DIAGNOSTICS (worker) =========================================== +// =========================================================================== +// The cluster RSS climbs continuously over multi-day uptime with no confirmed +// root cause yet. These hooks gather the evidence: +// - Periodic [memstats] line — feed it to a graph to see the leak slope and +// which segment (heap vs external/arrayBuffers vs sockets) is growing. +// - SIGUSR2 → v8 heap snapshot on disk — open in Chrome DevTools, compare two +// snapshots taken hours apart to find the retained object class. +// Trigger: kill -USR2 <worker-pid> +// +// Both are read-only and effectively free; safe to keep in production. +const MEMORY_LOG_INTERVAL_MS = parseInt(process.env.MEMORY_LOG_INTERVAL_MS, 10) || (5 * 60 * 1000); +const HEAPDUMP_DIR = process.env.HEAPDUMP_DIR || os.tmpdir(); + +function fmtMB(n) { + return `${(Number(n || 0) / 1024 / 1024).toFixed(1)}MB`; +} + +// http.globalAgent.sockets is keyed by `host:port` — its key count is the +// number of distinct upstream origins currently holding live sockets. A +// climbing count points at keep-alive socket pool churn. +function countAgentHosts(agent, prop) { + try { + return agent && agent[prop] ? Object.keys(agent[prop]).length : 0; + } catch (_) { + return 0; + } +} + +function logMemoryUsage() { + const m = process.memoryUsage(); + console.log( + `[memstats] pid=${process.pid} uptime=${Math.round(process.uptime())}s ` + + `rss=${fmtMB(m.rss)} heapTotal=${fmtMB(m.heapTotal)} heapUsed=${fmtMB(m.heapUsed)} ` + + `external=${fmtMB(m.external)} arrayBuffers=${fmtMB(m.arrayBuffers)} ` + + `httpHosts=${countAgentHosts(http.globalAgent, 'sockets')}/` + + `${countAgentHosts(http.globalAgent, 'freeSockets')} ` + + `httpsHosts=${countAgentHosts(https.globalAgent, 'sockets')}/` + + `${countAgentHosts(https.globalAgent, 'freeSockets')}`, + ); +} + +const memoryLogTimer = setInterval(logMemoryUsage, MEMORY_LOG_INTERVAL_MS); +memoryLogTimer.unref(); + +// SIGUSR2 — write a heap snapshot. nodemon also uses SIGUSR2, but production +// runs node directly so there is no conflict here. +process.on('SIGUSR2', () => { + const snapshotPath = path.join( + HEAPDUMP_DIR, + `heap-${process.pid}-${Date.now()}.heapsnapshot`, + ); + try { + const start = Date.now(); + v8.writeHeapSnapshot(snapshotPath); + console.warn( + `[heapdump] pid=${process.pid} written ${snapshotPath} in ${Date.now() - start}ms`, + ); + } catch (e) { + console.error(`[heapdump] pid=${process.pid} failed: ${e.message}`); + } +}); + // --------------------------------------------------------------------------- // startServer — create HTTP server with retry logic // --------------------------------------------------------------------------- @@ -142,7 +264,6 @@ const startServer = async (retries = 3) => { // Backlog increased to 4096 to handle burst connections server.listen(PORT, '0.0.0.0', 4096, () => { console.log(`Serveur démarré sur le port ${PORT} - Process ${process.pid}`); - console.log(`Proxy service available at http://localhost:${PORT}/proxy/`); console.log(`Keep-Alive configuré: timeout=${server.keepAliveTimeout}ms, max=1000`); console.log(`Performance tuning: UV_THREADPOOL_SIZE=${process.env.UV_THREADPOOL_SIZE}, RequestTimeout=${server.requestTimeout}ms`); }); @@ -167,6 +288,7 @@ let activeServer = null; startServer().then((server) => { activeServer = server; console.log(`✅ Worker ${process.pid} - Serveur démarré sur le port ${PORT}`); + logMemoryUsage(); // baseline [memstats] line at boot }).catch((error) => { console.error(`❌ Worker ${process.pid} - Échec du démarrage:`, error); process.exit(1); diff --git a/API/Mainapi/sharedListsRoutes.js b/API/Mainapi/sharedListsRoutes.js index cd832d3..da6704c 100644 --- a/API/Mainapi/sharedListsRoutes.js +++ b/API/Mainapi/sharedListsRoutes.js @@ -29,14 +29,14 @@ function getCachedPool() { const SHARED_LIST_CACHE_TTL = 600; // 10 minutes const USERS_DIR = path.join(__dirname, 'data', 'users'); -// === OpenRouter API Configuration for content moderation (using Gemini 2.5 Flash Lite) === +// === OpenRouter API Configuration for content moderation (using DeepSeek V4 Flash) === const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; -const OPENROUTER_MODEL = 'google/gemini-2.5-flash-lite'; +const OPENROUTER_MODEL = 'deepseek/deepseek-v4-flash'; const FRONTEND_BASE_URL = process.env.FRONTEND_BASE_URL; -// Fonction de modération des listes partagées avec OpenRouter/Gemini (exécutée en background) -async function moderateSharedListWithGemini(shareId, listName, items, username) { +// Fonction de modération des listes partagées avec OpenRouter (exécutée en background) +async function moderateSharedListWithAI(shareId, listName, items, username) { try { // Construire un résumé des items de la liste const itemsSummary = (items || []).slice(0, 30).map(i => `${i.title || i.name || 'Sans titre'} (${i.type || 'inconnu'})`).join(', '); @@ -449,7 +449,7 @@ router.post('/share', requireAuth, async (req, res) => { [isPublicInCatalog ? 1 : 0, now, userId, userType, profileId, String(listId)] ); - // Si on publie au catalogue, reset le flag de modération et lancer modération Gemini en background + // Si on publie au catalogue, reset le flag de modération et lancer modération IA en background if (isPublicInCatalog) { await dbRun( 'UPDATE shared_lists SET moderation_flagged = 0, moderation_reason = NULL, moderation_details = NULL, moderated_at = NULL WHERE user_id = ? AND user_type = ? AND profile_id = ? AND list_id = ?', @@ -460,8 +460,8 @@ router.post('/share', requireAuth, async (req, res) => { [userId, userType, profileId, String(listId)] ); const profileData = await getUserProfileData(userId, userType, profileId); - moderateSharedListWithGemini(existingRecord.id, list.name, list.items || [], profileData.username) - .catch(err => console.error('Erreur modération Gemini liste (non bloquant):', err)); + moderateSharedListWithAI(existingRecord.id, list.name, list.items || [], profileData.username) + .catch(err => console.error('Erreur modération IA liste (non bloquant):', err)); } // Rafraîchir le cache et retourner le code existant @@ -494,15 +494,15 @@ router.post('/share', requireAuth, async (req, res) => { // Construire et mettre en cache la liste await buildAndCacheSharedList(shareCode, userId, userType, profileId, listId, isPublicInCatalog); - // Si publié au catalogue, lancer modération Gemini en background + // Si publié au catalogue, lancer modération IA en background if (isPublicInCatalog) { const insertedRecord = await dbGet( 'SELECT id FROM shared_lists WHERE share_code = ?', [shareCode] ); const profileData = await getUserProfileData(userId, userType, profileId); - moderateSharedListWithGemini(insertedRecord.id, list.name, list.items || [], profileData.username) - .catch(err => console.error('Erreur modération Gemini liste (non bloquant):', err)); + moderateSharedListWithAI(insertedRecord.id, list.name, list.items || [], profileData.username) + .catch(err => console.error('Erreur modération IA liste (non bloquant):', err)); } res.json({ shareCode, isPublicInCatalog }); @@ -802,7 +802,7 @@ router.delete('/admin/public/:id', requireAdmin, async (req, res) => { } }); -// GET /admin/moderated — Lister les listes flaggées par Gemini (admin uniquement) +// GET /admin/moderated — Lister les listes flaggées par l'IA (admin uniquement) router.get('/admin/moderated', requireAdmin, async (req, res) => { try { const { page = 1, limit = 50 } = req.query; diff --git a/API/Mainapi/utils/adminIdentity.js b/API/Mainapi/utils/adminIdentity.js index 556b931..4519939 100644 --- a/API/Mainapi/utils/adminIdentity.js +++ b/API/Mainapi/utils/adminIdentity.js @@ -15,6 +15,7 @@ */ const { readUserData } = require('../routes/sync'); +const { truncateDisplayName } = require('./syncPolicy'); const DEFAULT = Object.freeze({ username: 'Admin', avatar: null }); @@ -26,11 +27,17 @@ function safeParseJson(raw) { /** * @param {string} userId * @param {string} authType — 'oauth', 'bip39' ou 'bip-39' (DB legacy) + * @param {{ preferProfile?: boolean }} [options] + * preferProfile: utiliser le PREMIER profil Movix (profiles[0]) pour le nom + + * avatar avant l'identité OAuth. Utilisé par le leaderboard Greenlight et la + * gestion d'équipe, qui veulent le profil Movix de l'utilisateur, pas le + * pseudo Discord/Google. * @returns {Promise<{ username: string, avatar: string | null }>} */ -async function resolveAdminIdentity(userId, authType) { +async function resolveAdminIdentity(userId, authType, options = {}) { if (!userId) return { ...DEFAULT }; + const preferProfile = options.preferProfile === true; const userType = authType === 'bip-39' || authType === 'bip39' ? 'bip39' : 'oauth'; let data; @@ -42,21 +49,34 @@ async function resolveAdminIdentity(userId, authType) { if (!data || typeof data !== 'object') return { ...DEFAULT }; + const profiles = Array.isArray(data.profiles) ? data.profiles : []; + const firstProfile = profiles[0]; + + // Greenlight / team management : le PREMIER profil Movix prime sur l'identité + // OAuth. (Le reste de la fonction garde l'ordre OAuth-d'abord par défaut.) + if (preferProfile && firstProfile?.name) { + return { + username: truncateDisplayName(firstProfile.name) || DEFAULT.username, + avatar: firstProfile.avatar ? String(firstProfile.avatar) : null, + }; + } + // 1) OAuth : nom + avatar du provider (Discord/Google). + // Tronque le username pour éviter qu'un pseudo trop long casse l'UI + // leaderboard (cas des données legacy stockées avant la limite serveur). const auth = safeParseJson(data.auth); if (auth?.userProfile?.username) { return { - username: String(auth.userProfile.username), + username: truncateDisplayName(auth.userProfile.username) || DEFAULT.username, avatar: auth.userProfile.avatar ? String(auth.userProfile.avatar) : null, }; } // 2) BIP-39 ou OAuth sans `auth.userProfile` : profil Movix par défaut. - const profiles = Array.isArray(data.profiles) ? data.profiles : []; - const defaultProfile = profiles.find((p) => p && p.isDefault) || profiles[0]; + const defaultProfile = profiles.find((p) => p && p.isDefault) || firstProfile; if (defaultProfile?.name) { return { - username: String(defaultProfile.name), + username: truncateDisplayName(defaultProfile.name) || DEFAULT.username, avatar: defaultProfile.avatar ? String(defaultProfile.avatar) : null, }; } diff --git a/API/Mainapi/utils/axiosHelpers.js b/API/Mainapi/utils/axiosHelpers.js index 195305f..841b7c4 100644 --- a/API/Mainapi/utils/axiosHelpers.js +++ b/API/Mainapi/utils/axiosHelpers.js @@ -20,7 +20,6 @@ const { ENABLE_FSTREAM_PROXY, ENABLE_ANIME_PROXY, ENABLE_WIFLIX_PROXY, - DARKINO_403_COOLDOWN_MS, DARKINO_PROXIES, PROXIES, HTTP_PROXIES, @@ -56,7 +55,7 @@ let deps = { incrementFstreamRequestCounter: () => {} }; -const WIFLIX_BASE_URL = 'https://flemmix.farm'; +const WIFLIX_BASE_URL = 'https://flemmix.golf'; const truncateForLog = (value, maxLength = 300) => { if (typeof value !== 'string') return value; @@ -92,8 +91,7 @@ function configure(injected) { Object.assign(deps, injected); } -// === Proxy manager re-exports for convenience (used by axiosDarkinoRequest) === -// We need mutable access to darkino403CooldownUntil via the proxyManager module +// === Proxy manager handle for Redis-backed Darkino cooldowns === const proxyManager = require('./proxyManager'); // Fonction utilitaire pour g\u00e9n\u00e9rer le referer dynamiquement @@ -241,16 +239,49 @@ async function buildDarkinoRequestHeaders(config) { // Fonction utilitaire pour requ\u00eates Darkino avec proxies (SOCKS5h) async function axiosDarkinoRequest(config) { - // V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 403 - if (Date.now() < proxyManager.darkino403CooldownUntil) { - const remainingMs = proxyManager.darkino403CooldownUntil - Date.now(); - const remainingMin = Math.ceil(remainingMs / 60000); + // V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 403 (cluster-wide via Redis) + const cd403Ms = await proxyManager.getDarkinoCooldownRemainingMs('403'); + if (cd403Ms > 0) { + const remainingMin = Math.ceil(cd403Ms / 60000); const error = new Error(`Darkino en cooldown (403 Cloudflare). R\u00e9essayez dans ${remainingMin} minute(s).`); error.isDarkinoCooldown = true; error.response = { status: 403 }; throw error; } + // V\u00e9rifier si on est en cooldown apr\u00e8s une erreur 429 (rate limit / Cloudflare Workers daily limit) + const cd429Ms = await proxyManager.getDarkinoCooldownRemainingMs('429'); + if (cd429Ms > 0) { + const remainingMin = Math.ceil(cd429Ms / 60000); + const error = new Error(`Darkino en cooldown (rate limit 429). R\u00e9essayez dans ${remainingMin} minute(s).`); + error.isDarkinoCooldown = true; + error.response = { status: 429 }; + throw error; + } + + // V\u00e9rifier si on est en cooldown apr\u00e8s une erreur serveur (5xx) + const cd5xxMs = await proxyManager.getDarkinoCooldownRemainingMs('5xx'); + if (cd5xxMs > 0) { + const remainingMin = Math.ceil(cd5xxMs / 60000); + const error = new Error(`Darkino en cooldown (erreur serveur). R\u00e9essayez dans ${remainingMin} minute(s).`); + error.isDarkinoCooldown = true; + error.response = { status: 503 }; + throw error; + } + + // Cooldown apres trop de timeouts / erreurs reseau consecutifs (cluster-wide). + // Les cooldowns 403/429/5xx ne se declenchent que sur une reponse HTTP : un + // timeout (ECONNABORTED) n'a pas de error.response donc rien ne s'armait, + // l'upstream mort etait martele en boucle (RSS qui monte sans redescendre). + const cdNeterrMs = await proxyManager.getDarkinoCooldownRemainingMs('neterr'); + if (cdNeterrMs > 0) { + const remainingMin = Math.ceil(cdNeterrMs / 60000); + const error = new Error(`Darkino en cooldown (trop de timeouts). Réessayez dans ${remainingMin} minute(s).`); + error.isDarkinoCooldown = true; + error.response = { status: 504 }; + throw error; + } + const requestUrl = `${DARKIWORLD_BASE_URL}${config.url}`; const darkinoRequestHeaders = await buildDarkinoRequestHeaders(config); @@ -287,10 +318,17 @@ async function axiosDarkinoRequest(config) { const setCookieHeader = error.response.headers['set-cookie']; await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL))); } - // En cas d'erreur 403, activer le cooldown - if (error.response?.status === 403) { - proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS; - console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9 pour 5 minutes`); + // En cas d'erreur 403, 429 ou 5xx, activer le cooldown (cluster-wide) + const directStatus = error.response?.status; + if (directStatus === 403) { + await proxyManager.armDarkinoCooldown('403'); + console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`); + } else if (directStatus === 429) { + await proxyManager.armDarkinoCooldown('429'); + console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`); + } else if (directStatus >= 500 && directStatus < 600) { + await proxyManager.armDarkinoCooldown('5xx'); + console.log(`[DARKINO] Erreur ${directStatus} d\u00e9tect\u00e9e (direct) - Cooldown activ\u00e9`); } throw error; } @@ -330,9 +368,16 @@ async function axiosDarkinoRequest(config) { const setCookieHeader = error.response.headers['set-cookie']; await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL))); } - if (error.response?.status === 403) { - proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS; - console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9 pour 5 minutes`); + const noProxyStatus = error.response?.status; + if (noProxyStatus === 403) { + await proxyManager.armDarkinoCooldown('403'); + console.log(`[DARKINO] Erreur 403 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`); + } else if (noProxyStatus === 429) { + await proxyManager.armDarkinoCooldown('429'); + console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`); + } else if (noProxyStatus >= 500 && noProxyStatus < 600) { + await proxyManager.armDarkinoCooldown('5xx'); + console.log(`[DARKINO] Erreur ${noProxyStatus} d\u00e9tect\u00e9e (direct sans proxy) - Cooldown activ\u00e9`); } throw error; } @@ -364,6 +409,8 @@ async function axiosDarkinoRequest(config) { if (setCookieHeader && deps.cookieJar) { await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL))); } + // Requete reussie : on n'est plus en serie d'echecs reseau. + proxyManager.resetDarkinoNetFailures(); return response; } catch (error) { lastError = error; @@ -381,21 +428,37 @@ async function axiosDarkinoRequest(config) { await Promise.all(setCookieHeader.map(cookie => deps.cookieJar.setCookie(cookie, DARKIWORLD_BASE_URL))); } - // En cas d'erreur 429 (Too Many Requests), essayer avec le prochain proxy si disponible - if (statusCode === 429) { - continue; - } - - // En cas d'erreur 403 (Cloudflare challenge), activer le cooldown de 5 minutes + // En cas d'erreur 403 (Cloudflare challenge), activer le cooldown (cluster-wide) if (statusCode === 403) { - proxyManager.darkino403CooldownUntil = Date.now() + DARKINO_403_COOLDOWN_MS; + await proxyManager.armDarkinoCooldown('403'); throw error; } - // Pour les autres erreurs (400, 500, etc.), arr\u00eater imm\u00e9diatement - if (statusCode && statusCode !== 429) { + // En cas d'erreur 429 (Too Many Requests / Cloudflare Workers daily limit), + // activer le cooldown 429 (cluster-wide) \u2014 inutile de spammer un autre proxy + // si l'upstream hydracker lui-m\u00eame rate-limit globalement. + if (statusCode === 429) { + await proxyManager.armDarkinoCooldown('429'); + console.log(`[DARKINO] Erreur 429 d\u00e9tect\u00e9e (proxy) - Cooldown activ\u00e9`); throw error; } + + // En cas d'erreur serveur (5xx), activer le cooldown 5xx (cluster-wide) + if (statusCode >= 500 && statusCode < 600) { + await proxyManager.armDarkinoCooldown('5xx'); + console.log(`[DARKINO] Erreur ${statusCode} d\u00e9tect\u00e9e (proxy) - Cooldown activ\u00e9`); + throw error; + } + + // Pour les autres erreurs (400, etc.), arr\u00eater imm\u00e9diatement + if (statusCode) { + throw error; + } + + // Pas de statusCode = erreur reseau sans reponse HTTP (timeout + // ECONNABORTED, ECONNRESET, ...). Compte l'echec pour le circuit + // breaker cluster-wide : au seuil, le cooldown 'neterr' est arme. + await proxyManager.recordDarkinoNetFailure(); } } diff --git a/API/Mainapi/utils/cacheManager.js b/API/Mainapi/utils/cacheManager.js index d17a172..f1fbf55 100644 --- a/API/Mainapi/utils/cacheManager.js +++ b/API/Mainapi/utils/cacheManager.js @@ -21,7 +21,8 @@ const CACHE_DIR = { TVDIRECT: path.join(__dirname, '..', 'cache', 'tvdirect'), PURSTREAM: path.join(__dirname, '..', 'cache', 'purstream'), NOCTAFLIX: path.join(__dirname, '..', 'cache', 'noctaflix'), - FTV: path.join(__dirname, '..', 'cache', 'ftv') + FTV: path.join(__dirname, '..', 'cache', 'ftv'), + J1F: path.join(__dirname, '..', 'cache', '1jour1film') }; const DEFAULT_CACHE_REFRESH_WINDOW_MS = 40 * 60 * 1000; diff --git a/API/Mainapi/utils/cloudflareSession.js b/API/Mainapi/utils/cloudflareSession.js deleted file mode 100644 index 95fca45..0000000 --- a/API/Mainapi/utils/cloudflareSession.js +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Cloudflare Session Manager for Wiflix — FlareSolverr edition. - * - * Uses an external FlareSolverr instance to solve Cloudflare challenges. - * Stores the session (cookies, User-Agent) in Redis so every cluster - * worker can reuse it. Only one worker triggers a solve at a time - * (Redis distributed lock). - * - * FlareSolverr manages its own browser — no Puppeteer/Chromium needed here. - * - * Env: FLARESOLVERR_URL (default http://localhost:8191) - */ - -const axios = require('axios'); -const { redis } = require('../config/redis'); -const { acquireRedisLock } = require('./redisLock'); - -// Config -const FLARESOLVERR_URL = (process.env.FLARESOLVERR_URL || 'http://localhost:8191').replace(/\/+$/, ''); -const SESSION_NAME = 'movix_wiflix'; -const SESSION_TTL_MS = 13 * 60 * 1000; // 13 min -const SESSION_REDIS_TTL_S = 15 * 60; // 15 min in Redis (margin) -const FLARESOLVERR_TIMEOUT = 60000; // 60s for challenge solving -const REDIS_SESSION_KEY = 'cf_session:wiflix:v2'; -const REDIS_LOCK_KEY = 'cf_session:wiflix:solve_lock'; - -// Proxy — first SOCKS5 proxy from env -function getProxyConfig() { - try { - const proxies = JSON.parse(process.env.SOCKS5_PROXIES || '[]'); - if (proxies.length > 0) { - const p = proxies[0]; - const url = `socks5://${p.auth}@${p.host}:${p.port}`; - return { url }; - } - } catch {} - return null; -} -const PROXY = getProxyConfig(); - -// ─── FlareSolverr API helpers ──────────────────────────────────────────────── - -async function flaresolverrPost(body) { - const res = await axios.post(`${FLARESOLVERR_URL}/v1`, body, { - timeout: FLARESOLVERR_TIMEOUT, - headers: { 'Content-Type': 'application/json' }, - }); - return res.data; -} - -async function createSession() { - try { - const body = { cmd: 'sessions.create', session: SESSION_NAME }; - if (PROXY) body.proxy = PROXY; - await flaresolverrPost(body); - console.log(`[CF SESSION] Session FlareSolverr "${SESSION_NAME}" creee${PROXY ? ` (proxy: ${PROXY.url})` : ''}`); - } catch (err) { - // Session might already exist — ignore 500 "already exists" - if (err.response?.data?.message?.includes('already exists')) { - console.log(`[CF SESSION] Session FlareSolverr "${SESSION_NAME}" existe deja`); - return; - } - throw err; - } -} - -async function destroySession() { - try { - await flaresolverrPost({ cmd: 'sessions.destroy', session: SESSION_NAME }); - } catch { /* ignore */ } -} - -// ─── Redis session store ───────────────────────────────────────────────────── - -async function loadSessionFromRedis() { - try { - const raw = await redis.get(REDIS_SESSION_KEY); - if (!raw) return null; - const session = JSON.parse(raw); - if (!session.cookies || !session.userAgent || !session.solvedAt) return null; - if ((Date.now() - session.solvedAt) >= SESSION_TTL_MS) return null; - return session; - } catch { - return null; - } -} - -async function saveSessionToRedis(session) { - try { - await redis.set(REDIS_SESSION_KEY, JSON.stringify(session), 'EX', SESSION_REDIS_TTL_S); - } catch (err) { - console.warn('[CF SESSION] Erreur sauvegarde Redis:', err.message); - } -} - -async function deleteSessionFromRedis() { - try { await redis.del(REDIS_SESSION_KEY); } catch {} -} - -// ─── Challenge solver ──────────────────────────────────────────────────────── - -async function _doSolve(targetUrl) { - console.log(`[CF SESSION] Resolution du challenge via FlareSolverr pour ${targetUrl}...`); - - // Recreate session to get a fresh browser state - await destroySession(); - await createSession(); - - const requestBody = { - cmd: 'request.get', - url: targetUrl, - session: SESSION_NAME, - maxTimeout: FLARESOLVERR_TIMEOUT, - }; - if (PROXY) requestBody.proxy = PROXY; - const result = await flaresolverrPost(requestBody); - - const solution = result.solution; - if (!solution || solution.status >= 400) { - throw new Error(`FlareSolverr a echoue: status=${solution?.status || 'unknown'}`); - } - - const cookies = solution.cookies || []; - const userAgent = solution.userAgent || ''; - - const cfClearance = cookies.find(c => c.name === 'cf_clearance'); - if (!cfClearance) { - console.warn('[CF SESSION] Cookie cf_clearance non trouve dans la reponse FlareSolverr'); - } else { - console.log('[CF SESSION] cf_clearance obtenu via FlareSolverr'); - } - - const session = { - cookies, - userAgent, - solvedAt: Date.now(), - }; - - await saveSessionToRedis(session); - console.log(`[CF SESSION] Session sauvegardee dans Redis (${cookies.length} cookies)`); - - return session; -} - -/** - * Ensure a valid session exists. Uses Redis lock so only one worker - * across the cluster triggers a solve at a time. - */ -async function ensureSession(targetUrl) { - // 1. Check Redis for existing valid session - const existing = await loadSessionFromRedis(); - if (existing) return existing; - - // 2. Acquire lock — only one worker solves - const lock = await acquireRedisLock(REDIS_LOCK_KEY, { - ttl: 90, - retries: 70, - retryDelay: 1000, - }); - - if (!lock) { - const retry = await loadSessionFromRedis(); - if (retry) return retry; - console.warn('[CF SESSION] Impossible d\'acquerir le lock et aucune session en Redis'); - return null; - } - - try { - // Re-check after acquiring lock - const doubleCheck = await loadSessionFromRedis(); - if (doubleCheck) return doubleCheck; - - return await _doSolve(targetUrl); - } finally { - await lock.release(); - } -} - -/** - * Invalidate the session (all workers). - */ -async function invalidateSession() { - await deleteSessionFromRedis(); - console.log('[CF SESSION] Session invalidee dans Redis'); -} - -// ─── Request helper ────────────────────────────────────────────────────────── - -function buildHeaders(session, extraHeaders = {}) { - const cookieStr = session.cookies.map(c => `${c.name}=${c.value}`).join('; '); - return { - 'User-Agent': session.userAgent, - 'Cookie': cookieStr, - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8', - ...extraHeaders, - }; -} - -/** - * Make an axios request using the Cloudflare session cookies. - * Automatically re-solves on 403 / challenge page and retries once. - */ -async function requestWithSession(axiosInstance, baseUrl, config) { - const session = await ensureSession(baseUrl); - if (!session) { - throw new Error('Aucune session Cloudflare disponible'); - } - - const mergedConfig = { - ...config, - headers: buildHeaders(session, config.headers || {}), - }; - - try { - const response = await axiosInstance(mergedConfig); - - if (typeof response.data === 'string' && response.data.includes("Un instant, s'il vous plait")) { - console.log('[CF SESSION] Challenge detecte dans la reponse, re-resolution...'); - await invalidateSession(); - const newSession = await ensureSession(baseUrl); - if (!newSession) throw new Error('Re-resolution echouee'); - return await axiosInstance({ - ...config, - headers: buildHeaders(newSession, config.headers || {}), - }); - } - - return response; - } catch (err) { - if (err.response && err.response.status === 403) { - console.log('[CF SESSION] 403 recu, re-resolution du challenge...'); - await invalidateSession(); - const newSession = await ensureSession(baseUrl); - if (!newSession) throw new Error('Re-resolution echouee'); - return await axiosInstance({ - ...config, - headers: buildHeaders(newSession, config.headers || {}), - }); - } - throw err; - } -} - -module.exports = { - ensureSession, - invalidateSession, - requestWithSession, -}; diff --git a/API/Mainapi/utils/darkiworldSqlite.js b/API/Mainapi/utils/darkiworldSqlite.js new file mode 100644 index 0000000..1fa53ef --- /dev/null +++ b/API/Mainapi/utils/darkiworldSqlite.js @@ -0,0 +1,521 @@ +// API/Mainapi/utils/darkiworldSqlite.js +// +// Single source of truth for darkiworld link data under the hydracker freeze. +// Reads from three readonly sqlite snapshots: +// - links_small.sqlite (extra coverage by link_id; converted from MySQL dump) +// - mirror.sqlite (filtered final_urls, no darkibox) +// - darkino.sqlite (raw link_details, includes darkibox; we filter) +// +// Lookup-by-lien priority: links_small > mirror > darkino. +// +// Default path: Mainapi/darkino-backups/. Override via DARKIWORLD_SQLITE_DIR. + +const path = require("path"); + +let Database = null; +try { + Database = require("better-sqlite3"); +} catch (e) { + console.warn("[darkiworldSqlite] better-sqlite3 unavailable:", e?.message); +} + +const DEFAULT_DIR = path.join(__dirname, "..", "darkino-backups"); + +let mirrorDb = null; +let darkinoDb = null; +let linksSmallDb = null; +let stmts = null; +let openAttempted = false; + +function dbDir() { + return process.env.DARKIWORLD_SQLITE_DIR || DEFAULT_DIR; +} + +function openAll() { + if (openAttempted) return; + openAttempted = true; + if (!Database) return; + + const mirrorPath = path.join(dbDir(), "mirror.sqlite"); + const darkinoPath = path.join(dbDir(), "darkino.sqlite"); + const linksSmallPath = path.join(dbDir(), "links_small.sqlite"); + + try { + mirrorDb = new Database(mirrorPath, { + readonly: true, + fileMustExist: true, + }); + // journal_mode is a writer-side concern; readonly handles cannot change it. + } catch (e) { + console.warn( + `[darkiworldSqlite] mirror open failed (${mirrorPath}): ${e?.message}`, + ); + mirrorDb = null; + } + + try { + darkinoDb = new Database(darkinoPath, { + readonly: true, + fileMustExist: true, + }); + // journal_mode is a writer-side concern; readonly handles cannot change it. + } catch (e) { + console.warn( + `[darkiworldSqlite] darkino open failed (${darkinoPath}): ${e?.message}`, + ); + darkinoDb = null; + } + + try { + linksSmallDb = new Database(linksSmallPath, { + readonly: true, + fileMustExist: true, + }); + } catch (e) { + // Optional snapshot — missing file is not an error, just disables this source. + console.warn( + `[darkiworldSqlite] links_small open failed (${linksSmallPath}): ${e?.message}`, + ); + linksSmallDb = null; + } + + prepareStatements(); +} + +function prepareStatements() { + stmts = {}; + if (mirrorDb) { + stmts.mirrorByLien = mirrorDb.prepare( + "SELECT id, hydracker_lien_id, quality_id, quality_name, size_bytes, " + + "season_number, episode_label, langs, final_url, created_at, updated_at " + + "FROM links WHERE hydracker_lien_id = ?", + ); + stmts.mirrorByTitleMovie = mirrorDb.prepare( + "SELECT id, hydracker_lien_id, quality_id, quality_name, size_bytes, " + + "season_number, episode_label, langs, final_url, created_at, updated_at " + + "FROM links WHERE title_hydracker_id = ? AND season_number IS NULL", + ); + // Mirror tv: match exact episode_label, OR a rare IS NULL row (defensive), + // OR mirror's "Saison Complète" sentinel that marks a full-season pack. + stmts.mirrorByTitleTv = mirrorDb.prepare( + "SELECT id, hydracker_lien_id, quality_id, quality_name, size_bytes, " + + "season_number, episode_label, langs, final_url, created_at, updated_at " + + "FROM links WHERE title_hydracker_id = ? AND season_number = ? " + + " AND (episode_label = ? OR episode_label IS NULL OR episode_label = 'Saison Complète')", + ); + } + if (linksSmallDb) { + stmts.linksSmallByLien = linksSmallDb.prepare( + "SELECT link_id, link_url, host_name, tmdb_id, season_number, " + + "episode_number, episode_name, is_full_season, quality_name, " + + "size_bytes, audio_langs, created_at " + + "FROM links_small WHERE link_id = ? LIMIT 1", + ); + } + if (darkinoDb) { + stmts.darkinoByLien = darkinoDb.prepare( + "SELECT link_id, lien, id_host, title_id, taille, qualite, saison, " + + "episode, full_saison, created_at, updated_at " + + "FROM link_details WHERE link_id = ? " + + "AND active = 1 AND deleted_at IS NULL AND (to_expire = 0 OR to_expire IS NULL)", + ); + stmts.darkinoByTitleMovie = darkinoDb.prepare( + "SELECT link_id, lien, id_host, title_id, taille, qualite, saison, " + + "episode, full_saison, created_at, updated_at " + + "FROM link_details " + + "WHERE title_id = ? AND active = 1 AND deleted_at IS NULL AND (to_expire = 0 OR to_expire IS NULL)", + ); + stmts.darkinoByTitleTv = darkinoDb.prepare( + "SELECT link_id, lien, id_host, title_id, taille, qualite, saison, " + + "episode, full_saison, created_at, updated_at " + + "FROM link_details " + + "WHERE title_id = ? " + + " AND saison = ? " + + " AND (episode = ? OR full_saison = 1) " + + " AND active = 1 AND deleted_at IS NULL AND (to_expire = 0 OR to_expire IS NULL)", + ); + } +} + +function isDarkiboxUrl(url) { + return typeof url === "string" && /darkibox\.com/i.test(url); +} + +function normalizeMirrorRow(row) { + if (!row || typeof row.final_url !== "string" || !row.final_url) return null; + if (isDarkiboxUrl(row.final_url)) return null; + return { + source: "mirror", + lien_id: row.hydracker_lien_id, + lien: row.final_url, + taille: row.size_bytes || 0, + quality: row.quality_name || null, + quality_id: row.quality_id != null ? row.quality_id : null, + host_id: null, + langs: row.langs || null, + season_number: row.season_number != null ? row.season_number : null, + episode_label: row.episode_label || null, + full_saison: row.episode_label === "Saison Complète" ? 1 : 0, + created_at: row.created_at || null, + updated_at: row.updated_at || null, + }; +} + +function normalizeLinksSmallRow(row) { + if (!row || typeof row.link_url !== "string" || !row.link_url) return null; + if (isDarkiboxUrl(row.link_url)) return null; + return { + source: "links_small", + lien_id: row.link_id, + lien: row.link_url, + taille: row.size_bytes || 0, + quality: row.quality_name || null, + quality_id: null, + host_id: null, + host_name: row.host_name || null, + langs: row.audio_langs || null, + season_number: row.season_number != null ? row.season_number : null, + episode_label: + row.episode_number != null ? String(row.episode_number) : null, + full_saison: row.is_full_season ? 1 : 0, + created_at: row.created_at || null, + updated_at: null, + }; +} + +function normalizeDarkinoRow(row) { + if (!row || typeof row.lien !== "string" || !row.lien) return null; + if (isDarkiboxUrl(row.lien)) return null; + return { + source: "darkino", + lien_id: row.link_id, + lien: row.lien, + taille: row.taille || 0, + quality: null, + quality_id: row.qualite != null ? row.qualite : null, + host_id: row.id_host != null ? row.id_host : null, + langs: null, + season_number: row.saison != null ? row.saison : null, + episode_label: row.episode != null ? String(row.episode) : null, + full_saison: row.full_saison ? 1 : 0, + created_at: row.created_at || null, + updated_at: row.updated_at || null, + }; +} + +function lookupByLienId(lienId) { + openAll(); + if (lienId == null) return null; + const idNum = Number(lienId); + if (!Number.isFinite(idNum) || !Number.isInteger(idNum) || idNum <= 0) + return null; + + if (stmts?.linksSmallByLien) { + try { + const l = normalizeLinksSmallRow(stmts.linksSmallByLien.get(idNum)); + if (l) return l; + } catch (e) { + console.warn( + `[darkiworldSqlite] linksSmallByLien failed for ${idNum}: ${e?.message}`, + ); + } + } + if (stmts?.mirrorByLien) { + try { + const m = normalizeMirrorRow(stmts.mirrorByLien.get(idNum)); + if (m) return m; + } catch (e) { + console.warn( + `[darkiworldSqlite] mirrorByLien failed for ${idNum}: ${e?.message}`, + ); + } + } + if (stmts?.darkinoByLien) { + try { + const d = normalizeDarkinoRow(stmts.darkinoByLien.get(idNum)); + if (d) return d; + } catch (e) { + console.warn( + `[darkiworldSqlite] darkinoByLien failed for ${idNum}: ${e?.message}`, + ); + } + } + return null; +} + +function toNormalizedEntry(row) { + return { + id: row.lien_id, + language: row.langs || undefined, + quality: row.quality || undefined, + sub: undefined, + provider: "darkiworld", + host_id: row.host_id || undefined, + host_name: row.host_name || undefined, + size: row.taille || undefined, + upload_date: row.created_at || undefined, + host_icon: undefined, + saison: row.season_number != null ? row.season_number : undefined, + episode: + row.episode_label != null && row.episode_label !== "" + ? Number.isFinite(Number(row.episode_label)) + ? Number(row.episode_label) + : undefined + : undefined, + full_saison: row.full_saison ? 1 : undefined, + source: row.source, + }; +} + +function listByTitle({ type, titleId, season, episode } = {}) { + openAll(); + if (titleId == null) return []; + const idNum = Number(titleId); + if (!Number.isFinite(idNum) || !Number.isInteger(idNum) || idNum <= 0) + return []; + + if (type === "tv") { + if (season == null || episode == null) return []; + } + const seasonNum = season != null ? Number(season) : null; + const episodeNum = episode != null ? Number(episode) : null; + + const merged = new Map(); + + // Mirror + try { + if (type === "movie" && stmts?.mirrorByTitleMovie) { + for (const r of stmts.mirrorByTitleMovie.all(idNum)) { + const n = normalizeMirrorRow(r); + if (n) merged.set(n.lien_id, n); + } + } else if ( + type === "tv" && + stmts?.mirrorByTitleTv && + Number.isFinite(seasonNum) && + Number.isFinite(episodeNum) + ) { + for (const r of stmts.mirrorByTitleTv.all( + idNum, + seasonNum, + String(episodeNum), + )) { + const n = normalizeMirrorRow(r); + if (n) merged.set(n.lien_id, n); + } + } + } catch (e) { + console.warn( + `[darkiworldSqlite] mirror title fetch failed for ${idNum}: ${e?.message}`, + ); + } + + // Darkino + try { + if (type === "movie" && stmts?.darkinoByTitleMovie) { + for (const r of stmts.darkinoByTitleMovie.all(idNum)) { + if (merged.has(r.link_id)) continue; + const n = normalizeDarkinoRow(r); + if (n) merged.set(n.lien_id, n); + } + } else if ( + type === "tv" && + stmts?.darkinoByTitleTv && + Number.isFinite(seasonNum) && + Number.isFinite(episodeNum) + ) { + for (const r of stmts.darkinoByTitleTv.all( + idNum, + seasonNum, + episodeNum, + )) { + if (merged.has(r.link_id)) continue; + const n = normalizeDarkinoRow(r); + if (n) merged.set(n.lien_id, n); + } + } + } catch (e) { + console.warn( + `[darkiworldSqlite] darkino title fetch failed for ${idNum}: ${e?.message}`, + ); + } + + return Array.from(merged.values()).map(toNormalizedEntry); +} + +const FAILED_MARKER_TTL_DEFAULT_MS = 2 * 60 * 60 * 1000; // 2h +const FAILED_MARKER_TTL_BY_CODE = { + sqlite_miss: 0, // 0 — always recheck sqlite (covers post-deploy snapshot additions like links_small) + live_no_directdl: 2 * 60 * 60 * 1000, // 2h — hydracker has no raw link, persistent + live_hydracker_error: 5 * 60 * 1000, // 5min — transient upstream blip +}; +// Keep the legacy export name for backwards compatibility with consumers +// that read it as a single-value cap; equals the longest TTL in the map. +const FAILED_MARKER_TTL_MS = FAILED_MARKER_TTL_DEFAULT_MS; + +function isFailedMarkerActive(payload) { + if (!payload || payload.failed !== true || typeof payload.failedAt !== "number") { + return false; + } + const ttl = FAILED_MARKER_TTL_BY_CODE[payload.debug] ?? FAILED_MARKER_TTL_DEFAULT_MS; + return Date.now() - payload.failedAt < ttl; +} + +function isCachedDarkiboxPayload(payload) { + if (!payload || payload.success !== true) return false; + const url = + typeof payload.embed_url === "string" + ? payload.embed_url + : payload.embed_url?.lien || ""; + return isDarkiboxUrl(url); +} + +function buildPayloadFromSqliteRow(lienId, row) { + return { + success: true, + id: String(lienId), + provider: "direct", + embed_url: { + lien: row.lien, + taille: row.taille, + created_at: row.created_at, + }, + metadata: { + language: row.langs || undefined, + quality: row.quality || undefined, + sub: undefined, + size: row.taille || undefined, + upload_date: row.created_at || undefined, + }, + source: row.source, + }; +} + +function buildFailedMarker(lienId, errorMsg, debugMsg) { + return { + failed: true, + failedAt: Date.now(), + id: String(lienId), + error: errorMsg || "Lien non trouvé ou inaccessible", + debug: debugMsg || "", + }; +} + +async function decodeLink(lienId, deps) { + const { + cacheDir, generateCacheKey, getFromCacheNoExpiration, saveToCache, + hydrackerLive, // optional — when present, used on sqlite miss before writing sqlite_miss marker + } = deps; + const cacheKey = generateCacheKey(`darkiworld_decode_v2_${lienId}`); + + const tryLive = async () => { + if (!hydrackerLive || typeof hydrackerLive.resolveLien !== "function") { + console.log(`[decodeLink] tryLive skipped id=${lienId} reason=no_dep`); + return null; + } + console.log(`[decodeLink] tryLive enter id=${lienId}`); + const t0 = Date.now(); + try { + const live = await hydrackerLive.resolveLien(lienId); + const dt = Date.now() - t0; + if (live?.payload) { + console.log(`[decodeLink] tryLive ok id=${lienId} dt=${dt}ms`); + return { payload: live.payload }; + } + if (live?.failed) { + console.log(`[decodeLink] tryLive failed id=${lienId} debug=${live.failed.debug} dt=${dt}ms`); + return { failed: live.failed }; + } + console.log(`[decodeLink] tryLive empty id=${lienId} dt=${dt}ms result=${JSON.stringify(live)}`); + } catch (e) { + console.warn(`[darkiworldSqlite] hydrackerLive.resolveLien threw for ${lienId}: ${e?.message} stack=${e?.stack?.split('\n')[1]?.trim() || '-'}`); + } + return null; + }; + + let cached = null; + try { + cached = await getFromCacheNoExpiration(cacheDir, cacheKey); + } catch (_) { + cached = null; + } + + if (cached) { + console.log(`[decodeLink] cache_hit id=${lienId} kind=${cached.failed ? 'failed:'+cached.debug : (cached.success ? 'payload' : 'other')}`); + if (isFailedMarkerActive(cached)) { + if (cached.debug === "sqlite_miss") { + const row = lookupByLienId(lienId); + if (row) { + console.log(`[decodeLink] sqlite_miss self-heal via sqlite id=${lienId}`); + const payload = buildPayloadFromSqliteRow(lienId, row); + try { await saveToCache(cacheDir, cacheKey, payload); } catch (_) {} + return { payload }; + } + // sqlite still misses — try live before honouring the stale marker. + console.log(`[decodeLink] sqlite_miss self-heal via live attempt id=${lienId}`); + const live = await tryLive(); + if (live) return live; + console.log(`[decodeLink] sqlite_miss self-heal exhausted — returning stale marker id=${lienId}`); + } + return { failed: cached }; + } + if (cached.success === true && !isCachedDarkiboxPayload(cached)) { + return { payload: cached }; + } + // Fall through: legacy darkibox payload or malformed cache. + } else { + console.log(`[decodeLink] cache_miss id=${lienId}`); + } + + const row = lookupByLienId(lienId); + if (row) { + const payload = buildPayloadFromSqliteRow(lienId, row); + try { await saveToCache(cacheDir, cacheKey, payload); } catch (_) {} + return { payload }; + } + + // Sqlite miss — try the live path if wired in. The live module owns its own + // disk-cache write under the same key, so on success/failure we just return. + const live = await tryLive(); + if (live) return live; + + const marker = buildFailedMarker(lienId, "Lien indisponible", "sqlite_miss"); + try { await saveToCache(cacheDir, cacheKey, marker); } catch (_) {} + return { failed: marker }; +} + +module.exports = { + isDarkiboxUrl, + lookupByLienId, + listByTitle, + decodeLink, + isFailedMarkerActive, + isCachedDarkiboxPayload, + buildFailedMarker, + buildPayloadFromSqliteRow, + FAILED_MARKER_TTL_MS, + // exported for tests + _openAll: openAll, + _resetForTests: () => { + if (mirrorDb) { + try { + mirrorDb.close(); + } catch (_) {} + } + if (darkinoDb) { + try { + darkinoDb.close(); + } catch (_) {} + } + if (linksSmallDb) { + try { + linksSmallDb.close(); + } catch (_) {} + } + mirrorDb = null; + darkinoDb = null; + linksSmallDb = null; + stmts = null; + openAttempted = false; + }, +}; diff --git a/API/Mainapi/utils/hydrackerBatch.js b/API/Mainapi/utils/hydrackerBatch.js deleted file mode 100644 index 16168cd..0000000 --- a/API/Mainapi/utils/hydrackerBatch.js +++ /dev/null @@ -1,461 +0,0 @@ -/** - * Hydracker batch decoding orchestrator (predecode-on-page-load). - * - * /decode/:id at click = cache hit OR direct POST single (cache miss fallback). - * /download/:type/:id triggers predecodePage(entries) in setImmediate after - * res.json — that batches all uncached IDs of a download page into one upstream - * POST. Result: most user clicks land on cache hits; the upstream quota is - * burned ~once per page open instead of once per click. - * - * Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries: - * - hydracker:lock:{id} (string, 60s TTL) — single-flight per ID - * - hydracker:predecode_lock:{tid}:{S}:{E} (string, 30s TTL) — one worker per page - * - hydracker:rate_limited_until (string, EXPIREAT) — kill-switch quota - * - * Pivoted on 2026-05-02 from the queue-based design (cf. spec - * docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md, now - * superseded). Kept the pure helpers and Redis primitives from that work; the - * orchestrators (decodeRequest, drainQueueOnce) were replaced by decodeSingle - * and predecodePage. - */ - -const fsp = require('fs').promises; -const path = require('path'); - -// Redis keys -const LOCK_KEY = (id) => `hydracker:lock:${id}`; -const PREDECODE_LOCK_KEY = (titleId, season, episode) => - `hydracker:predecode_lock:${titleId}:${season || 0}:${episode || 0}`; -const RATE_LIMIT_KEY = 'hydracker:rate_limited_until'; - -// Tunables -const BATCH_CHUNK_SIZE = 50; // hydracker accepts up to 50 IDs per POST -const LOCK_TTL_SEC = 60; // per-ID single-flight lock TTL -const PREDECODE_LOCK_TTL_SEC = 30; // page-level predecode lock TTL -const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h -const STALE_REVALIDATE_MS = 48 * 60 * 60 * 1000; // 48h - -// --------------------------------------------------------------------------- -// Pure helpers (no Redis, no fs side-effects) -// --------------------------------------------------------------------------- - -function chunk(arr, size) { - if (!Array.isArray(arr) || size <= 0) return []; - const out = []; - for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); - return out; -} - -function buildPayload(id, linkInfo) { - const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : ''; - const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct'; - - let resolvedUrl; - if (provider === 'darkibox') { - const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i); - const code = m ? m[1] : null; - resolvedUrl = code - ? `https://darkibox.com/embed-${code}.html` - : (rawLien || `https://darkibox.com/embed-${id}.html`); - } else { - resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`; - } - - // Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html - // which matches the invalid embed pattern. Return null to signal upstream-failure - // path to callers — distinguishes truly sparse linkInfo from a real file with - // empty lien that carries metadata. - const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl); - if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) { - return null; - } - - const embedUrlPayload = linkInfo - ? { ...linkInfo, lien: resolvedUrl } - : resolvedUrl; - - return { - success: true, - id: String(id), - provider, - embed_url: embedUrlPayload, - metadata: linkInfo ? { - language: undefined, - quality: undefined, - sub: undefined, - size: linkInfo?.taille, - upload_date: linkInfo?.created_at - } : null - }; -} - -function buildFailedMarker(id, errorMsg, debugMsg) { - return { - failed: true, - failedAt: Date.now(), - id: String(id), - error: errorMsg || 'Lien non trouvé ou inaccessible', - debug: debugMsg || '' - }; -} - -function parseRateLimitError(error) { - const data = error?.response?.data; - if (!data || data.error !== 'daily_api_limit_exceeded') { - return { isRateLimit: false, resetsAt: null }; - } - const resetsAtIso = data.resets_at; - const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null; - return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }; -} - -function isFailedMarkerActive(payload) { - return Boolean( - payload?.failed === true && - typeof payload.failedAt === 'number' && - (Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS) - ); -} - -// --------------------------------------------------------------------------- -// Redis primitives — coordination only, NEVER cache data here. -// All functions tolerate Redis errors silently so the system degrades -// gracefully when Redis is unavailable. -// --------------------------------------------------------------------------- - -async function acquireLock(redis, key, ttlSec) { - if (!redis) return false; - try { - const result = await redis.set(key, String(process.pid), 'NX', 'EX', ttlSec); - return result === 'OK'; - } catch (e) { return false; } -} - -async function releaseLock(redis, key) { - if (!redis) return; - try { await redis.del(key); } - catch (e) { /* silent */ } -} - -async function isRateLimited(redis) { - if (!redis) return false; - try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); } - catch (e) { return false; } -} - -async function getRateLimitedUntil(redis) { - if (!redis) return null; - try { - const v = await redis.get(RATE_LIMIT_KEY); - return v ? Number(v) : null; - } catch (e) { return null; } -} - -async function armRateLimit(redis, resetsAtMs) { - if (!redis || !resetsAtMs) return; - try { - const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000)); - await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec); - console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`); - } catch (e) { /* silent */ } -} - -async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) { - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - try { - const payload = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (!payload) return null; - const filePath = path.join(cacheDir, `${cacheKey}.json`); - const stats = await fsp.stat(filePath); - return { payload, mtimeMs: stats.mtime.getTime(), cacheKey }; - } catch (e) { return null; } -} - -// --------------------------------------------------------------------------- -// decodeSingle — handles a single /decode/:id click. Cache hit returns 200; -// cache miss does a single POST upstream (small fallback for IDs that the -// predecode missed or that are stale). Returns: -// { payload } — 200 OK -// { failed: <marker> } — 404 -// { rateLimited: true, retryAt } — 503 rate_limited -// --------------------------------------------------------------------------- - -async function decodeSingle(id, deps) { - const { - redis, - cacheDir, - generateCacheKey, - getFromCacheNoExpiration, - shouldUpdateCache48h - } = deps; - - // 1. Read disk cache - const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (cached) { - if (isFailedMarkerActive(cached.payload)) { - return { failed: cached.payload }; - } - if (cached.payload.success === true) { - const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey); - if (stale) { - // Background refresh — gate behind per-id lock so concurrent stale reads - // from N workers fire only 1 upstream POST instead of N. - setImmediate(async () => { - const lockKey = LOCK_KEY(id); - const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC); - if (!gotLock) return; - try { - await postSingleAndPersist(id, deps); - } finally { - await releaseLock(redis, lockKey); - } - }); - } - return { payload: cached.payload }; - } - // Malformed cache — fall through to refetch - } - - // 2. Kill-switch - if (await isRateLimited(redis)) { - const retryAt = await getRateLimitedUntil(redis); - if (retryAt) return { rateLimited: true, retryAt }; - // retryAt unavailable — fall through to attempt POST - } - - // 3. Acquire single-flight lock; if taken, poll cache for the holder's result - const lockKey = LOCK_KEY(id); - const gotLock = await acquireLock(redis, lockKey, LOCK_TTL_SEC); - if (!gotLock) { - // Another worker (or the predecode batch) is decoding this ID. Poll the - // disk cache for up to 30s; the holding worker will write the result. - for (let i = 0; i < 30; i += 1) { - await new Promise((r) => setTimeout(r, 1000)); - const recheck = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (recheck) { - if (isFailedMarkerActive(recheck.payload)) return { failed: recheck.payload }; - if (recheck.payload.success === true) return { payload: recheck.payload }; - } - } - // Timeout — fallthrough; lock should have expired by now (60s TTL). - } - - // 4. POST single upstream - try { - return await postSingleAndPersist(id, deps); - } finally { - if (gotLock) await releaseLock(redis, lockKey); - } -} - -/** - * Internal helper — POSTs a single ID upstream, persists the result to disk, - * returns the decodeSingle result shape. Used by decodeSingle (path 4) and by - * the SWR background refresh (caller already holds the lock there). - */ -async function postSingleAndPersist(id, deps) { - const { - redis, - cacheDir, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - } = deps; - - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - - try { - await refreshDarkinoSessionIfNeeded(); - const resp = await axiosDarkinoRequest({ - method: 'post', - url: `/api/v1/download-premium/${id}` - }); - - const linkInfo = resp.data?.liens?.[0] || null; - const payload = buildPayload(id, linkInfo); - - if (!payload) { - const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected'); - const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (!existing) { - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } - return { failed: marker }; - } - - await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => { - console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message); - }); - return { payload }; - - } catch (err) { - const rl = parseRateLimitError(err); - if (rl.isRateLimit) { - await armRateLimit(redis, rl.resetsAt); - return { rateLimited: true, retryAt: rl.resetsAt }; - } - const marker = buildFailedMarker(id, 'Lien non trouvé ou inaccessible', err?.message || ''); - const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (!existing) { - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } - return { failed: marker }; - } -} - -// --------------------------------------------------------------------------- -// predecodePage — fire-and-forget batch decoding for a download page. -// -// Called from /download/:type/:id route AFTER res.json() has been sent. -// Errors are logged but never propagate. Designed to be wrapped in -// setImmediate(...) so it never blocks the response. -// -// Skips IDs already in disk cache (success or active failed marker), already -// in-flight (per-id lock taken), or upstream-rate-limited. Reserves the -// remaining IDs via per-id locks, batches them in chunks of 50, POSTs to -// hydracker, distributes results to disk, releases locks. -// --------------------------------------------------------------------------- - -async function predecodePage({ entries, titleId, season, episode }, deps) { - const { - redis, - cacheDir, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - shouldUpdateCache48h, - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - } = deps; - - if (!Array.isArray(entries) || entries.length === 0) return; - if (!titleId) return; - - // 1. Page-level lock — prevents 2 workers from pre-decoding the same page - const pageLockKey = PREDECODE_LOCK_KEY(titleId, season, episode); - const gotPageLock = await acquireLock(redis, pageLockKey, PREDECODE_LOCK_TTL_SEC); - if (!gotPageLock) return; - - try { - // 2. Kill-switch — skip if upstream is rate-limited - if (await isRateLimited(redis)) return; - - // 3. Filter IDs needing decode + reserve them via per-id locks - const idsToBatch = []; - for (const entry of entries) { - if (!entry || entry.id == null) continue; - // Skip if entry already has a direct lien (resolved by /content/liens) - if (typeof entry.lien === 'string' && entry.lien.length > 0) continue; - // Skip if cache hit AND fresh AND not a failed marker - const cached = await readDiskCache(entry.id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (cached) { - if (isFailedMarkerActive(cached.payload)) continue; - if (cached.payload.success === true) { - const stale = await shouldUpdateCache48h(cacheDir, cached.cacheKey); - if (!stale) continue; - // Stale, include in batch to refresh - } - } - // Reserve via per-id lock - const got = await acquireLock(redis, LOCK_KEY(entry.id), LOCK_TTL_SEC); - if (!got) continue; // another worker holds this id - idsToBatch.push(String(entry.id)); - } - - if (idsToBatch.length === 0) return; - - // 4. Chunked batch POST (50 IDs per POST per upstream limit) - const chunks = chunk(idsToBatch, BATCH_CHUNK_SIZE); - for (const ids of chunks) { - try { - await refreshDarkinoSessionIfNeeded(); - const resp = await axiosDarkinoRequest({ - method: 'post', - url: `/api/v1/download-premium/${ids.join(',')}` - }); - - const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : []; - const byId = new Map(); - for (const li of liens) { - if (li && li.id != null) byId.set(String(li.id), li); - } - - for (const id of ids) { - const linkInfo = byId.get(String(id)) || null; - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - - if (!linkInfo) { - const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', ''); - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } else { - const payload = buildPayload(id, linkInfo); - if (!payload) { - const marker = buildFailedMarker(id, "Lien d'embed invalide", 'embed-NN.html shape detected'); - const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (!existing) { - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } - } else { - await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => { - console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message); - }); - } - } - await releaseLock(redis, LOCK_KEY(id)); - } - } catch (err) { - const rl = parseRateLimitError(err); - if (rl.isRateLimit) { - await armRateLimit(redis, rl.resetsAt); - for (const id of ids) await releaseLock(redis, LOCK_KEY(id)); - return; // skip remaining chunks - } - console.warn(`[hydracker] predecode chunk failed (${ids.length} ids):`, err?.message || err); - for (const id of ids) await releaseLock(redis, LOCK_KEY(id)); - // continue with next chunk - } - } - } finally { - await releaseLock(redis, pageLockKey); - } -} - -module.exports = { - // pure helpers - chunk, - buildPayload, - buildFailedMarker, - parseRateLimitError, - isFailedMarkerActive, - // Redis primitives - acquireLock, - releaseLock, - isRateLimited, - getRateLimitedUntil, - armRateLimit, - // disk cache - readDiskCache, - // orchestrators - decodeSingle, - predecodePage, - postSingleAndPersist, - // constants - LOCK_KEY, - PREDECODE_LOCK_KEY, - RATE_LIMIT_KEY, - BATCH_CHUNK_SIZE, - LOCK_TTL_SEC, - PREDECODE_LOCK_TTL_SEC, - FAILED_MARKER_TTL_MS, - STALE_REVALIDATE_MS -}; diff --git a/API/Mainapi/utils/hydrackerLive.js b/API/Mainapi/utils/hydrackerLive.js new file mode 100644 index 0000000..1b3e34b --- /dev/null +++ b/API/Mainapi/utils/hydrackerLive.js @@ -0,0 +1,428 @@ +// API/Mainapi/utils/hydrackerLive.js +// +// Live raw-URL resolution path used by darkiworldSqlite.decodeLink when both +// the disk cache and the two sqlite snapshots miss. See: +// docs/superpowers/plans/2026-05-16-hydracker-live-raw-resolution.md + +'use strict'; + +const { randomUUID } = require('node:crypto'); + +const LOCK_RELEASE_SCRIPT = + "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end"; + +function createLimit(max) { + let active = 0; + const queue = []; + const pump = () => { + if (active >= max || queue.length === 0) return; + const job = queue.shift(); + active++; + Promise.resolve() + .then(job.fn) + .then( + (v) => { active--; job.resolve(v); pump(); }, + (e) => { active--; job.reject(e); pump(); }, + ); + }; + return (fn) => new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + pump(); + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function withRedisLock(redis, key, ttlSec, work, opts = {}) { + const waitMs = opts.waitMs ?? 65000; + const pollMs = opts.pollMs ?? 500; + const onWaitCheck = opts.onWaitCheck || (async () => null); + const token = randomUUID(); + + let acquired = null; + try { + acquired = await redis.set(key, token, 'NX', 'EX', ttlSec); + } catch (_) { + // Redis unreachable — fail closed so we don't bypass single-flight and + // overload upstream during a Redis outage. + return { owned: false, value: null, redisDown: true }; + } + + if (acquired === 'OK') { + try { + const value = await work(); + return { owned: true, value }; + } finally { + try { + await redis.eval(LOCK_RELEASE_SCRIPT, 1, key, token); + } catch (_) { /* swallow */ } + } + } + + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + await sleep(pollMs); + let cached = null; + try { cached = await onWaitCheck(); } catch (_) { cached = null; } + if (cached) return { owned: false, value: cached }; + } + return { owned: false, value: null, timedOut: true }; +} + +async function fetchHydrackerLien(lienId, deps) { + const { axios, limit, cookies, xsrf, timeoutMs } = deps; + return limit(async () => { + try { + const resp = await axios.get( + `https://hydracker.com/api/v1/content/liens/${lienId}`, + { + timeout: timeoutMs, + headers: { + accept: 'application/json', + cookie: cookies || '', + 'x-xsrf-token': xsrf || '', + 'user-agent': 'Mozilla/5.0 (Movix HydrackerLive)', + }, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + const body = resp.data || {}; + const directDL = body.directDL; + if (!directDL || typeof directDL !== 'string') { + return { ok: false, code: 'live_no_directdl' }; + } + return { + ok: true, + directDL, + rawUrl: typeof body.raw_url === 'string' && body.raw_url ? body.raw_url : null, + taille: body.lien?.taille ?? null, + created_at: body.lien?.created_at ?? null, + }; + } catch (e) { + return { + ok: false, + code: 'live_hydracker_error', + status: e?.response?.status || 0, + }; + } + }); +} + +async function fetchHydrackerTitleLiens(titleId, deps) { + const { axios, limit, cookies, xsrf, timeoutMs } = deps; + return limit(async () => { + try { + const resp = await axios.get( + `https://hydracker.com/api/v1/titles/${titleId}/content/liens`, + { + params: { + perPage: 100, + loader: 'linksdl', + filters: '', + paginate: 'preferLengthAware', + }, + timeout: timeoutMs, + headers: { + accept: 'application/json', + cookie: cookies || '', + 'x-xsrf-token': xsrf || '', + 'user-agent': 'Mozilla/5.0 (Movix HydrackerLive)', + }, + validateStatus: (s) => s >= 200 && s < 300, + }, + ); + const rows = Array.isArray(resp.data?.pagination?.data) ? resp.data.pagination.data : []; + return { ok: true, rows }; + } catch (e) { + return { + ok: false, + code: 'live_hydracker_list_error', + status: e?.response?.status || 0, + }; + } + }); +} + +function normalizeHydrackerLien(row) { + if (!row || typeof row.id !== 'number') return null; + const langs = Array.isArray(row.langues_compact) + ? row.langues_compact.map((l) => l.name).filter(Boolean).join('/') + : ''; + const subs = Array.isArray(row.subs_compact) + ? row.subs_compact.map((s) => s.name).filter(Boolean).join('/') + : ''; + const hostName = row.host?.name || undefined; + return { + id: row.id, + language: langs || undefined, + quality: row.qual?.qual || undefined, + sub: subs || undefined, + // Frontend uses `provider` as the visible host label, so surface the + // real host name (1Fichier, Send, ...) instead of a generic tag. + provider: hostName || 'darkiworld', + host_id: row.id_host != null ? row.id_host : undefined, + host_name: hostName, + host_icon: row.host?.icon || undefined, + size: row.taille || undefined, + upload_date: row.created_at || undefined, + saison: row.saison != null ? row.saison : undefined, + episode: row.episode != null ? row.episode : undefined, + full_saison: row.full_saison ? 1 : undefined, + source: 'hydracker-live', + }; +} + +const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h — match darkiworldSqlite + +function buildFailedMarker(lienId, code, status) { + return { + failed: true, + failedAt: Date.now(), + id: String(lienId), + error: 'Lien indisponible', + debug: code, + status: status ?? null, + }; +} + +function buildSuccessPayload(lienId, raw, hydracker) { + return { + success: true, + id: String(lienId), + provider: 'hydracker-live', + embed_url: { + lien: raw.link, + taille: raw.size ?? hydracker.taille ?? 0, + created_at: hydracker.created_at ?? null, + }, + metadata: { + language: undefined, + quality: undefined, + sub: undefined, + size: raw.size ?? hydracker.taille ?? undefined, + upload_date: hydracker.created_at ?? undefined, + host: raw.host ?? undefined, + filename: raw.filename ?? undefined, + }, + source: 'live', + }; +} + +function createHydrackerLive(deps) { + const { + redis, axios, cookies, xsrf, + concurrency = 6, timeoutMs = 20000, + cacheGet, cacheSet, cacheKeyFor, cacheDir, + hydrackerLienCacheTtl = 60, + titleListCacheTtl = 300, + lockTtlSec = 60, lockWaitMs = 65000, lockPollMs = 500, + upstreamCooldownMs = 5 * 60 * 1000, + } = deps; + + const limit = createLimit(concurrency); + + // Cluster-wide hydracker upstream cooldown — stored in Redis so a 5xx + // observed by any worker stops ALL workers from hammering a sick server. + // Key carries a TTL = upstreamCooldownMs, so it self-expires. + // Fail-open on Redis errors: a Redis outage must not block live fetches. + const HYDRACKER_COOLDOWN_KEY = 'hydracker:cooldown:5xx'; + const cooldownTtlSec = Math.max(1, Math.ceil(upstreamCooldownMs / 1000)); + async function isHydrackerInCooldown() { + try { + const exists = await redis.exists(HYDRACKER_COOLDOWN_KEY); + return exists === 1; + } catch (_) { + return false; + } + } + async function armHydrackerCooldownIfServerError(status) { + if (typeof status !== 'number' || status < 500 || status >= 600) { + return false; + } + try { + // NX so concurrent 5xx responses don't keep resetting the TTL window — + // first writer wins, the rest are no-ops. + const armed = await redis.set( + HYDRACKER_COOLDOWN_KEY, + String(Date.now()), + 'EX', + cooldownTtlSec, + 'NX', + ); + if (armed === 'OK') { + console.warn( + `[hydrackerLive] upstream ${status} — cooldown ${Math.round(upstreamCooldownMs / 60000)}min (cluster-wide)`, + ); + } + } catch (_) { + // Redis down — cooldown not armed, but per-worker retry pressure stays + // low because hydracker itself is still returning 5xx quickly. + } + return true; + } + + async function getCachedHydracker(lienId) { + try { + const raw = await redis.get(`hydracker:lien:${lienId}`); + if (raw) return JSON.parse(raw); + } catch (_) { /* swallow */ } + return null; + } + + async function cacheHydracker(lienId, hyd) { + try { + await redis.set( + `hydracker:lien:${lienId}`, + JSON.stringify(hyd), + 'EX', + hydrackerLienCacheTtl, + ); + } catch (_) { /* swallow */ } + } + + async function resolveLien(lienId) { + const key = cacheKeyFor(lienId); + const lockKey = `lock:hydracker:lien:${lienId}`; + const enterTime = Date.now(); + + const lockResult = await withRedisLock(redis, lockKey, lockTtlSec, async () => { + // Winner path. Re-check cache in case a previous holder finished + // between our miss check and lock acquisition. + // IMPORTANT: only short-circuit on success payloads. Failed markers + // (especially the legacy `sqlite_miss` one) are precisely why the + // caller's decodeLink self-heal decided to invoke us; honouring them + // here would loop the retry back into the same marker and the live + // fetch would never run. + const pre = await cacheGet(cacheDir, key); + if (pre && pre.success === true) return pre; + + // Reuse a recent hydracker response (Redis, hydrackerLienCacheTtl) so + // we don't re-hit upstream while waiting for AllDebrid history to + // pick up a freshly-debrided link. Saves rate-limit budget. + let hyd = await getCachedHydracker(lienId); + if (!hyd) { + if (await isHydrackerInCooldown()) { + // Don't persist marker — cooldown is transient; retry cheap after + // window ends instead of locking a 2h disk marker. + return buildFailedMarker(lienId, 'live_hydracker_cooldown', 0); + } + hyd = await fetchHydrackerLien(lienId, { + axios, limit, cookies, xsrf, timeoutMs, + }); + if (hyd.ok) { + await cacheHydracker(lienId, hyd); + } else { + await armHydrackerCooldownIfServerError(hyd.status); + } + } + if (!hyd.ok) { + const marker = buildFailedMarker(lienId, hyd.code, hyd.status); + try { await cacheSet(cacheDir, key, marker); } catch (_) {} + return marker; + } + + // hydracker returns the raw host link (1fichier, ...) directly in + // raw_url/directDL now — no debrid unlock step. directDL is the + // fallback when raw_url is absent. + const match = { + link: hyd.rawUrl || hyd.directDL, + size: hyd.taille ?? undefined, + host: undefined, + filename: undefined, + }; + + const payload = buildSuccessPayload(lienId, match, hyd); + try { await cacheSet(cacheDir, key, payload); } catch (_) {} + return payload; + }, { + waitMs: lockWaitMs, + pollMs: lockPollMs, + onWaitCheck: async () => { + const c = await cacheGet(cacheDir, key); + if (!c) return null; + if (c.success === true) return c; + // Only honour a failed marker if it was written AFTER we started + // waiting — otherwise it's a stale pre-existing marker that has + // nothing to do with the current holder's in-flight work. + if (c.failed === true && typeof c.failedAt === 'number' && c.failedAt >= enterTime) { + return c; + } + return null; + }, + }); + + if (lockResult.redisDown) { + return { failed: buildFailedMarker(lienId, 'live_redis_down') }; + } + if (lockResult.timedOut || !lockResult.value) { + return { failed: buildFailedMarker(lienId, 'live_lock_timeout') }; + } + const result = lockResult.value; + return result.success === true ? { payload: result } : { failed: result }; + } + + async function listLiensForTitle(titleId, opts = {}) { + const { type, season, episode } = opts; + const titleNum = Number(titleId); + if (!Number.isFinite(titleNum) || !Number.isInteger(titleNum) || titleNum <= 0) { + return []; + } + const cacheKey = `hydracker:title:${titleNum}`; + + let rows = null; + try { + const raw = await redis.get(cacheKey); + if (raw) { + try { rows = JSON.parse(raw); } catch (_) { rows = null; } + } + } catch (_) { /* redis down — refetch */ } + + if (!rows) { + if (await isHydrackerInCooldown()) { + console.warn(`[hydrackerLive] title list skipped (cooldown active) title=${titleNum}`); + return []; + } + const res = await fetchHydrackerTitleLiens(titleNum, { + axios, limit, cookies, xsrf, timeoutMs, + }); + if (!res.ok) { + await armHydrackerCooldownIfServerError(res.status); + console.warn(`[hydrackerLive] title list fetch failed title=${titleNum} code=${res.code} status=${res.status ?? '-'}`); + return []; + } + rows = res.rows; + try { + await redis.set(cacheKey, JSON.stringify(rows), 'EX', titleListCacheTtl); + } catch (_) { /* swallow */ } + } + + let filtered = rows; + if (type === 'tv') { + const sNum = Number(season); + const eNum = Number(episode); + if (Number.isFinite(sNum) && Number.isFinite(eNum)) { + filtered = rows.filter((r) => + Number(r.saison) === sNum && (Number(r.episode) === eNum || r.full_saison === 1 || r.full_saison === true), + ); + } + } + // For 'movie' (or unspecified) return everything — hydracker stores movies + // as saison=0/episode=null and the caller already implies that by passing + // type='movie'. + + return filtered.map(normalizeHydrackerLien).filter(Boolean); + } + + return { resolveLien, listLiensForTitle }; +} + +module.exports = { + createHydrackerLive, + _createLimit: createLimit, + _withRedisLock: withRedisLock, + _fetchHydrackerLien: fetchHydrackerLien, + _fetchHydrackerTitleLiens: fetchHydrackerTitleLiens, + _normalizeHydrackerLien: normalizeHydrackerLien, + _buildSuccessPayload: buildSuccessPayload, + _buildFailedMarker: buildFailedMarker, +}; diff --git a/API/Mainapi/utils/hydrackerQueue.js b/API/Mainapi/utils/hydrackerQueue.js deleted file mode 100644 index 3d02c24..0000000 --- a/API/Mainapi/utils/hydrackerQueue.js +++ /dev/null @@ -1,529 +0,0 @@ -/** - * Hydracker queue-based decoding orchestrator. - * - * /decode/:id at click = cache hit OR SADD queue + 202 (frontend retry). - * Drain worker in worker process: when queue size >= 50, SPOP 50 atomically, - * batch POST hydracker, persist results to disk cache, release lock. - * - * Cache lives ONLY on disk (DOWNLOAD_CACHE_DIR). Redis carries: - * - hydracker:queue:pending (SET, dedup-safe) - * - hydracker:worker_lock (string, 60s TTL) - * - hydracker:rate_limited_until (string, EXPIREAT) - * - * See: docs/superpowers/specs/2026-05-01-hydracker-queue-decoding-design.md - */ - -const fsp = require('fs').promises; -const path = require('path'); - -// Redis keys -const QUEUE_KEY = 'hydracker:queue:pending'; -const WORKER_LOCK_KEY = 'hydracker:worker_lock'; -const RATE_LIMIT_KEY = 'hydracker:rate_limited_until'; - -// Tunables -// HYDRACKER_BATCHING_ENABLED=false → bypass complet : la route /decode/:id -// utilise decodeRequestSync (POST direct, pas de Redis, pas de queue) et -// le drain timer dans app.js est skip. Comportement identique à l'ancien -// code synchrone, avant l'introduction de la queue. -const BATCHING_ENABLED = process.env.HYDRACKER_BATCHING_ENABLED !== 'false'; -const BATCH_SIZE = 50; -const WORKER_LOCK_TTL_SEC = 60; -const FAILED_MARKER_TTL_MS = 2 * 60 * 60 * 1000; // 2h -// Bumped 48h → 7d : un lien déjà en cache reste servi sans refetch pendant -// 7 jours, ce qui réduit la pression sur hydracker pour des contenus stables. -const STALE_REVALIDATE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days - -// --------------------------------------------------------------------------- -// Pure helpers (no Redis, no fs side-effects) -// --------------------------------------------------------------------------- - -function chunk(arr, size) { - if (!Array.isArray(arr) || size <= 0) return []; - const out = []; - for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); - return out; -} - -function buildPayload(id, linkInfo) { - const rawLien = typeof linkInfo?.lien === 'string' ? linkInfo.lien : ''; - const provider = /darkibox\.com/i.test(rawLien) ? 'darkibox' : 'direct'; - - let resolvedUrl; - if (provider === 'darkibox') { - const m = rawLien.match(/darkibox\.com\/(?:embed-)?([a-z0-9]{12,})(?:\.html)?/i); - const code = m ? m[1] : null; - resolvedUrl = code - ? `https://darkibox.com/embed-${code}.html` - : (rawLien || `https://darkibox.com/embed-${id}.html`); - } else { - resolvedUrl = rawLien || `https://darkibox.com/embed-${id}.html`; - } - - // Sparse linkInfo (empty lien AND no taille) → fallback URL is /embed-{id}.html - // which matches the invalid embed pattern. Return null to signal upstream-failure - // path to callers — distinguishes truly sparse linkInfo from a real file with - // empty lien that carries metadata. - const isInvalidEmbed = /\/embed-\d+\.html$/i.test(resolvedUrl); - if (isInvalidEmbed && !rawLien && linkInfo?.taille === undefined) { - return null; - } - - const embedUrlPayload = linkInfo - ? { ...linkInfo, lien: resolvedUrl } - : resolvedUrl; - - return { - success: true, - id: String(id), - provider, - embed_url: embedUrlPayload, - metadata: linkInfo ? { - language: undefined, - quality: undefined, - sub: undefined, - size: linkInfo?.taille, - upload_date: linkInfo?.created_at - } : null - }; -} - -function buildFailedMarker(id, errorMsg, debugMsg) { - return { - failed: true, - failedAt: Date.now(), - id: String(id), - error: errorMsg || 'Lien non trouvé ou inaccessible', - debug: debugMsg || '' - }; -} - -function parseRateLimitError(error) { - const data = error?.response?.data; - if (!data || data.error !== 'daily_api_limit_exceeded') { - return { isRateLimit: false, resetsAt: null }; - } - const resetsAtIso = data.resets_at; - const resetsAt = resetsAtIso ? Date.parse(resetsAtIso) : null; - return { isRateLimit: true, resetsAt: Number.isFinite(resetsAt) ? resetsAt : null }; -} - -function isFailedMarkerActive(payload) { - return Boolean( - payload?.failed === true && - typeof payload.failedAt === 'number' && - (Date.now() - payload.failedAt < FAILED_MARKER_TTL_MS) - ); -} - -// --------------------------------------------------------------------------- -// Redis primitives — coordination only, NEVER cache data here. -// All functions tolerate Redis errors silently so the system degrades -// gracefully when Redis is unavailable. -// --------------------------------------------------------------------------- - -async function enqueueId(redis, id) { - if (!redis) return false; - const idStr = id == null ? '' : String(id); - if (!idStr) return false; - try { - await redis.sadd(QUEUE_KEY, idStr); - return true; - } catch (e) { return false; } -} - -async function getQueueSize(redis) { - if (!redis) return 0; - try { return await redis.scard(QUEUE_KEY); } - catch (e) { return 0; } -} - -async function popBatch(redis, size) { - if (!redis) return []; - try { - const ids = await redis.spop(QUEUE_KEY, size); - return Array.isArray(ids) ? ids : []; - } catch (e) { return []; } -} - -async function requeueIds(redis, ids) { - if (!redis || !Array.isArray(ids) || ids.length === 0) return; - try { await redis.sadd(QUEUE_KEY, ...ids.map(String)); } - catch (e) { /* silent */ } -} - -async function isRateLimited(redis) { - if (!redis) return false; - try { return Boolean(await redis.exists(RATE_LIMIT_KEY)); } - catch (e) { return false; } -} - -async function getRateLimitedUntil(redis) { - if (!redis) return null; - try { - const v = await redis.get(RATE_LIMIT_KEY); - return v ? Number(v) : null; - } catch (e) { return null; } -} - -async function armRateLimit(redis, resetsAtMs) { - if (!redis || !resetsAtMs) return; - try { - const ttlSec = Math.max(1, Math.ceil((resetsAtMs - Date.now()) / 1000)); - await redis.set(RATE_LIMIT_KEY, String(resetsAtMs), 'EX', ttlSec); - console.warn(`[hydracker] daily quota exhausted, kill-switch armed until ${new Date(resetsAtMs).toISOString()}`); - } catch (e) { /* silent */ } -} - -async function acquireWorkerLock(redis) { - if (!redis) return false; - try { - const result = await redis.set(WORKER_LOCK_KEY, String(process.pid), 'NX', 'EX', WORKER_LOCK_TTL_SEC); - return result === 'OK'; - } catch (e) { return false; } -} - -async function releaseWorkerLock(redis) { - if (!redis) return; - try { await redis.del(WORKER_LOCK_KEY); } - catch (e) { /* silent */ } -} - -async function readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }) { - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - try { - const payload = await getFromCacheNoExpiration(cacheDir, cacheKey); - if (!payload) return null; - const filePath = path.join(cacheDir, `${cacheKey}.json`); - const stats = await fsp.stat(filePath); - return { payload, mtimeMs: stats.mtime.getTime(), cacheKey }; - } catch (e) { return null; } -} - -// --------------------------------------------------------------------------- -// decodeRequest — handles a single /decode/:id click from the route handler. -// Never POSTs upstream; just reads cache or enqueues. Returns: -// { payload } — 200 OK -// { failed: <marker> } — 404 -// { queued: true, queue_size: N } — 202 (frontend retries) -// { rateLimited: true, retryAt } — 503 rate_limited -// { unavailable: true } — 503 queue_unavailable (Redis down at enqueue) -// --------------------------------------------------------------------------- - -async function decodeRequest(id, deps) { - const { - redis, - cacheDir, - generateCacheKey, - getFromCacheNoExpiration - } = deps; - - // 1. Read disk cache - const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (cached) { - if (isFailedMarkerActive(cached.payload)) { - return { failed: cached.payload }; - } - if (cached.payload.success === true) { - // Stale check (STALE_REVALIDATE_MS = 7d) — return immediately, optionally - // enqueue for refresh. mtimeMs vient de readDiskCache, pas de re-stat. - const stale = (Date.now() - cached.mtimeMs) >= STALE_REVALIDATE_MS; - if (stale) { - // Refresh asynchronously by adding to the queue. - // No await — fire-and-forget, this is best-effort. - enqueueId(redis, id).catch(() => {}); - } - return { payload: cached.payload }; - } - // Malformed cache — fall through to enqueue - } - - // 2. Kill-switch check - if (await isRateLimited(redis)) { - const retryAt = await getRateLimitedUntil(redis); - if (retryAt) { - return { rateLimited: true, retryAt }; - } - // retryAt unavailable (Redis flake, key just expired) — fall through to enqueue - // rather than serving an undefined retry hint to the user. - } - - // 3. Enqueue (or fail fast if Redis is unavailable) - const enqueued = await enqueueId(redis, id); - if (!enqueued) { - // Redis unreachable — caller can't queue, so polling would never succeed. - // Surface as a 503 with a distinct error code so the frontend can show a - // clear "infra issue, retry later" message rather than spinning 5 min. - return { unavailable: true }; - } - const queue_size = await getQueueSize(redis); - return { queued: true, queue_size }; -} - -// --------------------------------------------------------------------------- -// decodeRequestSync — bypass mode (HYDRACKER_BATCHING_ENABLED=false). -// POST hydracker directement pour un ID, pas de Redis, pas de queue. -// Comportement identique à l'ancien code synchrone d'avant la queue. -// Returns: -// { payload } — 200 OK -// { failed: <marker> } — 404 -// --------------------------------------------------------------------------- - -async function decodeRequestSync(id, deps) { - const { - cacheDir, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - } = deps; - - // 1. Read disk cache - const cached = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (cached) { - if (isFailedMarkerActive(cached.payload)) { - return { failed: cached.payload }; - } - if (cached.payload.success === true) { - return { payload: cached.payload }; - } - // Malformed cache — fall through to refetch - } - - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - - try { - await refreshDarkinoSessionIfNeeded(); - const resp = await axiosDarkinoRequest({ - method: 'post', - url: `/api/v1/download-premium/${id}` - }); - - const linkInfo = resp.data?.liens?.[0] || null; - if (!linkInfo) { - const marker = buildFailedMarker(id, 'Lien non trouvé', 'download-premium response empty'); - await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); - return { failed: marker }; - } - - const payload = buildPayload(id, linkInfo); - if (!payload) { - const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected'); - await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); - return { failed: marker }; - } - - await saveToCache(cacheDir, cacheKey, payload).catch(() => {}); - return { payload }; - - } catch (err) { - const marker = buildFailedMarker(id, 'Erreur upstream hydracker', err?.message || String(err)); - // Ne pas écrire de marker si un cache existe déjà — évite de l'empoisonner. - if (!cached) { - await saveToCache(cacheDir, cacheKey, marker).catch(() => {}); - } - return { failed: marker }; - } -} - -// --------------------------------------------------------------------------- -// drainQueueOnce — one tick of the drain loop. Returns: -// { drained: false, reason: 'rate_limited' | 'queue_too_small' | 'lock_taken' } -// { drained: true, batchSize: N } -// { drained: false, error: <msg> } -// -// Caller (timer in every HTTP worker process) invokes this every ~5s; only one worker drains per tick via Redis lock. -// --------------------------------------------------------------------------- - -async function drainQueueOnce(deps) { - const { - redis, - cacheDir, - generateCacheKey, - getFromCacheNoExpiration, - saveToCache, - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded - } = deps; - - // 1. Skip if rate-limited - if (await isRateLimited(redis)) { - return { drained: false, reason: 'rate_limited' }; - } - - // 2. Skip if queue too small - const sizeBefore = await getQueueSize(redis); - if (sizeBefore < BATCH_SIZE) { - return { drained: false, reason: 'queue_too_small', queueSize: sizeBefore }; - } - - // 3. Try to acquire worker lock - const gotLock = await acquireWorkerLock(redis); - if (!gotLock) return { drained: false, reason: 'lock_taken' }; - - let popped = []; - try { - // 4. Re-check size after lock (anti-race) - const sizeAfter = await getQueueSize(redis); - if (sizeAfter < BATCH_SIZE) { - return { drained: false, reason: 'queue_too_small', queueSize: sizeAfter }; - } - - // 5. SPOP 50 atomically - popped = await popBatch(redis, BATCH_SIZE); - if (popped.length === 0) { - return { drained: false, reason: 'queue_empty_after_pop' }; - } - - // 6. POST hydracker - await refreshDarkinoSessionIfNeeded(); - const resp = await axiosDarkinoRequest({ - method: 'post', - url: `/api/v1/download-premium/${popped.join(',')}` - }); - - // 7. Distribute results to disk cache - const liens = Array.isArray(resp.data?.liens) ? resp.data.liens : []; - const byId = new Map(); - for (const li of liens) { - if (li && li.id != null) byId.set(String(li.id), li); - } - - for (const id of popped) { - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${id}`); - const linkInfo = byId.get(String(id)) || null; - - if (!linkInfo) { - // Absent from response → write failure marker - const marker = buildFailedMarker(id, 'Absent de la réponse batch hydracker', ''); - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } else { - const payload = buildPayload(id, linkInfo); - if (!payload) { - // Invalid embed shape → write failure marker (only if no prior cache) - const existing = await readDiskCache(id, { cacheDir, generateCacheKey, getFromCacheNoExpiration }); - if (!existing) { - const marker = buildFailedMarker(id, 'Lien d\'embed invalide', 'embed-NN.html shape detected'); - await saveToCache(cacheDir, cacheKey, marker).catch((cacheErr) => { - console.warn(`[hydracker] failure marker write failed for ${id}:`, cacheErr?.message); - }); - } - } else { - await saveToCache(cacheDir, cacheKey, payload).catch((cacheErr) => { - console.warn(`[hydracker] payload write failed for ${id}:`, cacheErr?.message); - }); - } - } - } - - return { drained: true, batchSize: popped.length }; - - } catch (err) { - console.warn(`[hydracker] drain failed (popped=${popped.length}):`, err?.message || err); - // 8. Error handling — requeue popped IDs so nothing is lost - const rl = parseRateLimitError(err); - if (rl.isRateLimit) { - await armRateLimit(redis, rl.resetsAt); - await requeueIds(redis, popped); - return { drained: false, reason: 'rate_limited', requeued: popped.length }; - } - // Other error - await requeueIds(redis, popped); - return { drained: false, error: err?.message || String(err), requeued: popped.length }; - } finally { - await releaseWorkerLock(redis); - } -} - -// --------------------------------------------------------------------------- -// prewarmDecodeCache — call the new hydracker /download endpoint and seed the -// disk decode cache for every entry that carries a direct `lien`. Best-effort: -// silent on failure, never writes a failed marker. Triggered in parallel with -// the existing /content/liens fetch in routes/darkiworld.js (cache miss path). -// --------------------------------------------------------------------------- - -async function prewarmDecodeCache({ type, id, season, episode, deps }) { - const { - axiosDarkinoRequest, - refreshDarkinoSessionIfNeeded, - cacheDir, - generateCacheKey, - saveToCache, - blockedUsers - } = deps; - const blocked = blockedUsers instanceof Set ? blockedUsers : new Set(); - - try { await refreshDarkinoSessionIfNeeded(); } catch (_) { /* non-fatal */ } - - const url = type === 'movie' - ? `/api/v1/titles/${id}/download` - : `/api/v1/titles/${id}/season/${season}/episode/${episode}/download`; - - let resp; - try { - resp = await axiosDarkinoRequest({ method: 'get', url }); - } catch (e) { - console.warn(`[hydracker] prewarm upstream fail ${type}/${id}: ${e?.message || e}`); - return { warmed: 0, warmedIds: new Set() }; - } - - const entries = [ - resp.data?.video, - ...(resp.data?.alternative_videos || []) - ].filter(Boolean); - - const warmedIds = new Set(); - const seen = new Set(); - for (const entry of entries) { - if (entry.id == null) continue; - const idKey = String(entry.id); - if (seen.has(idKey)) continue; - if (typeof entry.lien !== 'string' || entry.lien.trim() === '') continue; - if (entry.id_user && blocked.has(entry.id_user)) continue; - seen.add(idKey); - const payload = buildPayload(entry.id, entry); - if (!payload) continue; - const cacheKey = generateCacheKey(`darkiworld_decode_v2_${entry.id}`); - await saveToCache(cacheDir, cacheKey, payload).catch(() => {}); - warmedIds.add(idKey); - } - return { warmed: warmedIds.size, warmedIds }; -} - -module.exports = { - // helpers - chunk, - buildPayload, - buildFailedMarker, - parseRateLimitError, - isFailedMarkerActive, - // Redis primitives - enqueueId, - getQueueSize, - popBatch, - requeueIds, - isRateLimited, - getRateLimitedUntil, - armRateLimit, - acquireWorkerLock, - releaseWorkerLock, - // orchestrators - decodeRequest, - decodeRequestSync, - drainQueueOnce, - prewarmDecodeCache, - // disk cache - readDiskCache, - // constants - QUEUE_KEY, - WORKER_LOCK_KEY, - RATE_LIMIT_KEY, - BATCH_SIZE, - BATCHING_ENABLED, - WORKER_LOCK_TTL_SEC, - FAILED_MARKER_TTL_MS, - STALE_REVALIDATE_MS -}; diff --git a/API/Mainapi/utils/lruMap.js b/API/Mainapi/utils/lruMap.js new file mode 100644 index 0000000..614c89e --- /dev/null +++ b/API/Mainapi/utils/lruMap.js @@ -0,0 +1,106 @@ +'use strict'; + +/** + * LruMap — bounded Map with LRU eviction. + * + * Drop-in replacement for `new Map()` when entries hold expensive resources + * (socket pools, large JSON, etc.) that would otherwise accumulate forever. + * + * Iteration order is insertion-order with LRU-touch on `get()`: the most + * recently used entry is at the tail, the least recent at the head. + * + * @param {number} options.max Max entries before eviction. Required. + * @param {Function} [options.onEvict] Optional `(value, key) => void` callback + * fired on eviction, explicit `delete()`, + * and `clear()`. Use it to call + * `agent.destroy()` and similar cleanup. + */ +class LruMap { + constructor({ max, onEvict } = {}) { + if (!Number.isInteger(max) || max <= 0) { + throw new TypeError('LruMap: `max` must be a positive integer'); + } + this.max = max; + this.onEvict = typeof onEvict === 'function' ? onEvict : null; + this._map = new Map(); + } + + has(key) { + return this._map.has(key); + } + + get(key) { + if (!this._map.has(key)) return undefined; + const value = this._map.get(key); + this._map.delete(key); + this._map.set(key, value); + return value; + } + + set(key, value) { + if (this._map.has(key)) { + this._map.delete(key); + } else if (this._map.size >= this.max) { + const oldestKey = this._map.keys().next().value; + if (oldestKey !== undefined) { + const oldestValue = this._map.get(oldestKey); + this._map.delete(oldestKey); + this._invokeEvict(oldestValue, oldestKey); + } + } + this._map.set(key, value); + return this; + } + + delete(key) { + if (!this._map.has(key)) return false; + const value = this._map.get(key); + this._map.delete(key); + this._invokeEvict(value, key); + return true; + } + + clear() { + if (this.onEvict) { + for (const [key, value] of this._map) { + this._invokeEvict(value, key); + } + } + this._map.clear(); + } + + get size() { + return this._map.size; + } + + keys() { + return this._map.keys(); + } + + values() { + return this._map.values(); + } + + entries() { + return this._map.entries(); + } + + forEach(callback, thisArg) { + this._map.forEach(callback, thisArg); + } + + [Symbol.iterator]() { + return this._map[Symbol.iterator](); + } + + _invokeEvict(value, key) { + if (!this.onEvict) return; + try { + this.onEvict(value, key); + } catch (_) { + // Eviction callbacks must never throw out of the map. + } + } +} + +module.exports = { LruMap }; diff --git a/API/Mainapi/utils/oauthClientsDb.js b/API/Mainapi/utils/oauthClientsDb.js index 4b13075..12a30e4 100644 --- a/API/Mainapi/utils/oauthClientsDb.js +++ b/API/Mainapi/utils/oauthClientsDb.js @@ -11,6 +11,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { getPool } = require('../mysqlPool'); +const { redis } = require('../config/redis'); const SCHEMA_PATH = path.join(__dirname, '..', 'exportscripts', 'add_oauth_apps_tables.sql'); const LEGACY_JSON_PATH = path.join(__dirname, '..', 'data', 'oauth-clients.json'); @@ -206,6 +207,60 @@ function invalidateCache() { memCache = { loadedAt: 0, clients: [] }; } +// ─── Cross-worker cache invalidation (Redis pub/sub) ───────────────────── +// Movix runs in Node cluster mode (server.js): each worker has its own +// in-process `memCache`. A DB mutation only reloads the worker that handled +// it — other workers keep serving stale OAuth clients until they reboot. +// After every mutation we publish on a Redis channel so all workers reload. +// Without this, a redirect_uri added to a client after boot is rejected +// intermittently, depending on which worker the request is routed to. + +const CLIENTS_CHANGED_CHANNEL = 'oauth:clients:changed'; +let cacheSubscriber = null; + +/** Notify every cluster worker that the oauth_clients table changed. */ +async function publishClientsChanged() { + try { + await redis.publish(CLIENTS_CHANGED_CHANNEL, String(process.pid)); + } catch (err) { + // The local reloadCache() already ran; cross-worker propagation is + // best-effort and self-heals at the next mutation or worker restart. + console.warn('[OAuth Clients DB] publishClientsChanged failed:', err.message); + } +} + +/** Reload this worker's cache, then broadcast so every other worker reloads. */ +async function reloadCacheAndBroadcast() { + await reloadCache(); + await publishClientsChanged(); +} + +/** + * Start the per-worker cache subscriber. Call once per worker at boot. + * Uses a dedicated connection — in ioredis a connection in subscriber mode + * cannot run normal commands. ioredis auto-reconnects and re-subscribes. + */ +function startClientsCacheSubscriber() { + if (cacheSubscriber) return; + cacheSubscriber = redis.duplicate(); + cacheSubscriber.on('error', (err) => { + console.error('[OAuth Clients DB] cache subscriber error:', err.message); + }); + cacheSubscriber.on('message', (channel, message) => { + if (channel !== CLIENTS_CHANGED_CHANNEL) return; + reloadCache() + .then(() => console.log(`[OAuth Clients DB] cache reloaded (pub/sub from pid ${message})`)) + .catch((err) => console.error('[OAuth Clients DB] reloadCache after pub/sub failed:', err.message)); + }); + cacheSubscriber.subscribe(CLIENTS_CHANGED_CHANNEL, (err) => { + if (err) { + console.error('[OAuth Clients DB] subscribe failed:', err.message); + return; + } + console.log('[OAuth Clients DB] cache subscriber ready on', CLIENTS_CHANGED_CHANNEL); + }); +} + // ─── Stats helpers ─────────────────────────────────────────────────────── async function recordEvent(clientId, eventType, userId, metadata) { @@ -334,6 +389,9 @@ module.exports = { ensureTables, migrateLegacyJsonIfNeeded, reloadCache, + reloadCacheAndBroadcast, + publishClientsChanged, + startClientsCacheSubscriber, getCachedClients, invalidateCache, recordEvent, diff --git a/API/Mainapi/utils/proxyManager.js b/API/Mainapi/utils/proxyManager.js index 3a6a3f3..28325ac 100644 --- a/API/Mainapi/utils/proxyManager.js +++ b/API/Mainapi/utils/proxyManager.js @@ -12,10 +12,45 @@ const { HttpProxyAgent } = require("http-proxy-agent"); const { HttpsProxyAgent } = require("https-proxy-agent"); const tough = require("tough-cookie"); const initCycleTLS = require("cycletls"); +const { LruMap } = require("./lruMap"); +const { redis } = require("../config/redis"); // === PROXY AGENT CACHES === -const proxyAgentCache = new Map(); -const darkinoProxyAgentCache = new Map(); +// LRU-capped with onEvict that destroys the underlying agent (closes its +// keep-alive socket pool) so evicted entries do not leak sockets at the OS +// level. Without this, growing proxy rotation accumulated agents indefinitely +// — the root cause of multi-GB worker RSS. +const PROXY_AGENT_CACHE_MAX = 256; + +function destroyHttpAgentLike(value) { + if (!value || typeof value !== "object") return; + // SocksProxyAgent / HttpProxyAgent / HttpsProxyAgent all extend http.Agent + // and expose a `destroy()` that closes pooled sockets. + if (typeof value.destroy === "function") { + try { value.destroy(); } catch (_) { /* ignore */ } + return; + } + // Darkino/Wiflix entries wrap two agents. + if (value.httpAgent && typeof value.httpAgent.destroy === "function") { + try { value.httpAgent.destroy(); } catch (_) { /* ignore */ } + } + if ( + value.httpsAgent && + value.httpsAgent !== value.httpAgent && + typeof value.httpsAgent.destroy === "function" + ) { + try { value.httpsAgent.destroy(); } catch (_) { /* ignore */ } + } +} + +const proxyAgentCache = new LruMap({ + max: PROXY_AGENT_CACHE_MAX, + onEvict: destroyHttpAgentLike, +}); +const darkinoProxyAgentCache = new LruMap({ + max: PROXY_AGENT_CACHE_MAX, + onEvict: destroyHttpAgentLike, +}); const proxyRotationState = new Map(); // Initialize global agent keep-alive with socket limits @@ -34,15 +69,116 @@ const ENABLE_LECTEURVIDEO_PROXY = true; // Active/d\u00e9sactive le proxy pour L const ENABLE_FSTREAM_PROXY = true; // Active/d\u00e9sactive le proxy pour FStream const ENABLE_ANIME_PROXY = true; // Active/d\u00e9sactive le proxy pour AnimeSama (via Cloudflare Workers) const ENABLE_WIFLIX_PROXY = true; // Active/d\u00e9sactive le proxy pour Wiflix -const MAX_LECTEURVIDEO_PROXY_ATTEMPTS = 2; +const MAX_PROXYSCRAPE_PROXY_ATTEMPTS = 2; // Constante pour l'enhancement Darkino const darkiworld_premium = false; // Passe \u00e0 false pour d\u00e9sactiver l'enhancement Darkino -// === DARKINO 403 COOLDOWN === -// Cooldown de 5 minutes apr\u00e8s une erreur 403 (Cloudflare challenge) -const DARKINO_403_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes -let darkino403CooldownUntil = 0; // Timestamp jusqu'auquel on ne fait plus de requ\u00eates +// === DARKINO COOLDOWNS (cluster-wide via Redis) === +// Cooldown de 5 minutes apr\u00e8s une erreur 403 (Cloudflare challenge), 429 +// (Too Many Requests / Cloudflare Workers daily limit) ou 5xx (erreur +// serveur). Stock\u00e9 en Redis pour que les 6 workers partagent le signal : +// un seul worker doit hit l'erreur pour que tous arr\u00eatent de marteler. +const DARKINO_403_COOLDOWN_MS = 5 * 60 * 1000; +const DARKINO_429_COOLDOWN_MS = 5 * 60 * 1000; +const DARKINO_5XX_COOLDOWN_MS = 5 * 60 * 1000; +const DARKINO_NETERR_COOLDOWN_MS = 5 * 60 * 1000; + +const DARKINO_COOLDOWN_KEYS = { + '403': 'darkino:cooldown:403', + '429': 'darkino:cooldown:429', + '5xx': 'darkino:cooldown:5xx', + 'neterr': 'darkino:cooldown:neterr', +}; +const DARKINO_COOLDOWN_TTL_MS = { + '403': DARKINO_403_COOLDOWN_MS, + '429': DARKINO_429_COOLDOWN_MS, + '5xx': DARKINO_5XX_COOLDOWN_MS, + 'neterr': DARKINO_NETERR_COOLDOWN_MS, +}; + +// === DARKINO NETWORK-FAILURE CIRCUIT BREAKER === +// Les cooldowns 403/429/5xx ne couvrent QUE les erreurs avec une reponse HTTP. +// Un timeout / erreur reseau (ECONNABORTED, ECONNRESET, ...) n'a pas de +// error.response : aucun cooldown ne s'armait, donc chaque requete continuait +// a marteler un upstream mort -> requetes en vol qui s'accumulent (RSS qui +// monte sans redescendre). Ce compteur d'echecs reseau CONSECUTIFs est +// cluster-wide via Redis ; au seuil il arme le cooldown 'neterr'. +const DARKINO_NETERR_THRESHOLD = 20; // echecs reseau consecutifs avant pause +const DARKINO_NETERR_WINDOW_MS = 2 * 60 * 1000; // fenetre glissante du compteur +const DARKINO_NETERR_COUNTER_KEY = 'darkino:netfail:count'; + +/** + * Returns remaining cooldown in ms (0 if not active or Redis unavailable). + * Fail-open: a Redis outage must not block Darkino traffic. + */ +async function getDarkinoCooldownRemainingMs(kind) { + const key = DARKINO_COOLDOWN_KEYS[kind]; + if (!key) return 0; + try { + const pttl = await redis.pttl(key); + return pttl > 0 ? pttl : 0; + } catch (_) { + return 0; + } +} + +/** + * Arms a Darkino cooldown. NX so concurrent 5xx responses across workers + * don't keep extending the window \u2014 first arm wins, rest are no-ops. + * Returns true if this caller actually set the key. + */ +async function armDarkinoCooldown(kind) { + const key = DARKINO_COOLDOWN_KEYS[kind]; + const ttlMs = DARKINO_COOLDOWN_TTL_MS[kind]; + if (!key || !ttlMs) return false; + try { + const ttlSec = Math.max(1, Math.ceil(ttlMs / 1000)); + const armed = await redis.set(key, String(Date.now()), 'EX', ttlSec, 'NX'); + return armed === 'OK'; + } catch (_) { + return false; + } +} + +/** + * Compte un echec reseau Darkino (timeout / erreur de connexion sans reponse + * HTTP). Cluster-wide via Redis INCR + fenetre glissante atomique. Au seuil, + * arme le cooldown 'neterr' et remet le compteur a zero. Fail-open : une + * panne Redis ne compte rien et ne bloque jamais le trafic. + * @returns {Promise<number>} compteur courant (0 si Redis indisponible) + */ +async function recordDarkinoNetFailure() { + try { + const execResult = await redis.multi() + .incr(DARKINO_NETERR_COUNTER_KEY) + .pexpire(DARKINO_NETERR_COUNTER_KEY, DARKINO_NETERR_WINDOW_MS) + .exec(); + const count = Number(execResult?.[0]?.[1]) || 0; + if (count >= DARKINO_NETERR_THRESHOLD) { + const armed = await armDarkinoCooldown('neterr'); + await redis.del(DARKINO_NETERR_COUNTER_KEY); + if (armed) { + console.log(`[DARKINO] ${count} echecs reseau consecutifs - cooldown ${Math.round(DARKINO_NETERR_COOLDOWN_MS / 60000)} min active`); + } + } + return count; + } catch (_) { + return 0; + } +} + +/** + * Reinitialise le compteur d'echecs reseau Darkino apres une requete reussie + * (le seuil compte des echecs *consecutifs*). Fail-open. + */ +async function resetDarkinoNetFailures() { + try { + await redis.del(DARKINO_NETERR_COUNTER_KEY); + } catch (_) { + /* ignore */ + } +} // === CPASMAL CONFIGURATION === const CPASMAL_BASE_URL = "https://www.cpasmal.rip"; @@ -50,7 +186,10 @@ const CPASMAL_BASE_URL = "https://www.cpasmal.rip"; const cpasmalJar = new tough.CookieJar(null, { rejectPublicSuffixes: false }); // Cache pour les agents SOCKS5 Cpasmal (Keep-Alive) -const cpasmalAgentCache = new Map(); +const cpasmalAgentCache = new LruMap({ + max: PROXY_AGENT_CACHE_MAX, + onEvict: destroyHttpAgentLike, +}); function getCpasmalAgent(proxy) { if (!proxy) return null; @@ -435,6 +574,26 @@ async function makeRequestWithCorsFallback(targetUrl, options = {}) { } // Fonction pour faire une requ\u00eate Coflix avec rotation Cloudflare Workers. +// Classe un 429 recu via les Cloudflare Workers Coflix. +// - 'worker' : preuve POSITIVE d'un blocage Cloudflare du worker lui-meme +// (page "error code: 1015"/1027, "You are being rate limited", limite +// journaliere). Roter vers un autre worker (bucket de limite distinct) aide. +// - 'site' : tout le reste -> 429 forwarde depuis coflix.band (rate-limit +// global). Defaut volontaire : sans preuve d'un 1015, on ne rote pas et on +// ne martele pas l'upstream. +function classifyCloudflare429(body) { + const text = typeof body === "string" ? body : String(body || ""); + return /error\s*code:?\s*(1015|1027|1029)|you are being rate limited|daily request limit/i.test( + text, + ) + ? "worker" + : "site"; +} + +// Coflix (recherche + pages film/serie) via proxies ProxyScrape (HTTP prefere, +// SOCKS5 fallback) au lieu des Cloudflare Workers. Sur 429/403/5xx/erreur reseau +// on rote vers l'IP suivante (2 max). Si tout est 429, le flag coflixSiteRateLimited +// coupe le spam de log par titre cote route (coflix.js). async function makeCoflixRequest(targetUrl, options = {}) { const { timeout = 15000, @@ -481,10 +640,11 @@ async function makeCoflixRequest(targetUrl, options = {}) { delete cleanHeaders[header.toLowerCase()]; }); - const proxyCandidates = getAvailableProxies(CLOUDFLARE_WORKERS_PROXIES); + const { proxies, useSocks } = pickProxyscrapeCandidates(); let lastError = null; - if (proxyCandidates.length === 0) { + // Aucun proxy ProxyScrape dispo -> tentative directe. + if (!proxies || proxies.length === 0) { return axios({ url: cleanTargetUrl, method: otherOptions.method || "GET", @@ -497,24 +657,29 @@ async function makeCoflixRequest(targetUrl, options = {}) { }); } - // Un 403/429 peut d\u00e9pendre du worker utilise : on tente chaque worker avant d'abandonner. - for (let i = 0; i < proxyCandidates.length; i++) { - const cloudflareProxy = proxyCandidates[i]; - const proxiedUrl = buildProxiedUrl(cloudflareProxy, cleanTargetUrl); + // Rotation sur 2 proxies ProxyScrape max (egress different = bucket 429 distinct). + for (let i = 0; i < proxies.length; i++) { + const proxy = proxies[i]; + const auth = proxy.auth ? `${proxy.auth}@` : ""; + // ponytail: agent non cache (<=2/req, keepAlive off -> pas de fuite socket). + const agent = useSocks + ? getProxyAgent(proxy) + : new HttpsProxyAgent(`http://${auth}${proxy.host}:${proxy.port}`); try { const response = await axios({ - url: proxiedUrl, + url: cleanTargetUrl, method: otherOptions.method || "GET", headers: cleanHeaders, timeout, decompress, responseType: "text", responseEncoding: "utf8", + httpAgent: agent, + httpsAgent: agent, ...otherOptions, }); - markProxyAsHealthy(cloudflareProxy); return response; } catch (error) { lastError = error; @@ -522,27 +687,25 @@ async function makeCoflixRequest(targetUrl, options = {}) { const errorCode = error.code || "unknown"; error.coflixUrl = cleanTargetUrl; - error.coflixProxy = cloudflareProxy; - error.coflixProxiedUrl = proxiedUrl; + error.coflixProxy = `${proxy.host}:${proxy.port}`; - if (statusCode === 403 || statusCode === 429) { - markProxyAsErrored(cloudflareProxy, statusCode); - continue; - } - - if (statusCode >= 500 && statusCode < 600) { - markProxyAsErrored(cloudflareProxy, statusCode); + // 429 = coflix.band rate-limit. Une autre IP ProxyScrape = bucket distinct + // -> on rote. Le flag coupe le spam de log par titre si tout est limite. + if (statusCode === 429) { + error.coflixSiteRateLimited = true; continue; } + // 403 (Cloudflare), 5xx, ou erreur reseau -> proxy suivant. if ( + statusCode === 403 || + (statusCode >= 500 && statusCode < 600) || errorCode === "ECONNABORTED" || errorCode === "ETIMEDOUT" || errorCode === "ECONNRESET" || errorCode === "EHOSTUNREACH" || errorCode === "ENETUNREACH" ) { - markProxyAsErrored(cloudflareProxy, errorCode); continue; } @@ -552,9 +715,7 @@ async function makeCoflixRequest(targetUrl, options = {}) { } if (lastError) throw lastError; - throw new Error( - "Tous les Cloudflare Workers proxies ont \u00e9chou\u00e9 (Coflix)", - ); + throw new Error("Tous les proxies ProxyScrape ont echoue (Coflix)"); } // Fonction pour faire une requete LecteurVideo avec CycleTLS. @@ -603,7 +764,7 @@ const CHROME_JA3 = const CHROME_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; -function pickLecteurVideoProxyCandidates() { +function pickProxyscrapeCandidates() { const httpPool = HTTP_PROXIES.length > 0 ? HTTP_PROXIES @@ -634,7 +795,7 @@ function pickLecteurVideoProxyCandidates() { const selectedProxies = [...preferredPool.proxies] .sort(() => Math.random() - 0.5) - .slice(0, MAX_LECTEURVIDEO_PROXY_ATTEMPTS); + .slice(0, MAX_PROXYSCRAPE_PROXY_ATTEMPTS); return { proxies: selectedProxies, @@ -653,7 +814,7 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) { useSocks, label: proxyPoolLabel, totalAvailable, - } = pickLecteurVideoProxyCandidates(); + } = pickProxyscrapeCandidates(); if (!proxies || proxies.length === 0) { const cycleTLS = await getCycleTLS(); @@ -664,8 +825,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) { ja3: CHROME_JA3, userAgent: CHROME_UA, headers: { - Referer: "https://coflix.date/", - Origin: "https://coflix.date", + Referer: "https://coflix.trade/", + Origin: "https://coflix.trade", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9", @@ -714,8 +875,8 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) { userAgent: CHROME_UA, proxy: proxyUrl, headers: { - Referer: "https://coflix.date/", - Origin: "https://coflix.date", + Referer: "https://coflix.trade/", + Origin: "https://coflix.trade", Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9", @@ -765,6 +926,247 @@ async function makeLecteurVideoRequest(targetUrl, options = {}) { throw lastError || new Error("[LECTEURVIDEO] Tous les proxies ont echoue"); } +// CineStream (Cloudflare-fronted Next.js) via CycleTLS + ProxyScrape rotation, +// like LecteurVideo/AnimeSama. Plain axios + CF Workers gets hammered with 525 +// (CF edge SSL errors under volume from one IP); a real Chrome JA3 over rotating +// low-volume proxy IPs survives. Returns an axios-like {data,status,headers} +// EVEN on 5xx/525 (never throws on HTTP status) so a flaky upstream degrades to +// "film not found" instead of a thrown error — the caller's regex just finds no +// tmdbid. Rotates proxies on 5xx/525/cf-block; returns 4xx immediately (a 400 on +// an exotic-script title is deterministic, not worth burning another proxy). +async function makeCinestreamRequest(targetUrl, options = {}) { + const { headers = {}, timeout = 15 } = options; + const cleanTargetUrl = targetUrl.trim(); + + const cycleHeaders = { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + Referer: "https://cinestream.info/", + ...headers, + }; + + const isCfBlock = (status, body) => + status === 403 && + typeof body === "string" && + (body.includes("cf-wrapper") || body.includes("cloudflare")); + // 5xx (incl. Cloudflare 520-527 origin/SSL errors) + cf-block -> rotate proxy. + const shouldRotate = (status, body) => + (status >= 500 && status < 600) || isCfBlock(status, body); + + const cycleTLS = await getCycleTLS(); + const asAxiosLike = (resp) => ({ + data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body), + status: resp.status, + headers: resp.headers || {}, + }); + + const { proxies, useSocks } = pickProxyscrapeCandidates(); + + // No proxy pool -> single direct CycleTLS attempt. + if (!proxies || proxies.length === 0) { + const resp = await cycleTLS( + cleanTargetUrl, + { body: "", ja3: CHROME_JA3, userAgent: CHROME_UA, headers: cycleHeaders, timeout }, + "get", + ); + return asAxiosLike(resp); + } + + let lastResult = null; + let lastError = null; + + for (let i = 0; i < proxies.length; i++) { + const proxy = proxies[i]; + const auth = proxy.auth ? `${proxy.auth}@` : ""; + const proxyUrl = useSocks + ? `socks5h://${auth}${proxy.host}:${proxy.port}` + : `http://${auth}${proxy.host}:${proxy.port}`; + + try { + const resp = await cycleTLS( + cleanTargetUrl, + { body: "", ja3: CHROME_JA3, userAgent: CHROME_UA, proxy: proxyUrl, headers: cycleHeaders, timeout }, + "get", + ); + const result = asAxiosLike(resp); + if (shouldRotate(result.status, result.data)) { + lastResult = result; + continue; + } + return result; // 2xx success, or a definitive 4xx + } catch (err) { + lastError = err; + } + } + + // All proxies 5xx/cf-block or errored: hand back the last response so the + // caller parses (and quietly finds nothing) instead of throwing. + if (lastResult) return lastResult; + throw lastError || new Error("[CINESTREAM] Tous les proxies ont echoue"); +} + +// 1jour1film (Dooplay theme, Cloudflare-fronted) via CycleTLS + ProxyScrape +// rotation — same mechanism as makeCinestreamRequest, but: +// - supports POST (the Dooplay `doo_player_ajax` admin-ajax call), and +// - takes a caller-supplied Referer/Origin in `headers` (the domain rotates, +// so there's no hardcoded referer here). +// Returns an axios-like {data,status,headers} and never throws on HTTP status; +// rotates proxies on 5xx / Cloudflare-block, returns 4xx as-is. A degraded +// response just yields HTML the caller's parser finds nothing in -> "not found". +// NOTE: the residential proxy pool is what gets the *full* page payload (player +// option labels, j1fEpsData, search nonce); datacenter IPs get a stripped +// variant. timeout is in SECONDS (CycleTLS), like the sibling helpers. +async function make1j1fRequest(targetUrl, options = {}) { + const { headers = {}, timeout = 15, method = "get", body = "" } = options; + const cleanTargetUrl = targetUrl.trim(); + const lowerMethod = String(method).toLowerCase(); + + const cycleHeaders = { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + ...headers, + }; + + const isCfBlock = (status, b) => + (status === 403 || status === 503) && + typeof b === "string" && + (b.includes("cf-wrapper") || + b.includes("Just a moment") || + b.includes("cloudflare")); + const shouldRotate = (status, b) => + (status >= 500 && status < 600) || isCfBlock(status, b); + + const cycleTLS = await getCycleTLS(); + const asAxiosLike = (resp) => ({ + data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body), + status: resp.status, + headers: resp.headers || {}, + }); + + const doRequest = (proxyUrl) => + cycleTLS( + cleanTargetUrl, + { + body, + ja3: CHROME_JA3, + userAgent: CHROME_UA, + headers: cycleHeaders, + timeout, + ...(proxyUrl ? { proxy: proxyUrl } : {}), + }, + lowerMethod, + ); + + const { proxies, useSocks } = pickProxyscrapeCandidates(); + + // No proxy pool -> single direct CycleTLS attempt (works from a residential + // host; from a datacenter IP it returns the stripped variant). + if (!proxies || proxies.length === 0) { + return asAxiosLike(await doRequest(null)); + } + + let lastResult = null; + let lastError = null; + for (let i = 0; i < proxies.length; i++) { + const proxy = proxies[i]; + const auth = proxy.auth ? `${proxy.auth}@` : ""; + const proxyUrl = useSocks + ? `socks5h://${auth}${proxy.host}:${proxy.port}` + : `http://${auth}${proxy.host}:${proxy.port}`; + try { + const result = asAxiosLike(await doRequest(proxyUrl)); + if (shouldRotate(result.status, result.data)) { + lastResult = result; + continue; + } + return result; // 2xx success, or a definitive 4xx + } catch (err) { + lastError = err; + } + } + + if (lastResult) return lastResult; + throw lastError || new Error("[1J1F] Tous les proxies ont echoue"); +} + +// Cpasmal (DLE, Cloudflare-fronted) via CycleTLS + ProxyScrape rotation. +// Same mechanism as make1j1fRequest: a real Chrome JA3 over rotating low-volume +// IPs beats the Cloudflare bot-challenge that plain axios + datacenter proxies +// hit (every GET/POST 403'd). Supports POST (DLE search form) and GET (detail / +// getxfield / Season.php pages). Caller supplies Referer/Origin/Content-Type in +// `headers`. timeout is in SECONDS (CycleTLS). Returns axios-like {data,status,headers}. +async function makeCpasmalRequest(targetUrl, options = {}) { + const { headers = {}, timeout = 15, method = "get", body = "" } = options; + const cleanTargetUrl = targetUrl.trim(); + const lowerMethod = String(method).toLowerCase(); + + const cycleHeaders = { + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8", + ...headers, + }; + + const isCfBlock = (status, b) => + (status === 403 || status === 503) && + typeof b === "string" && + (b.includes("cf-wrapper") || + b.includes("Just a moment") || + b.includes("cloudflare")); + const shouldRotate = (status, b) => + (status >= 500 && status < 600) || isCfBlock(status, b); + + const cycleTLS = await getCycleTLS(); + const asAxiosLike = (resp) => ({ + data: typeof resp.body === "string" ? resp.body : JSON.stringify(resp.body), + status: resp.status, + headers: resp.headers || {}, + }); + + const doRequest = (proxyUrl) => + cycleTLS( + cleanTargetUrl, + { + body, + ja3: CHROME_JA3, + userAgent: CHROME_UA, + headers: cycleHeaders, + timeout, + ...(proxyUrl ? { proxy: proxyUrl } : {}), + }, + lowerMethod, + ); + + const { proxies, useSocks } = pickProxyscrapeCandidates(); + + // No proxy pool -> single direct CycleTLS attempt. + if (!proxies || proxies.length === 0) { + return asAxiosLike(await doRequest(null)); + } + + let lastResult = null; + let lastError = null; + for (let i = 0; i < proxies.length; i++) { + const proxy = proxies[i]; + const auth = proxy.auth ? `${proxy.auth}@` : ""; + const proxyUrl = useSocks + ? `socks5h://${auth}${proxy.host}:${proxy.port}` + : `http://${auth}${proxy.host}:${proxy.port}`; + try { + const result = asAxiosLike(await doRequest(proxyUrl)); + if (shouldRotate(result.status, result.data)) { + lastResult = result; + continue; + } + return result; // 2xx success, or a definitive 4xx + } catch (err) { + lastError = err; + } + } + + if (lastResult) return lastResult; + throw lastError || new Error("[CPASMAL] Tous les proxies ont echoue"); +} + // Fonction AnimeSama avec CycleTLS (cloudscraper-style : JA3 Chrome pour bypass Cloudflare). // Utilise le SOCKS5_PROXIES env directement (proxies dedies) -- pas le pool ProxyScrape, // pas de fallback direct sans proxy. @@ -866,7 +1268,6 @@ const parseJsonArrayEnv = (envName, fallback = []) => { } }; -const { redis } = require("../config/redis"); const { acquireRedisLock } = require("./redisLock"); const PROXY_ROTATION_REDIS_PREFIX = "proxyscrape:rotation:v1"; @@ -1673,10 +2074,17 @@ scheduleProxyScrapeRefresh(); const WIFLIX_FREE_PROXY_URL = "https://api.proxyscrape.com/v4/free-proxy-list/get?request=display_proxies&proxy_format=protocolipport&format=text"; const WIFLIX_FREE_PROXY_REFRESH_MS = 5 * 60 * 1000; // 5 minutes -const WIFLIX_FREE_PROXY_MAX_ATTEMPTS = 10; // Try up to 10 proxies per request +const WIFLIX_FREE_PROXY_MAX_ATTEMPTS = 3; // Try up to 3 proxies per request const WIFLIX_FREE_PROXY_TIMEOUT = 5000; // 5s per proxy attempt (fail fast) + +// Cookie requis sur l'endpoint search (do=search) sinon réponse "Bot shield active.". +// Le site vérifie h_check==25 exactement (posé par son JS). Bump ici si le site change la valeur. +const WIFLIX_H_CHECK = "25"; const wiflixFreeProxies = []; // HTTP-only proxy URL strings -const wiflixProxyAgentCache = new Map(); +const wiflixProxyAgentCache = new LruMap({ + max: PROXY_AGENT_CACHE_MAX, + onEvict: destroyHttpAgentLike, +}); const wiflixDeadProxies = new Set(); // Proxies who echoue — survit entre les requetes, reset au refresh async function fetchWiflixFreeProxies() { @@ -1740,13 +2148,25 @@ async function makeWiflixRequest(targetUrl, options = {}) { } = options; const method = (requestMethod || "GET").toUpperCase(); + + // Cookie anti "Bot shield active." : l'endpoint search (do=search) l'exige. + // Inoffensif sur les GET de pages. Mergé avec un éventuel Cookie fourni par l'appelant. + const callerCookie = headers.cookie || headers.Cookie; + const mergedHeaders = { ...headers }; + delete mergedHeaders.Cookie; + mergedHeaders.cookie = callerCookie + ? `h_check=${WIFLIX_H_CHECK}; ${callerCookie}` + : `h_check=${WIFLIX_H_CHECK}`; + // Re-propage à toutes les phases (le fallback CF Workers relit options.headers). + options = { ...options, headers: mergedHeaders }; + const defaultHeaders = { accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", - ...headers, + ...mergedHeaders, }; // --- Phase 1: free proxy list (skip already-dead ones) --- @@ -1796,6 +2216,107 @@ async function makeWiflixRequest(targetUrl, options = {}) { return makeRequestWithCorsFallback(targetUrl, options); } +// Set-Cookie[] -> "name=value; name2=value2" (drop path/expires/etc. attributes) +function extractCookieHeader(setCookie) { + if (!Array.isArray(setCookie) || setCookie.length === 0) return ""; + return setCookie + .map((c) => String(c).split(";")[0].trim()) + .filter(Boolean) + .join("; "); +} + +// One GET (harvest the bot-shield session cookie) + one POST (search) over the +// given axios proxy agents. Returns the response if it's a real result page, +// else throws so the caller rotates to the next proxy. +async function wiflixHandshake(homeUrl, searchUrl, baseHeaders, pdata, agents, timeout) { + const home = await axios({ + url: homeUrl, + method: "GET", + headers: { ...baseHeaders, referer: homeUrl }, + timeout, + decompress: true, + ...agents, + }); + + const harvested = extractCookieHeader(home.headers["set-cookie"]); + const cookie = harvested + ? `${harvested}; h_check=${WIFLIX_H_CHECK}` + : `h_check=${WIFLIX_H_CHECK}`; + + const res = await axios({ + url: searchUrl, + method: "POST", + headers: { ...baseHeaders, cookie }, + data: pdata, + timeout, + decompress: true, + ...agents, + }); + + const body = typeof res.data === "string" ? res.data : ""; + if ( + res.status === 200 && + body.length > 100 && + !body.includes("Bot shield active") + ) { + return res; + } + throw new Error(`wiflix search bad response (status ${res.status}, ${body.length}b)`); +} + +/** + * Wiflix search — the bot shield rejects do=search unless the request carries a + * real session cookie issued by the homepage GET (plus h_check=25). The session + * cookie is IP-bound, so the GET (harvest) and the POST (search) MUST run on the + * same proxy. Port of vStream wiflix.py showMovies() handshake. + */ +async function makeWiflixSearchRequest(homeUrl, searchUrl, options = {}) { + const { timeout = 10000, headers = {}, data: pdata } = options; + + const baseHeaders = { + accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + ...headers, + }; + + // --- Phase 1: free public proxies (residential) — handshake on same proxy --- + const alive = wiflixFreeProxies.filter((p) => !wiflixDeadProxies.has(p)); + if (alive.length > 0) { + const candidates = alive + .sort(() => Math.random() - 0.5) + .slice(0, WIFLIX_FREE_PROXY_MAX_ATTEMPTS); + + for (let i = 0; i < candidates.length; i++) { + const proxyUrl = candidates[i]; + try { + const res = await wiflixHandshake( + homeUrl, searchUrl, baseHeaders, pdata, + getWiflixFreeProxyAgent(proxyUrl), WIFLIX_FREE_PROXY_TIMEOUT, + ); + console.log( + `[WIFLIX SEARCH] OK via free proxy ${proxyUrl} (${i + 1}/${candidates.length})`, + ); + return res; + } catch { + wiflixDeadProxies.add(proxyUrl); + wiflixProxyAgentCache.delete(proxyUrl); + } + } + console.log(`[WIFLIX SEARCH] ${candidates.length} free proxies echoues, fallback CF Workers`); + } + + // --- Phase 2: CF Workers fallback — stateless single POST (h_check only) --- + return makeRequestWithCorsFallback(searchUrl, { + method: "POST", + data: pdata, + headers: { ...headers, cookie: `h_check=${WIFLIX_H_CHECK}` }, + timeout, + }); +} + // Initial fetch + 5-min refresh fetchWiflixFreeProxies(); setInterval(() => { @@ -1886,14 +2407,14 @@ module.exports = { ENABLE_WIFLIX_PROXY, darkiworld_premium, - // Darkino 403 cooldown (expose getter/setter since it's mutable) + // Darkino cooldowns (Redis-backed, cluster-wide) DARKINO_403_COOLDOWN_MS, - get darkino403CooldownUntil() { - return darkino403CooldownUntil; - }, - set darkino403CooldownUntil(val) { - darkino403CooldownUntil = val; - }, + DARKINO_5XX_COOLDOWN_MS, + DARKINO_NETERR_COOLDOWN_MS, + getDarkinoCooldownRemainingMs, + armDarkinoCooldown, + recordDarkinoNetFailure, + resetDarkinoNetFailures, // Cpasmal CPASMAL_BASE_URL, @@ -1913,9 +2434,14 @@ module.exports = { // Request helpers makeRequestWithCorsFallback, makeWiflixRequest, + makeWiflixSearchRequest, makeCoflixRequest, + classifyCloudflare429, makeLecteurVideoRequest, makeAnimeSamaRequest, + makeCinestreamRequest, + make1j1fRequest, + makeCpasmalRequest, // SOCKS5 proxies PROXIES, diff --git a/API/Mainapi/utils/syncPolicy.js b/API/Mainapi/utils/syncPolicy.js index c41692d..d93db26 100644 --- a/API/Mainapi/utils/syncPolicy.js +++ b/API/Mainapi/utils/syncPolicy.js @@ -35,9 +35,12 @@ const SYNCABLE_EXACT_KEYS = new Set([ 'subtitleStyle', 'support_popup_seen', 'user_language', - // SECURITY (audit P0) : `is_vip` retiré du sync — c'est juste un cache UI - // côté frontend qui doit être recalculé via /api/check-vip à chaque session. - // Le laisser syncable permettait à n'importe qui de forger son statut VIP. + // `is_vip` est un cache UI côté frontend. Le statut réel est validé côté + // serveur sur chaque requête sensible via /api/check-vip (x-access-key) ; + // forger `is_vip` en localStorage (sync ou DevTools) ne donne aucun accès + // VIP réel, juste un affichage trompeur. On le garde syncable pour que le + // statut suive l'utilisateur entre appareils sans recheck immédiat. + 'is_vip', 'watched_movie', 'watched_tv', 'watchPartyNickname' @@ -104,6 +107,16 @@ const SYNC_LIMITS = Object.freeze({ maxValueDepth: 8 }); +// Bornes affichage des identifiants utilisateur. +// `profileName` = nom du profil Movix (créé via /api/profiles ou /api/oauth/profiles). +// `displayName` = pseudo OAuth (Discord/Google) tel qu'on l'affiche dans l'UI. +// Discord limite à 32 chars ; Google peut aller plus loin → on tronque à 64. +const IDENTITY_LIMITS = Object.freeze({ + profileNameMin: 1, + profileNameMax: 32, + displayNameMax: 64, +}); + class SyncPolicyError extends Error { constructor(message, status = 400, code = 'SYNC_POLICY_ERROR') { super(message); @@ -113,6 +126,69 @@ class SyncPolicyError extends Error { } } +/** + * Valide un nom de profil utilisateur. + * + * - Trim + * - Retire les caractères de contrôle (U+0000–U+001F, U+007F) qui peuvent + * casser l'affichage ou être utilisés pour des attaques de spoofing. + * - Refuse les caractères zero-width (U+200B-U+200D, U+FEFF) pour la même raison. + * - Borne la longueur entre `profileNameMin` (1) et `profileNameMax` (32). + * + * Throw `SyncPolicyError` si invalide. Sinon retourne la chaîne nettoyée. + */ +// Caracteres dangereux a stripper avant validation/affichage des noms : +// \x00-\x1F\x7F : controle ASCII +// \u200B-\u200D : zero-width (spoofing) +// \uFEFF : BOM +const DANGEROUS_NAME_CHARS = /[\x00-\x1F\x7F\u200B-\u200D\uFEFF]/g; + +function cleanNameInput(raw) { + return String(raw).replace(DANGEROUS_NAME_CHARS, '').trim(); +} + +function validateProfileName(raw) { + if (typeof raw !== 'string') { + throw new SyncPolicyError('Le nom de profil doit etre une chaine', 400, 'INVALID_PROFILE_NAME'); + } + const cleaned = cleanNameInput(raw); + if (cleaned.length < IDENTITY_LIMITS.profileNameMin) { + throw new SyncPolicyError('Le nom de profil ne peut pas etre vide', 400, 'PROFILE_NAME_TOO_SHORT'); + } + if (cleaned.length > IDENTITY_LIMITS.profileNameMax) { + throw new SyncPolicyError( + `Le nom de profil ne peut pas depasser ${IDENTITY_LIMITS.profileNameMax} caracteres`, + 400, + 'PROFILE_NAME_TOO_LONG', + ); + } + return cleaned; +} + +/** + * Version booléenne de validateProfileName : ne throw pas, retourne juste + * un boolean. Utilisé sur les chemins de lecture (vérifier si le username + * stocké respecte la policy → si non, forcer l'user à le changer). + */ +function isProfileNameValid(raw) { + if (typeof raw !== 'string') return false; + const cleaned = cleanNameInput(raw); + return cleaned.length >= IDENTITY_LIMITS.profileNameMin + && cleaned.length <= IDENTITY_LIMITS.profileNameMax; +} + +/** + * Tronque proprement un display name (pseudo OAuth provider) pour l'affichage. + * Pas de throw : on accepte les valeurs legacy deja stockees, on les coupe juste + * si elles depassent. Ajoute une ellipse si tronque. + */ +function truncateDisplayName(raw, max = IDENTITY_LIMITS.displayNameMax) { + if (typeof raw !== 'string') return ''; + const cleaned = cleanNameInput(raw); + if (cleaned.length <= max) return cleaned; + return cleaned.slice(0, Math.max(1, max - 1)) + "\u2026"; +} + function canonicalPath(value) { const resolved = path.resolve(value); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; @@ -417,6 +493,7 @@ function getOwnedProfile(userData, profileId) { module.exports = { ALLOWED_SYNC_USER_TYPES, BLOCKED_SYNC_KEYS, + IDENTITY_LIMITS, SYNC_LIMITS, SyncPolicyError, assertUserDataSize, @@ -430,6 +507,9 @@ module.exports = { isSyncableStorageKey, resolveInside, sanitizeLegacySyncData, + isProfileNameValid, sanitizeProfileData, - validateAndNormalizeOps + truncateDisplayName, + validateAndNormalizeOps, + validateProfileName }; diff --git a/API/Mainapi/utils/vipDonations.js b/API/Mainapi/utils/vipDonations.js index 96a6844..4d68a37 100644 --- a/API/Mainapi/utils/vipDonations.js +++ b/API/Mainapi/utils/vipDonations.js @@ -499,6 +499,7 @@ async function fetchPaygateTemporaryWallet(callbackUrl) { const trackingAddress = String(response.data?.address_in || '').trim(); const temporaryWalletAddress = String(response.data?.polygon_address_in || '').trim(); const callbackUrlFromGateway = String(response.data?.callback_url || '').trim(); + const ipnToken = String(response.data?.ipn_token || '').trim(); if (!trackingAddress) { throw new Error('PayGate n\'a pas retourné d\'adresse de suivi'); @@ -507,10 +508,27 @@ async function fetchPaygateTemporaryWallet(callbackUrl) { return { trackingAddress, temporaryWalletAddress: temporaryWalletAddress || null, - callbackUrl: callbackUrlFromGateway || null + callbackUrl: callbackUrlFromGateway || null, + ipnToken: ipnToken || null }; } +// Poll officiel PayGate (payment-status.php) via l'ipn_token retourné par +// wallet.php. Filet de sécurité quand le callback GET de PayGate n'atteint +// jamais notre serveur (WAF, timeout, outage) — sinon l'invoice reste bloquée +// en awaiting_payment alors que le paiement est passé. +async function fetchPaygatePaymentStatus(ipnToken) { + const response = await axios.get( + 'https://api.paygate.to/control/payment-status.php', + { + params: { ipn_token: ipnToken }, + timeout: 15000 + } + ); + + return response.data || {}; +} + async function normalizePaygatePaidUsdValue(receivedCoin, paidValue) { const rawValue = parseNumber(paidValue); if (!rawValue) { @@ -1063,6 +1081,23 @@ async function refreshInvoiceStatus(pool, invoiceInput, options = {}) { } if (isPaygatePaymentMethod(paymentMethod)) { + // Fallback officiel PayGate: si le callback n'est jamais arrivé, on + // interroge payment-status.php avec l'ipn_token stocké à la création. + // Couvre aussi les invoices expirées côté Movix mais payées côté PayGate. + if (invoice.status !== 'paid' && invoice.paygate_ipn_token) { + try { + const statusPayload = await fetchPaygatePaymentStatus(invoice.paygate_ipn_token); + if (String(statusPayload?.status || '').trim().toLowerCase() === 'paid') { + return markPaygateInvoicePaidFromStatusPoll(pool, invoice, statusPayload, { + actorType: options.actorType || 'system', + actorId: options.actorId || null + }); + } + } catch (error) { + // Non-fatal — poll de secours, le callback reste la voie principale. + } + } + const isExpired = fromSqlDateTime(invoice.expires_at)?.getTime() < Date.now(); const shouldExpire = invoice.status === 'awaiting_payment' && isExpired; @@ -1089,8 +1124,10 @@ async function refreshInvoiceStatus(pool, invoiceInput, options = {}) { options.actorId || null ); } else { + // 60s (pas 25s): payment-status.php est doc'd "casual basis only", + // on espace les polls PayGate pour éviter le rate limit. await pool.execute( - 'UPDATE vip_invoices SET next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND), updated_at = NOW() WHERE id = ?', + 'UPDATE vip_invoices SET next_check_at = DATE_ADD(NOW(), INTERVAL 60 SECOND), updated_at = NOW() WHERE id = ?', [invoice.id] ); } @@ -1428,10 +1465,10 @@ async function createPaygateVipInvoice(pool, payload, context = {}) { vip_key_value, created_by_user_id, created_by_user_type, created_by_session_id, created_ip_hash, expires_at, next_check_at, paygate_tracking_address, paygate_temporary_wallet_address, paygate_callback_url, paygate_callback_nonce, paygate_checkout_url, paygate_payer_email, - paygate_paid_coin, paygate_paid_value, paygate_paid_txid, created_at, updated_at) + paygate_ipn_token, paygate_paid_coin, paygate_paid_value, paygate_paid_txid, created_at, updated_at) VALUES (?, 'paygate_hosted', 'awaiting_payment', NULL, ?, ?, ?, 0, 0, ?, ?, ?, NULL, -1, 0, 0, NULL, '', ?, 1, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, NOW(), - ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NOW(), NOW())`, + ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NOW(), NOW())`, [ publicId, pack.amountEur, @@ -1451,7 +1488,8 @@ async function createPaygateVipInvoice(pool, payload, context = {}) { paygateWallet.callbackUrl || callbackUrl, callbackNonce, checkoutUrl, - normalizedEmail + normalizedEmail, + paygateWallet.ipnToken ] ); @@ -1687,6 +1725,109 @@ async function handlePaygateCallback(pool, payload = {}) { return refreshedInvoice; } +// Marque une invoice PayGate payée depuis un poll payment-status.php confirmé. +// Même seuil (min ratio USD) et mêmes colonnes que handlePaygateCallback, mais +// sans nonce : l'ipn_token stocké en DB authentifie déjà la requête sortante. +async function markPaygateInvoicePaidFromStatusPoll(pool, invoiceInput, statusPayload, options = {}) { + const baseInvoice = invoiceInput?.id ? invoiceInput : await fetchInvoiceById(pool, invoiceInput); + if (!baseInvoice) return null; + if (FINAL_STATUSES.has(baseInvoice.status)) return baseInvoice; + if (!isPaygatePaymentMethod(getInvoicePaymentMethod(baseInvoice))) return baseInvoice; + + const paidCoin = String(statusPayload?.coin || '').trim().toLowerCase() || null; + const paidTxid = String(statusPayload?.txid_out || '').trim() || null; + const paidValue = await normalizePaygatePaidUsdValue(paidCoin, statusPayload?.value_coin); + const minimumPaidAmount = roundFiat(parseNumber(baseInvoice.amount_usd) * getPaygateMinPaidRatio()); + const nextStatus = paidValue >= minimumPaidAmount && paidValue > 0 ? 'paid' : 'partial_payment'; + + // Idempotence: poll répété sans changement → pas d'UPDATE ni d'event spam. + if ( + baseInvoice.status === nextStatus + && roundFiat(parseNumber(baseInvoice.paygate_paid_value)) === paidValue + && (paidTxid || null) === (baseInvoice.paygate_paid_txid || null) + ) { + return baseInvoice; + } + + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + + const [rows] = await connection.execute( + 'SELECT * FROM vip_invoices WHERE id = ? FOR UPDATE', + [baseInvoice.id] + ); + const invoice = rows[0]; + if (!invoice || FINAL_STATUSES.has(invoice.status)) { + await connection.commit(); + return invoice || null; + } + + const shouldMarkPaidAt = nextStatus === 'paid' && !invoice.paid_at; + + await connection.execute( + `UPDATE vip_invoices + SET status = ?, + tx_hash = COALESCE(?, tx_hash), + paid_at = CASE WHEN ? THEN COALESCE(paid_at, NOW()) ELSE paid_at END, + paygate_paid_coin = COALESCE(?, paygate_paid_coin), + paygate_paid_value = ?, + paygate_paid_txid = COALESCE(?, paygate_paid_txid), + next_check_at = DATE_ADD(NOW(), INTERVAL 25 SECOND), + updated_at = NOW() + WHERE id = ?`, + [ + nextStatus, + paidTxid, + shouldMarkPaidAt ? 1 : 0, + paidCoin, + paidValue, + paidTxid, + invoice.id + ] + ); + + await logVipInvoiceEvent( + connection, + invoice.id, + 'invoice_paygate_status_poll', + nextStatus === 'paid' + ? 'Paiement PayGate confirmé via payment-status.php (callback manqué).' + : 'Poll payment-status.php PayGate: montant insuffisant.', + { + previousStatus: invoice.status, + nextStatus, + paidCoin, + paidValue, + paidTxid, + minimumPaidAmount + }, + options.actorType || 'system', + options.actorId || null + ); + + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + + const refreshed = await fetchInvoiceById(pool, baseInvoice.id); + if (refreshed?.status === 'paid') { + return deliverInvoiceIfReady( + pool, + refreshed.id, + options.actorType || 'system', + options.actorId || null, + 'paygate_status_poll' + ); + } + + return refreshed; +} + async function handlePayblisIpn(pool, { publicId, body, sourceIp, headerSignature }) { const payblisMod = require('./vipPayblis'); const secretKey = payblisMod.getPayblisSecretKey(); @@ -2285,6 +2426,18 @@ async function normalizeVipInvoicePaymentMethods(pool) { WHERE payment_method IS NOT NULL `); + // payblis_ref_order is written only by createPayblisVipInvoice, so it is the + // authoritative marker of a Payblis invoice. Pin payment_method to 'payblis' + // here — both to recover rows a previous migration run mislabelled, and to + // stop the autobuy catch-all below (which predates the Payblis method) from + // rewriting them to 'autobuy' on every restart. + await pool.execute(` + UPDATE vip_invoices + SET payment_method = 'payblis' + WHERE payblis_ref_order IS NOT NULL + AND (payment_method IS NULL OR payment_method <> 'payblis') + `); + await pool.execute(` UPDATE vip_invoices SET payment_method = LOWER(TRIM(coin)) @@ -2374,7 +2527,7 @@ async function normalizeVipInvoicePaymentMethods(pool) { SET autobuy_gateway = COALESCE(NULLIF(autobuy_gateway, ''), payment_method), payment_method = 'autobuy' WHERE payment_method IS NOT NULL - AND payment_method NOT IN ('btc', 'ltc', 'paygate_hosted', 'autobuy') + AND payment_method NOT IN ('btc', 'ltc', 'paygate_hosted', 'autobuy', 'payblis') `); await pool.execute(` @@ -2449,6 +2602,7 @@ async function ensureVipDonationsTables(pool) { paygate_callback_nonce VARCHAR(128) DEFAULT NULL, paygate_checkout_url TEXT DEFAULT NULL, paygate_payer_email VARCHAR(255) DEFAULT NULL, + paygate_ipn_token VARCHAR(512) DEFAULT NULL, paygate_paid_coin VARCHAR(64) DEFAULT NULL, paygate_paid_value DECIMAL(18,8) DEFAULT NULL, paygate_paid_txid VARCHAR(255) DEFAULT NULL, @@ -2537,6 +2691,12 @@ async function ensureVipDonationsTables(pool) { 'paygate_payer_email', "`paygate_payer_email` VARCHAR(255) DEFAULT NULL AFTER `paygate_checkout_url`" ); + await ensureColumnExists( + pool, + 'vip_invoices', + 'paygate_ipn_token', + "`paygate_ipn_token` VARCHAR(512) DEFAULT NULL AFTER `paygate_payer_email`" + ); await ensureColumnExists( pool, 'vip_invoices', diff --git a/API/Mainapi/wishboardRoutes.js b/API/Mainapi/wishboardRoutes.js index b973be0..7472835 100644 --- a/API/Mainapi/wishboardRoutes.js +++ b/API/Mainapi/wishboardRoutes.js @@ -946,12 +946,11 @@ function createWishboardRouter(mysqlPool, redis) { } } - // Resolve user data (username, avatar) for each admin via the - // shared helper — prefers OAuth provider identity over the - // generic Movix profile, so we display "Maxou DM" instead of - // "Admin" / "Profil". + // Resolve user data (username, avatar) for each greenlighter via the + // shared helper. Greenlight uses the user's FIRST Movix profile + // (preferProfile) for pdp + username, not the OAuth/Discord identity. const leaderboard = await Promise.all(rows.map(async (row) => { - const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type); + const identity = await resolveAdminIdentity(row.admin_id, row.admin_auth_type, { preferProfile: true }); return { admin_id: row.admin_id, admin_auth_type: row.admin_auth_type, diff --git a/API/Mainapi/wrappedRoutes.js b/API/Mainapi/wrappedRoutes.js index 6ff7ad0..6d23956 100644 --- a/API/Mainapi/wrappedRoutes.js +++ b/API/Mainapi/wrappedRoutes.js @@ -161,12 +161,15 @@ function buildWrappedProgress({ totalMinutes, uniqueTitles, totalSessions, total */ function extractWrappedFields(data) { if (!data) return null; + const releaseDate = data.release_date || data.first_air_date || null; return { title: data.title || data.name, poster_path: data.poster_path, + backdrop_path: data.backdrop_path || null, genres: (data.genres || []).map(g => typeof g === 'string' ? g : g.name), vote_average: data.vote_average || null, - runtime: data.runtime || data.episode_run_time?.[0] || null + runtime: data.runtime || data.episode_run_time?.[0] || null, + release_year: releaseDate ? parseInt(String(releaseDate).slice(0, 4)) || null : null }; } @@ -640,19 +643,29 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = return pct; } catch { /* fallback to SQL */ } } - // Expensive SQL — runs once per 30 min then cached for all users/workers - const pctT0 = Date.now(); - const [allUsersStats] = await pool.execute(` - SELECT user_id, ROUND(SUM(watch_duration) / 60) as total - FROM wrapped_viewing_data WHERE year = ? - GROUP BY user_id ORDER BY total DESC - `, [year]); - _sqlTimings['percentile'] = Date.now() - pctT0; - console.log(`[Wrapped][PERF] 📊 Percentile: SQL in ${_sqlTimings['percentile']}ms (${allUsersStats.length} users)`); - redisSet(pctCacheKey, JSON.stringify(allUsersStats), PERCENTILE_CACHE_TTL); - const totalUsers = allUsersStats.length; - const userRank = allUsersStats.findIndex(u => u.user_id === userId) + 1; - return totalUsers > 1 ? Math.round(((totalUsers - userRank) / totalUsers) * 100) : 99; + // Cache froid : ne JAMAIS bloquer la réponse. On renvoie null et on + // réchauffe le cache en arrière-plan pour les prochains appels. + _sqlTimings['percentile'] = 0; + setImmediate(async () => { + try { + // Single-flight cluster-wide : un seul worker lance le scan + if (redisReady()) { + const lock = await redis.set(`${PERCENTILE_CACHE_PREFIX}${year}:lock`, '1', 'EX', 300, 'NX'); + if (!lock) return; + } + const t0 = Date.now(); + const [allUsersStats] = await pool.execute(` + SELECT user_id, ROUND(SUM(watch_duration) / 60) as total + FROM wrapped_viewing_data WHERE year = ? + GROUP BY user_id ORDER BY total DESC + `, [year]); + redisSet(pctCacheKey, JSON.stringify(allUsersStats), PERCENTILE_CACHE_TTL); + console.log(`[Wrapped][PERF] 📊 Percentile warmup (background): ${Date.now() - t0}ms (${allUsersStats.length} users)`); + } catch (e) { + console.warn('[Wrapped] Percentile warmup failed:', e.message); + } + }); + return null; } // ── 1. Fire ALL queries in parallel (user-scoped + percentile) ────────── @@ -667,6 +680,9 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = [dailyActivity], [firstWatch], [lastWatch], + [recordDayRows], + [weekdayRows], + [rewatchRows], percentile ] = await Promise.all([ // 1. Viewing Stats @@ -693,6 +709,7 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = content_type, ROUND(SUM(watch_duration) / 60) as duration FROM wrapped_viewing_data ${whereClause} + AND content_type != 'live-tv' GROUP BY content_id, content_type ORDER BY duration DESC LIMIT 10 `, queryParams)), @@ -730,6 +747,7 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = timedQuery('firstWatch', () => pool.execute(` SELECT content_title, content_type, content_id, created_at FROM wrapped_viewing_data ${whereClause} + AND content_type != 'live-tv' ORDER BY created_at ASC LIMIT 1 `, queryParams)), @@ -737,9 +755,44 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = timedQuery('lastWatch', () => pool.execute(` SELECT content_title, content_type, content_id, created_at FROM wrapped_viewing_data ${whereClause} + AND content_type != 'live-tv' ORDER BY created_at DESC LIMIT 1 `, queryParams)), + // 9a. Jour record (journée avec le plus de visionnage) + timedQuery('recordDay', () => pool.execute(` + SELECT DATE(created_at) as day, ROUND(SUM(watch_duration) / 60) as minutes + FROM wrapped_viewing_data ${whereClause} + GROUP BY DATE(created_at) ORDER BY minutes DESC LIMIT 1 + `, queryParams)), + + // 9b. Répartition par jour de semaine (DAYOFWEEK MySQL : 1=dimanche … 7=samedi) + timedQuery('weekdayStats', () => pool.execute(` + SELECT DAYOFWEEK(created_at) as dow, ROUND(SUM(watch_duration) / 60) as minutes + FROM wrapped_viewing_data ${whereClause} + GROUP BY dow + `, queryParams)), + + // 9c. Rewatch champion : film revu sur ≥3 jours distincts, ou même épisode revu sur ≥2 jours + timedQuery('rewatch', () => pool.execute(` + SELECT content_id, content_type, content_title, distinct_days FROM ( + SELECT content_id, content_type, + COALESCE(MAX(NULLIF(content_title, '')), MAX(content_title)) as content_title, + COUNT(DISTINCT DATE(created_at)) as distinct_days + FROM wrapped_viewing_data ${whereClause} AND content_type = 'movie' + GROUP BY content_id, content_type + HAVING distinct_days >= 3 + UNION ALL + SELECT content_id, content_type, + COALESCE(MAX(NULLIF(content_title, '')), MAX(content_title)) as content_title, + COUNT(DISTINCT DATE(created_at)) as distinct_days + FROM wrapped_viewing_data ${whereClause} + AND content_type IN ('tv', 'anime') AND season_number IS NOT NULL AND episode_number IS NOT NULL + GROUP BY content_id, content_type, season_number, episode_number + HAVING distinct_days >= 2 + ) rw ORDER BY distinct_days DESC LIMIT 1 + `, [...queryParams, ...queryParams])), + // 10. Percentile (Redis-cached 30min, SQL fallback) fetchPercentile() ]); @@ -759,15 +812,13 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = const progress = buildWrappedProgress({ totalMinutes, uniqueTitles, totalSessions, totalActiveDays }); if (!progress.isEligible) { - console.log(`[Wrapped][PERF] Not enough data yet — total ${Date.now() - _t.start}ms`); + console.log(`[Wrapped][PERF] Not enough data yet — total ${Date.now() - _t.start}ms`); return res.json({ success: true, wrapped: null, progress, - message: "Pas encore assez de données pour débloquer ce Wrapped." + message: "Pas encore assez de données pour débloquer ce Wrapped." }); - console.log(`[Wrapped][PERF] No data — total ${Date.now() - _t.start}ms`); - return res.json({ success: true, wrapped: null, message: "Pas encore de données pour cette année." }); } // ── 2. TMDB enrichment (parallel, Redis-cached) ──────────────────────── @@ -861,8 +912,8 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = return arr.map(c => { const k = `${c.content_type}:${c.content_id}`; const d = tmdbCache.get(k); - if (!d) return { ...c, content_title: c.content_title || `${c.content_type} #${c.content_id}`, poster_path: c.poster_path || null, genres: [], vote_average: null }; - return { ...c, content_title: d.title || c.content_title, poster_path: d.poster_path || c.poster_path || null, genres: d.genres || [], vote_average: d.vote_average || null }; + if (!d) return { ...c, content_title: c.content_title || `${c.content_type} #${c.content_id}`, poster_path: c.poster_path || null, backdrop_path: null, genres: [], vote_average: null, release_year: null }; + return { ...c, content_title: d.title || c.content_title, poster_path: d.poster_path || c.poster_path || null, backdrop_path: d.backdrop_path || null, genres: d.genres || [], vote_average: d.vote_average || null, release_year: d.release_year || null }; }); } const enrichedTopContent = applyTMDB(topContent); @@ -941,9 +992,6 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = const topShowTitle = topShow ? topShow.content_title : null; const topShowType = topShow ? topShow.content_type : null; - const secondShow = enrichedTopContent[1] || null; - const secondShowTitle = secondShow ? secondShow.content_title : null; - // Peak / lowest month const peakMonth = monthlyStats[0] || { month: 1, duration: 0 }; const lowestMonth = monthlyStats[monthlyStats.length - 1] || peakMonth; @@ -964,6 +1012,47 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = const moviePercent = movieStats ? Math.round((parseInt(movieStats.duration) / totalMinutes) * 100) : 0; const tvPercent = tvStats ? Math.round((parseInt(tvStats.duration) / totalMinutes) * 100) : 0; + // Jour record / weekday / rewatch + const recordDayRow = recordDayRows[0] || null; + const recordDay = recordDayRow + ? { date: recordDayRow.day, minutes: parseInt(recordDayRow.minutes) } + : null; + + const weekdayMap = new Array(7).fill(0); // index 0 = dimanche … 6 = samedi + weekdayRows.forEach(r => { weekdayMap[r.dow - 1] = parseInt(r.minutes); }); + const weekday = weekdayMap.map((minutes, i) => ({ dow: i + 1, minutes })); + + const rewatchRow = rewatchRows[0] || null; + + // Âge ciné : année médiane de sortie, pondérée par le temps de visionnage (top 10) + const datedItems = enrichedForGenres.filter(c => c.release_year); + let watchAgeYear = null; + if (datedItems.length >= 5) { + const sortedByYear = [...datedItems].sort((a, b) => a.release_year - b.release_year); + const totalWeight = sortedByYear.reduce((s, c) => s + parseInt(c.duration), 0); + let acc = 0; + for (const c of sortedByYear) { + acc += parseInt(c.duration); + if (acc >= totalWeight / 2) { watchAgeYear = c.release_year; break; } + } + } + + let rewatchData = null; + if (rewatchRow) { + let rwTitle = rewatchRow.content_title; + if (!rwTitle) { + const cached = tmdbCache.get(`${rewatchRow.content_type}:${rewatchRow.content_id}`); + if (cached) rwTitle = cached.title; + else { + try { + const d = await fetchTMDBDetails(rewatchRow.content_id, rewatchRow.content_type); + rwTitle = d?.title || null; + } catch { /* slide skippée si pas de titre */ } + } + } + if (rwTitle) rewatchData = { title: rwTitle, type: rewatchRow.content_type, count: parseInt(rewatchRow.distinct_days) }; + } + _t.computeEnd = Date.now(); console.log(`[Wrapped][PERF] 🧮 Compute stats: ${_t.computeEnd - _t.computeStart}ms`); @@ -972,23 +1061,24 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = const persona = determinePersona({ totalHours, uniqueTitles, dominantPercent, dominantType, animePercent, moviePercent, tvPercent, isExplorer, isLoyal, isBinger, - topShowType, topShowHours, isNightOwl + topShowType, topShowHours, isNightOwl, isEarlyBird, + topGenres, avgSessionMinutes, longestStreak, totalSessions, percentile }); const slides = generateSlides({ totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles, topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, topShowType, - secondShowTitle, secondShow, dominantType, dominantPercent, peakMonth, lowestMonth, monthNames, animePercent, moviePercent, tvPercent, isExplorer, isLoyal, isBinger, enrichedTopContent, typeStats, persona, - year, + year, userId, percentile, peakHour, isNightOwl, isEarlyBird, longestStreak, totalActiveDays, firstWatchData, lastWatchData, - topGenres, avgSessionMinutes + topGenres, avgSessionMinutes, totalSessions, + recordDay, rewatchData, watchAgeYear, topPages, weekday }); _t.slidesEnd = Date.now(); @@ -1016,6 +1106,9 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = durationLabel: formatDurationShort(mins), tmdbId: c.content_type !== 'live-tv' ? parseInt(c.content_id) : null, poster_path: c.poster_path, + backdrop_path: c.backdrop_path || null, + year: c.release_year || null, + vote_average: c.vote_average || null, genres: c.genres || [] }; }), @@ -1040,7 +1133,11 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = date: lastWatchData.created_at, tmdbId: lastWatchData.content_type !== 'live-tv' ? parseInt(lastWatchData.content_id) : null } : null, - topPages: topPages.map(p => ({ page: p.page_name, minutes: parseInt(p.duration) })) + topPages: topPages.map(p => ({ page: p.page_name, minutes: parseInt(p.duration) })), + recordDay, + weekday, + rewatch: rewatchData, + watchAgeYear }; // ── 6. Store in Redis cache (fire-and-forget) ─────────────────────────── @@ -1062,6 +1159,51 @@ router.get('/generate/:year', verifyToken, generateRateLimit, async (req, res) = // === FONCTIONS DE GÉNÉRATION TEMPLATES === // ============================================ +// ─── Sélection déterministe (hash FNV-1a) ─────────────────────────────────── +// Même Wrapped à chaque ouverture pour un même user/année (screenshots stables). +function fnv1aHash(str) { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +function seededShuffle(array, seedStr) { + const arr = [...array]; + let seed = fnv1aHash(seedStr); + for (let i = arr.length - 1; i > 0; i--) { + seed = Math.imul(seed ^ (seed >>> 15), 0x01000193) >>> 0; + const j = seed % (i + 1); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// ─── Personas par genre dominant (genre #1 ≥ 30% du temps) ────────────────── +const GENRE_PERSONAS = [ + { keys: ['horreur', 'horror', 'épouvante'], persona: { id: 'horror', title: 'L\'Accro aux Frissons', emoji: '👻', subtitle: 'Tu dors la lumière allumée, et alors ?', description: 'L\'horreur, c\'est ton terrain de jeu.', color: '#B71C1C' } }, + { keys: ['thriller', 'crime', 'mystère', 'mystery', 'policier'], persona: { id: 'thriller', title: 'Le Profiler', emoji: '🕵️', subtitle: 'Tu résous l\'enquête avant le générique', description: 'Le suspense, tu carbures à ça.', color: '#37474F' } }, + { keys: ['romance'], persona: { id: 'romance', title: 'Le Grand Romantique', emoji: '💘', subtitle: 'Tu crois encore au grand amour', description: 'Une belle histoire et tu fonds.', color: '#EC407A' } }, + { keys: ['comédie', 'comedy'], persona: { id: 'comedy', title: 'Le Roi du Rire', emoji: '😎', subtitle: 'La vie est trop courte pour le drame', description: 'Tu choisis toujours l\'option qui fait marrer.', color: '#FBC02D' } }, + { keys: ['science-fiction', 'science fiction', 'sci-fi', 'fantastique', 'fantasy'], persona: { id: 'scifi', title: 'Le Voyageur', emoji: '🚀', subtitle: 'Tu vis mieux dans d\'autres mondes', description: 'Plus à l\'aise dans le futur qu\'au présent.', color: '#7E57C2' } }, + { keys: ['documentaire', 'documentary'], persona: { id: 'documentary', title: 'L\'Éternel Curieux', emoji: '🔬', subtitle: 'Tu apprends un truc par épisode', description: 'Le réel te passionne plus que la fiction.', color: '#00897B' } }, + { keys: ['action', 'aventure', 'adventure'], persona: { id: 'action', title: 'L\'Accro à l\'Adrénaline', emoji: '💥', subtitle: 'Plus ça explose, plus t\'es content', description: 'Tu carbures aux poursuites et aux gros boums.', color: '#F4511E' } }, + { keys: ['drame', 'drama'], persona: { id: 'drama', title: 'L\'Âme Sensible', emoji: '🎭', subtitle: 'Tu chiales et t\'assumes', description: 'Les belles histoires, ça te remue.', color: '#5C6BC0' } }, + { keys: ['animation', 'familial', 'family'], persona: { id: 'animation', title: 'Le Grand Enfant', emoji: '🎨', subtitle: 'L\'animation, c\'est pas que pour les petits', description: 'Ton âme d\'enfant se porte très bien.', color: '#26A69A' } }, +]; + +function matchGenrePersona(topGenres) { + const top = Array.isArray(topGenres) && topGenres[0]; + if (!top || top.percent < 30 || !top.name) return null; + const name = top.name.toLowerCase(); + for (const g of GENRE_PERSONAS) { + if (g.keys.some(k => name.includes(k))) return g.persona; + } + return null; +} + /** * Détermine la personnalité de visionnage de l'utilisateur * Basé sur des règles de classification (comme le ML de Spotify) @@ -1070,201 +1212,56 @@ function determinePersona(data) { const { totalHours, uniqueTitles, dominantPercent, dominantType, animePercent, moviePercent, tvPercent, isExplorer, isLoyal, isBinger, - topShowType, topShowHours, isNightOwl + topShowType, topShowHours, isNightOwl, isEarlyBird, + topGenres, avgSessionMinutes, longestStreak, totalSessions, percentile } = data; - // Personas par priorité (le premier qui matche gagne) const personas = [ - // --- PERSONAS EXTRÊMES (basés sur l'intensité) --- - { - condition: totalHours > 1000, - persona: { - id: 'legend', - title: 'La Légende Vivante', - emoji: '👑', - subtitle: 'Tu as littéralement vécu sur Movix cette année', - description: 'Plus de 1000 heures. On devrait te payer à ce stade.', - color: '#FFD700' - } - }, - { - condition: totalHours > 500, - persona: { - id: 'marathon', - title: 'Le Marathonien Ultime', - emoji: '🏃‍♂️', - subtitle: 'Ton canapé a une empreinte permanente de toi', - description: 'Tu as transformé le binge-watching en sport olympique.', - color: '#FF6B35' - } - }, - // --- NIGHT OWL --- - { - condition: isNightOwl && totalHours > 100, - persona: { - id: 'night-owl', - title: 'L\'Oiseau de Nuit', - emoji: '🦉', - subtitle: 'La nuit, tous les écrans sont gris... sauf le tien', - description: 'Tu fais tes meilleures sessions entre minuit et 5h.', - color: '#1A237E' - } - }, - // --- PERSONAS PAR TYPE DOMINANT --- - { - condition: animePercent > 80, - persona: { - id: 'weeb-supreme', - title: 'Weeb Suprême', - emoji: '⛩️', - subtitle: 'Tu penses en sous-titres à ce stade', - description: 'Plus de 80% d\'anime. Tu es officiellement plus japonais que français.', - color: '#E91E63' - } - }, - { - condition: animePercent > 50, - persona: { - id: 'otaku', - title: 'L\'Otaku Assumé', - emoji: '🍜', - subtitle: 'Ton cœur bat au rythme des openings', - description: 'Les animes ne sont pas une phase, c\'est un mode de vie.', - color: '#9C27B0' - } - }, - { - condition: moviePercent > 70 && uniqueTitles > 50, - persona: { - id: 'critic', - title: 'Le Critique', - emoji: '🎬', - subtitle: 'Tu as un avis sur tout, et il est probablement juste', - description: 'Tu pourrais écrire pour les Cahiers du Cinéma.', - color: '#2196F3' - } - }, - { - condition: moviePercent > 70, - persona: { - id: 'cinephile', - title: 'Le Cinéphile', - emoji: '🎥', - subtitle: 'Le 7ème art coule dans tes veines', - description: 'Les films, c\'est ta religion.', - color: '#3F51B5' - } - }, - { - condition: tvPercent > 70 && isBinger, - persona: { - id: 'binger', - title: 'Le Binge-Watcher Pro', - emoji: '📺', - subtitle: '"Encore un épisode" est ton mantra', - description: 'Tu ne regardes pas les séries, tu les dévores.', - color: '#4CAF50' - } - }, - { - condition: tvPercent > 50, - persona: { - id: 'series-addict', - title: 'L\'Accro aux Séries', - emoji: '📡', - subtitle: 'Tu connais plus de personnages fictifs que de vraies personnes', - description: 'Les séries sont ta deuxième famille.', - color: '#009688' - } - }, - // --- PERSONAS PAR COMPORTEMENT --- - { - condition: isExplorer && uniqueTitles > 100, - persona: { - id: 'explorer-elite', - title: 'L\'Explorateur d\'Élite', - emoji: '🧭', - subtitle: 'Tu as vu des trucs dont personne n\'a entendu parler', - description: 'Plus de 100 titres ! Tu es la définition de la curiosité.', - color: '#FF9800' - } - }, - { - condition: isExplorer, - persona: { - id: 'explorer', - title: 'L\'Explorateur', - emoji: '🔍', - subtitle: 'Toujours en quête de la prochaine pépite', - description: 'Tu préfères découvrir que revoir.', - color: '#FFC107' - } - }, - { - condition: isLoyal && topShowHours > 50, - persona: { - id: 'superfan', - title: 'Le Superfan', - emoji: '💜', - subtitle: 'Tu as trouvé TON truc et tu t\'y tiens', - description: 'La loyauté, c\'est ta plus grande qualité.', - color: '#673AB7' - } - }, - { - condition: isLoyal, - persona: { - id: 'comfort-watcher', - title: 'L\'Amateur de Confort', - emoji: '🛋️', - subtitle: 'Pourquoi changer une équipe qui gagne ?', - description: 'Tu remates tes classiques, et c\'est très bien comme ça.', - color: '#795548' - } - } + // --- Intensité --- + { condition: totalHours > 1000, persona: { id: 'legend', title: 'La Légende Vivante', emoji: '👑', subtitle: 'T\'as carrément habité sur Movix', description: 'Plus de 1000h. On devrait te salarier.', color: '#FFD700' } }, + { condition: totalHours > 500, persona: { id: 'marathon', title: 'Le Marathonien Ultime', emoji: '🏃', subtitle: 'Ton canapé a pris ta forme', description: 'T\'as fait du binge un sport olympique.', color: '#FF6B35' } }, + { condition: percentile != null && percentile >= 99 && totalHours > 200, persona: { id: 'elite-1pct', title: 'Le Top 1%', emoji: '💎', subtitle: 'Dans le club très fermé', description: 'Tu regardes plus que 99% des gens ici. Respect.', color: '#00E5FF' } }, + // --- Rythme --- + { condition: isNightOwl && totalHours > 100, persona: { id: 'night-owl', title: 'L\'Oiseau de Nuit', emoji: '🦉', subtitle: 'La nuit, ton écran est le seul allumé', description: 'Tes meilleures sessions ? Entre minuit et 5h.', color: '#1A237E' } }, + { condition: isEarlyBird && totalHours > 100, persona: { id: 'early-bird', title: 'Le Lève-tôt', emoji: '🌅', subtitle: 'Un épisode avant le café', description: 'Tu lances Movix quand les autres dorment encore.', color: '#FF8A65' } }, + // --- Type dominant --- + { condition: animePercent > 80, persona: { id: 'weeb-supreme', title: 'Weeb Suprême', emoji: '⛩️', subtitle: 'Tu penses en sous-titres', description: '+80% d\'anime. T\'es plus à Tokyo qu\'à Paris.', color: '#E91E63' } }, + { condition: animePercent > 50, persona: { id: 'otaku', title: 'L\'Otaku Assumé', emoji: '🍜', subtitle: 'Ton cœur bat au rythme des openings', description: 'L\'anime, c\'est pas une phase, c\'est un mode de vie.', color: '#9C27B0' } }, + { condition: moviePercent > 70 && uniqueTitles > 50, persona: { id: 'critic', title: 'Le Critique', emoji: '🎬', subtitle: 'Un avis sur tout, et souvent le bon', description: 'Tu pourrais noter des films pour de vrai.', color: '#2196F3' } }, + { condition: moviePercent > 70, persona: { id: 'cinephile', title: 'Le Cinéphile', emoji: '🎥', subtitle: 'Le 7e art coule dans tes veines', description: 'Les films, c\'est ta religion.', color: '#3F51B5' } }, + { condition: tvPercent > 70 && isBinger, persona: { id: 'binger', title: 'Le Binge-Watcher Pro', emoji: '📺', subtitle: '"Encore un épisode" = ton mantra', description: 'Tu regardes pas les séries, tu les dévores.', color: '#4CAF50' } }, + { condition: tvPercent > 50, persona: { id: 'series-addict', title: 'L\'Accro aux Séries', emoji: '📡', subtitle: 'Plus de persos fictifs que d\'amis (gentiment)', description: 'Les séries, c\'est ta deuxième famille.', color: '#009688' } }, ]; - // Chercher le premier persona qui matche for (const p of personas) { - if (p.condition === true) { - return p.persona; - } + if (p.condition === true) return p.persona; } - // Persona par défaut basé sur le type dominant + // --- Genre dominant (si ≥30% du temps) --- + const genrePersona = matchGenrePersona(topGenres); + if (genrePersona) return genrePersona; + + // --- Comportement --- + const behaviorPersonas = [ + { condition: isExplorer && uniqueTitles > 100, persona: { id: 'explorer-elite', title: 'L\'Explorateur d\'Élite', emoji: '🧭', subtitle: 'Tu connais des trucs que personne connaît', description: '+100 titres. La curiosité incarnée.', color: '#FF9800' } }, + { condition: isExplorer, persona: { id: 'explorer', title: 'L\'Explorateur', emoji: '🔍', subtitle: 'Toujours en chasse de la prochaine pépite', description: 'Tu préfères découvrir que revoir.', color: '#FFC107' } }, + { condition: totalSessions > 50 && avgSessionMinutes < 25, persona: { id: 'snacker', title: 'Le Grignoteur', emoji: '🍿', subtitle: 'Tu mates par petites bouchées', description: 'Sessions courtes mais souvent. Le snacking version streaming.', color: '#FF7043' } }, + { condition: longestStreak >= 14, persona: { id: 'streak-machine', title: 'La Machine', emoji: '⚙️', subtitle: 'Aucun jour off', description: 'T\'enchaînes les jours sans jamais lâcher.', color: '#607D8B' } }, + { condition: isLoyal && topShowHours > 50, persona: { id: 'superfan', title: 'Le Superfan', emoji: '💜', subtitle: 'T\'as trouvé TON truc, tu lâches plus', description: 'La loyauté, c\'est ta signature.', color: '#673AB7' } }, + { condition: isLoyal, persona: { id: 'comfort-watcher', title: 'L\'Amateur de Confort', emoji: '🛋️', subtitle: 'Pourquoi changer une équipe qui gagne ?', description: 'Tu remates tes classiques, et c\'est très bien.', color: '#795548' } }, + { condition: dominantPercent < 45, persona: { id: 'omnivore', title: 'Le Touche-à-tout', emoji: '🎲', subtitle: 'Films, séries, anime : tu prends tout', description: 'Aucune case te résume. L\'équilibre parfait.', color: '#26C6DA' } }, + ]; + + for (const p of behaviorPersonas) { + if (p.condition === true) return p.persona; + } + + // --- Défauts par type dominant --- const defaultPersonas = { - 'anime': { - id: 'anime-fan', - title: 'L\'Anime Fan', - emoji: '✨', - subtitle: 'Tu apprécies l\'art de l\'animation japonaise', - description: 'Un équilibre sain d\'anime dans ta vie.', - color: '#E91E63' - }, - 'movie': { - id: 'movie-lover', - title: 'L\'Amoureux du Cinéma', - emoji: '🍿', - subtitle: 'Rien ne vaut un bon film', - description: 'Tu sais apprécier une bonne histoire.', - color: '#2196F3' - }, - 'tv': { - id: 'tv-enthusiast', - title: 'L\'Enthousiaste des Séries', - emoji: '📺', - subtitle: 'Les séries font partie de ta routine', - description: 'Un épisode par jour éloigne le médecin.', - color: '#4CAF50' - }, - 'live-tv': { - id: 'live-watcher', - title: 'Le Téléspectateur', - emoji: '📡', - subtitle: 'Tu restes connecté à l\'actualité', - description: 'La TV en direct, c\'est ton truc.', - color: '#607D8B' - } + 'anime': { id: 'anime-fan', title: 'L\'Anime Fan', emoji: '✨', subtitle: 'L\'animation japonaise te parle', description: 'Un bel équilibre d\'anime dans ta vie.', color: '#E91E63' }, + 'movie': { id: 'movie-lover', title: 'L\'Amoureux du Cinéma', emoji: '🍿', subtitle: 'Rien ne vaut un bon film', description: 'Tu sais apprécier une bonne histoire.', color: '#2196F3' }, + 'tv': { id: 'tv-enthusiast', title: 'L\'Enthousiaste des Séries', emoji: '📺', subtitle: 'Les séries rythment ta routine', description: 'Un épisode par jour éloigne le médecin.', color: '#4CAF50' }, + 'live-tv': { id: 'live-watcher', title: 'Le Téléspectateur', emoji: '📡', subtitle: 'Toujours branché sur le direct', description: 'La TV en direct, c\'est ton truc.', color: '#607D8B' } }; return defaultPersonas[dominantType.content_type] || defaultPersonas['movie']; @@ -1294,255 +1291,427 @@ function formatDurationShort(minutes) { } /** - * Génère les slides du Wrapped avec des templates "à trous" - * Écrit comme un copywriter Spotify le ferait + * Génère les slides du Wrapped v2 */ function generateSlides(data) { const { totalMinutes, totalHours, totalDays, totalDurationLabel, uniqueTitles, - topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, topShowType, - secondShowTitle, secondShow, + topShowTitle, topShowHours, topShowMinutes, topShowDurationLabel, dominantType, dominantPercent, peakMonth, lowestMonth, monthNames, - animePercent, moviePercent, tvPercent, - isExplorer, isLoyal, isBinger, - enrichedTopContent, typeStats, persona, - year, - // New data + enrichedTopContent, persona, + year, userId, percentile, peakHour, isNightOwl, isEarlyBird, longestStreak, totalActiveDays, - firstWatchData, lastWatchData, - topGenres, avgSessionMinutes + firstWatchData, + topGenres, avgSessionMinutes, + isExplorer, isBinger, + recordDay, rewatchData, watchAgeYear, topPages, weekday } = data; const slides = []; + const seed = `${userId}:${year}`; + const shortLabel = formatDurationShort(totalMinutes); + // Variante seedée par user+slide : chaque utilisateur tombe sur une formulation + // différente, mais stable d'une ouverture à l'autre (screenshots reproductibles). + const pick = (key, variants) => variants[fnv1aHash(`${seed}:${key}`) % variants.length]; - // === SLIDE 1: INTRO / HOOK === + // === 1. INTRO === const introTemplates = [ + { + condition: totalHours > 500, + title: pick('intro-t', [`${year}, hors catégorie.`, `${year}, niveau final.`]), + subtitle: 'On a recompté trois fois, c\'est réel.', + texts: [ + `${totalDurationLabel} de visionnage, soit ${totalDays} jours complets devant un écran. T'es plus un utilisateur, t'es un pilier de Movix.`, + `${totalDurationLabel} cette année. Des séries entières sont nées et mortes pendant que toi, t'étais là.`, + `${totalDurationLabel} au compteur. Ton canapé devrait être classé monument historique.` + ], + highlight: `${totalDays} jours` + }, + { + condition: totalHours > 300, + title: pick('intro-t', [`${year}, du très lourd.`, `${year}, régime intensif.`]), + subtitle: 'Et c\'est peu de le dire.', + texts: [ + `${totalDurationLabel} sur Movix, soit ${totalDays} jours non-stop. Le canapé a officiellement pris ta forme.`, + `${totalDurationLabel} de visionnage. Certains font des marathons. Toi, t'as couru toute l'année.`, + `${totalDurationLabel} cette année. Quelque part entre la passion et le record du monde.` + ], + highlight: `${totalDays} jours` + }, { condition: totalHours > 200, - title: `${year}, c'était intense.`, - subtitle: 'Genre, vraiment intense.', - text: `Tu as passé ${totalDurationLabel} sur Movix. C'est ${totalDays} jours complets. On espère que t'avais des snacks.`, + title: `${year}, t'as tout donné.`, + subtitle: 'Et on a tout vu.', + texts: [ + `${totalDurationLabel} sur Movix cette année. Soit ${totalDays} jours non-stop. À ce stade, c'est plus un hobby, c'est un mode de vie.`, + `${totalDurationLabel} de visionnage, ${totalDays} jours pleins. T'as pas regardé une année, t'en as vécu deux.`, + `${totalDurationLabel} au total. Si regarder était un sport, t'aurais une fédération à ton nom.` + ], highlight: `${totalDays} jours` }, { condition: totalHours > 100, - title: `${year} ? Tu l'as bien remplie.`, - subtitle: 'Et ton historique aussi.', - text: `${totalDurationLabel} de contenu. C'est plus que certains jobs à temps partiel.`, - highlight: `${formatDurationShort(totalMinutes)}` + title: pick('intro-t', [`${year}, bien rempli.`, `${year}, sacré rythme.`]), + subtitle: 'Et ton historique le prouve.', + texts: [ + `${totalDurationLabel} de visionnage. Genre, plus que certains mi-temps de boulot. Validé.`, + `${totalDurationLabel} cette année. T'as trouvé ton rythme de croisière, et il est soutenu.`, + `${totalDurationLabel} sur Movix. Une vraie deuxième vie, bien remplie.` + ], + highlight: shortLabel }, { condition: totalHours > 50, - title: `Pas mal, ${year}.`, - subtitle: 'Pas mal du tout.', - text: `${totalDurationLabel} de streaming. Tu sais ce que tu veux.`, - highlight: `${formatDurationShort(totalMinutes)}` + title: `Solide, ${year}.`, + subtitle: 'Vraiment solide.', + texts: [ + `${totalDurationLabel} sur Movix. Tu sais ce que t'aimes, et tu fonces.`, + `${totalDurationLabel} de visionnage. Pas d'excès, pas de manque : l'équilibre du connaisseur.`, + `${totalDurationLabel} cette année. Régulier, précis, efficace.` + ], + highlight: shortLabel + }, + { + condition: totalHours > 20, + title: pick('intro-t', [`${year}, tranquille.`, `${year}, en finesse.`]), + subtitle: 'Mais efficace.', + texts: [ + `${totalDurationLabel} bien choisies. Toi, tu regardes pas beaucoup — tu regardes bien.`, + `${totalDurationLabel} au compteur. La qualité avant la quantité, toujours.`, + `${totalDurationLabel} cette année. Chaque session compte, rien au hasard.` + ], + highlight: shortLabel }, { condition: true, - title: `${year} s'est bien passée.`, - subtitle: 'On a les preuves.', - text: `${totalDurationLabel} ensemble cette année. C'est un bon début.`, - highlight: `${formatDurationShort(totalMinutes)}` + title: `${year}, avec toi.`, + subtitle: 'Et c\'est déjà pas mal.', + texts: [ + `${totalDurationLabel} ensemble cette année. Le début d'une belle histoire.`, + `${totalDurationLabel} passées ici. On espère que c'était que du bon.` + ], + highlight: shortLabel } ]; - slides.push({ type: 'intro', ...introTemplates.find(t => t.condition) }); + const introTpl = introTemplates.find(t => t.condition); + slides.push({ type: 'intro', title: introTpl.title, subtitle: introTpl.subtitle, text: pick('intro', introTpl.texts), highlight: introTpl.highlight }); - // === SLIDE 2: TOP 1 === - if (topShowTitle) { - const top1Templates = [ - { - condition: topShowHours > 100, - title: `"${topShowTitle}"`, - subtitle: 'Ton obsession de l\'année', - text: `${topShowDurationLabel} dessus. À ce stade, tu pourrais écrire la suite toi-même.`, - highlight: `#1`, - subtext: 'C\'est un peu gênant, mais on adore.' - }, - { - condition: topShowHours > 50, - title: `"${topShowTitle}"`, - subtitle: 'Ton coup de cœur absolu', - text: `${topShowDurationLabel}. Tu l'as regardé, rerererereregardé, et tu recommencerais.`, - highlight: `#1`, - subtext: 'Fan numéro 1 ? C\'est toi.' - }, - { - condition: topShowHours > 20, - title: `"${topShowTitle}"`, - subtitle: 'Ta grande histoire d\'amour', - text: `${topShowDurationLabel} ensemble. C'est plus que certaines relations.`, - highlight: `#1`, - subtext: 'Et on ne juge pas.' - }, - { - condition: true, - title: `"${topShowTitle}"`, - subtitle: 'Ton préféré de l\'année', - text: `${topShowDurationLabel} passées dessus. Un classique dans ton cœur.`, - highlight: `#1`, - subtext: '' - } - ]; - slides.push({ type: 'top1', ...top1Templates.find(t => t.condition) }); - } - - // === SLIDE 3: TOP 5 === - if (enrichedTopContent.length >= 3) { - const top5Text = enrichedTopContent.slice(0, 5).map((c, i) => - `${i + 1}. ${c.content_title}` - ).join('\n'); - - slides.push({ - type: 'top5', - title: 'Ton Top 5', - subtitle: 'Ceux qui ont marqué ton année', - text: top5Text, - highlight: `${uniqueTitles} titres au total`, - subtext: uniqueTitles > 50 ? 'Tu es incollable.' : '' - }); - } - - // === SLIDE 4: PERSONNALITÉ / VIBE === - const typeLabel = { - 'anime': 'les animes', - 'movie': 'les films', - 'tv': 'les séries', - 'live-tv': 'la TV en direct' - }; - - const vibeTemplates = [ - { - condition: dominantPercent > 80, - title: persona.title, - subtitle: persona.subtitle, - text: `${dominantPercent}% de ton temps sur ${typeLabel[dominantType.content_type]}. Tu sais ce que tu aimes, et tu assumes.`, - highlight: persona.emoji, - subtext: persona.description - }, - { - condition: dominantPercent > 50, - title: persona.title, - subtitle: persona.subtitle, - text: `Ta vibe ? Principalement ${typeLabel[dominantType.content_type]}, avec une touche de variété.`, - highlight: persona.emoji, - subtext: persona.description - }, - { - condition: true, - title: persona.title, - subtitle: persona.subtitle, - text: `Tu es éclectique. Un peu de tout, c'est ton style.`, - highlight: persona.emoji, - subtext: persona.description - } - ]; - slides.push({ type: 'persona', ...vibeTemplates.find(t => t.condition) }); - - // === SLIDE 5: MOIS LE PLUS ACTIF === + // === 2. TIMELINE (remplace peak-month) === const peakMonthMinutes = parseInt(peakMonth.duration); - const peakMonthHours = Math.round(peakMonthMinutes / 60); - const peakMonthDurationLabel = formatDuration(peakMonthMinutes); - - const monthTemplates = [ - { - condition: peakMonthHours > 50, - title: monthNames[peakMonth.month], - subtitle: 'Ton mois de folie', - text: `${peakMonthDurationLabel} en un seul mois. Il s'est passé quoi ? Rupture ? Vacances ? Les deux ?`, - highlight: `🔥`, - subtext: 'On ne juge pas, on constate.' - }, - { - condition: peakMonthHours > 20, - title: monthNames[peakMonth.month], - subtitle: 'Ton mois le plus actif', - text: `${peakMonthDurationLabel}. Tu étais dans ta bulle, et c'était bien.`, - highlight: `📅`, - subtext: '' - }, - { - condition: true, - title: monthNames[peakMonth.month], - subtitle: 'Là où tout s\'est joué', - text: `Ton pic de l'année. ${peakMonthDurationLabel} de pur bonheur.`, - highlight: `✨`, - subtext: '' - } - ]; - slides.push({ type: 'peak-month', ...monthTemplates.find(t => t.condition) }); + const peakMonthLabel = formatDuration(peakMonthMinutes); + const peakName = monthNames[peakMonth.month]; + const lowestName = monthNames[lowestMonth.month]; + const avgMonthlyMinutes = totalMinutes / 12; + const peakRatio = avgMonthlyMinutes > 0 ? peakMonthMinutes / avgMonthlyMinutes : 1; + let timelineText; + if (peakMonth.month === lowestMonth.month) { + timelineText = `${peakName}, ton pic absolu : ${peakMonthLabel} en un seul mois.`; + } else if (peakRatio >= 3) { + timelineText = pick('timeline', [ + `${peakName} a tout écrasé : ${peakMonthLabel} à lui seul. Il s'est passé un truc en ${peakName.toLowerCase()}, avoue.`, + `${peakMonthLabel} rien qu'en ${peakName.toLowerCase()}. Le reste de l'année regardait de loin.`, + `${peakName} en mode rouleau compresseur (${peakMonthLabel}), pendant que ${lowestName.toLowerCase()} comptait les jours.` + ]); + } else { + timelineText = pick('timeline', [ + `${peakName} en feu (${peakMonthLabel}), ${lowestName.toLowerCase()} fantôme. Chacun son rythme.`, + `${peakName} au sommet avec ${peakMonthLabel}, ${lowestName.toLowerCase()} en mode avion. L'année a eu ses saisons.`, + `Gros pic en ${peakName.toLowerCase()} (${peakMonthLabel}), calme plat en ${lowestName.toLowerCase()}. Une année avec du relief.` + ]); + } + slides.push({ + type: 'timeline', + title: 'Ton année en courbe', + subtitle: `${peakName}, ton mois fort`, + text: timelineText, + highlight: '📈', + subtext: peakMonthMinutes > 3000 ? pick('timeline-s', ['On constate, on ne juge pas.', 'Aucun jugement. Beaucoup de respect.']) : '' + }); - // === SLIDE 6: TOP GENRES === + // === 3. TOP GENRES === if (topGenres && topGenres.length >= 2) { const topGenreNames = topGenres.slice(0, 3).map(g => g.name).join(', '); slides.push({ type: 'top-genres', - title: 'Tes genres préférés', + title: pick('genres-t', ['Tes genres de prédilection', 'Ton ADN de spectateur', 'Ta palette de l\'année']), subtitle: topGenreNames, text: topGenres.length >= 3 - ? `${topGenres[0].name} domine avec ${topGenres[0].percent}% de ton temps. Suivi par ${topGenres[1].name} et ${topGenres[2].name}.` - : `${topGenres[0].name} domine avec ${topGenres[0].percent}% de ton temps.`, + ? pick('genres', [ + `${topGenres[0].name} mène la danse avec ${topGenres[0].percent}% de ton temps. Juste derrière : ${topGenres[1].name} et ${topGenres[2].name}.`, + `${topGenres[0].name} en tête (${topGenres[0].percent}% de ton temps), talonné par ${topGenres[1].name} et ${topGenres[2].name}. Le trio gagnant.`, + `Ton podium des genres : ${topGenres[0].name} (${topGenres[0].percent}%), puis ${topGenres[1].name} et ${topGenres[2].name}. Une signature bien à toi.` + ]) + : `${topGenres[0].name} mène la danse avec ${topGenres[0].percent}% de ton temps.`, highlight: '🎭', - subtext: topGenres.length > 5 ? `Et aussi : ${topGenres.slice(5).map(g => g.name).join(', ')}` : '' + subtext: '' }); } - // === SLIDE 7: LISTENING CLOCK (Quand tu regardes) === + // === 4. TOP 1 (le quiz client s'insère juste avant côté frontend) === + if (topShowTitle) { + const top1Templates = [ + { + condition: topShowHours > 200, + subtitle: 'Ton année lui appartient', + texts: [ + `${topShowDurationLabel} dessus. C'est plus du visionnage, c'est une colocation.`, + `${topShowDurationLabel} passées ensemble. Vous devriez officialiser, là.`, + `${topShowDurationLabel} sur ce titre. Tu pourrais doubler les personnages de mémoire.` + ], + subtext: 'Iconique. Légèrement inquiétant. Surtout iconique.' + }, + { + condition: topShowHours > 100, + subtitle: 'Ton obsession de l\'année', + texts: [ + `${topShowDurationLabel} dessus. Tu pourrais en écrire la suite les yeux fermés.`, + `${topShowDurationLabel} passées dessus. Les acteurs eux-mêmes l'ont moins vu que toi.`, + `${topShowDurationLabel} sur ce titre. À ce niveau, c'est plus un programme, c'est un membre de la famille.` + ], + subtext: 'Un peu gênant. Beaucoup iconique.' + }, + { + condition: topShowHours > 50, + subtitle: 'Ton grand gagnant', + texts: [ + `${topShowDurationLabel} ensemble. Vous êtes officiellement en couple.`, + `${topShowDurationLabel} dessus. Le genre de fidélité qui se fait rare.`, + `${topShowDurationLabel} passées dessus. Coup de cœur confirmé, et largement assumé.` + ], + subtext: 'Fan n°1 ? C\'est toi.' + }, + { + condition: topShowHours > 20, + subtitle: 'Ta grande histoire de l\'année', + texts: [ + `${topShowDurationLabel} dessus. Plus que certaines vraies relations.`, + `${topShowDurationLabel} ensemble. Une belle histoire, sans drama (enfin, sauf à l'écran).`, + `${topShowDurationLabel} passées dessus. Vous deux, c'était évident.` + ], + subtext: 'On ne juge pas.' + }, + { + condition: true, + subtitle: 'Ton préféré de l\'année', + texts: [ + `${topShowDurationLabel} passées dessus. Un classique gravé dans ton cœur.`, + `${topShowDurationLabel} dessus. Le titre qui a gagné ton année, tout simplement.` + ], + subtext: '' + } + ]; + const tpl = top1Templates.find(t => t.condition); + slides.push({ type: 'top1', title: `"${topShowTitle}"`, subtitle: tpl.subtitle, text: pick('top1', tpl.texts), highlight: '#1', subtext: tpl.subtext }); + } + + // === 5. TOP 5 === + if (enrichedTopContent.length >= 3) { + const top5Text = enrichedTopContent.slice(0, 5).map((c, i) => `${i + 1}. ${c.content_title}`).join('\n'); + slides.push({ + type: 'top5', + title: 'Ton Top 5', + subtitle: pick('top5-s', ['Le casting de ton année', 'Tes têtes d\'affiche', 'Le grand palmarès']), + text: top5Text, + highlight: `${uniqueTitles} titres en tout`, + subtext: uniqueTitles > 200 + ? pick('top5-x', ['Encyclopédie vivante.', 'Une vidéothèque entière à toi tout seul.']) + : uniqueTitles > 50 ? 'Incollable.' : '' + }); + } + + // === 6. REWATCH (conditionnelle) === + if (rewatchData) { + const rwTexts = rewatchData.count >= 5 + ? [ + `${rewatchData.count} fois sur "${rewatchData.title}" cette année. Tu connais les répliques avant les acteurs.`, + `T'es revenu ${rewatchData.count} fois sur "${rewatchData.title}". C'est plus un rewatch, c'est un pèlerinage.` + ] + : rewatchData.count >= 3 + ? [ + `T'es revenu ${rewatchData.count} fois sur "${rewatchData.title}". Le confort a un nom.`, + `${rewatchData.count} visites à "${rewatchData.title}" cette année. Quand on aime, on recompte pas.` + ] + : [ + `Deux fois sur "${rewatchData.title}" cette année. Quand c'est bon, c'est bon.`, + `"${rewatchData.title}", vu et revu. Certaines histoires méritent une deuxième séance.` + ]; + slides.push({ + type: 'rewatch', + title: 'Ton revenant', + subtitle: rewatchData.type === 'movie' ? 'Le film qui te lâche pas' : 'L\'épisode que tu relances en boucle', + text: pick('rewatch', rwTexts), + highlight: `×${rewatchData.count}`, + subtext: pick('rewatch-s', ['Les classiques, ça se respecte.', 'La valeur sûre, c\'est sacré.']) + }); + } + + // === 7. RECORD DAY (conditionnelle, > 3h) === + if (recordDay && recordDay.minutes > 180) { + const recordDate = new Date(recordDay.date); + const recordDateLabel = recordDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' }); + const recordLabel = formatDuration(recordDay.minutes); + const recordTpl = recordDay.minutes > 480 + ? { + title: 'Ta journée hors norme', + texts: [ + `${recordLabel} en une seule journée. Une performance. La médaille arrive par la poste.`, + `${recordLabel} d'affilée ce jour-là. Le soleil s'est levé et couché sans toi.`, + `${recordLabel} en 24h. On appelle plus ça une journée, on appelle ça un festival.` + ], + subtext: 'Journée rentrée dans la légende.' + } + : recordDay.minutes > 300 + ? { + title: 'Ta journée légendaire', + texts: [ + `${recordLabel} en une seule journée. On sait pas ce qui s'est passé, mais c'était du sérieux.`, + `${recordLabel} ce jour-là. Pluie dehors ou grosse flemme : dans tous les cas, bien joué.`, + `${recordLabel} en un jour. La définition même du jour parfait.` + ], + subtext: 'Journée certifiée canapé.' + } + : { + title: 'Ta plus grosse journée', + texts: [ + `${recordLabel} en une journée. Ton record perso de l'année.`, + `${recordLabel} ce jour-là. Une après-midi bien investie.` + ], + subtext: '' + }; + slides.push({ + type: 'record-day', + title: recordTpl.title, + subtitle: `Le ${recordDateLabel}`, + text: pick('record', recordTpl.texts), + highlight: formatDurationShort(recordDay.minutes), + subtext: recordTpl.subtext + }); + } + + // === 8. LISTENING CLOCK (+ weekday rendu côté front) === const hourLabel = (h) => `${h}h`; const clockTemplates = [ { condition: isNightOwl, - title: 'Le mode nocturne', - subtitle: `Tu es le plus actif vers ${hourLabel(peakHour)}`, - text: 'Pendant que le monde dort, toi tu mates. Tes yeux rouges ne mentent pas.', + title: 'Team nuit', + subtitle: `Ton pic : ${hourLabel(peakHour)}`, + texts: [ + `Pendant que le monde dort, toi tu lances un épisode. Les cernes, c'est le prix de la passion.`, + `Tes meilleures sessions commencent quand les autres éteignent. La nuit te réussit.`, + `Minuit passé, un épisode de plus. Le club des noctambules te salue.` + ], highlight: '🌙', subtext: 'Les meilleures sessions, c\'est la nuit.' }, { condition: isEarlyBird, - title: 'Lève-tôt, regarde tôt', - subtitle: `Ton pic d'activité : ${hourLabel(peakHour)}`, - text: 'Tu lances un épisode avant même que le café soit prêt. Respect.', + title: 'Team matin', + subtitle: `Ton pic : ${hourLabel(peakHour)}`, + texts: [ + `Un épisode avant même le café. Respect total.`, + `Toi, tu commences la journée par un générique. Et franchement, c'est une bonne routine.` + ], highlight: '🌅', subtext: '' }, + { + condition: peakHour >= 18 && peakHour <= 23, + title: 'Team prime time', + subtitle: `Ton pic : ${hourLabel(peakHour)}`, + texts: [ + `${hourLabel(peakHour)}, le canapé t'appelle, et tu réponds toujours présent. La grande tradition de la soirée écran.`, + `Le soir venu, c'est ton moment. ${hourLabel(peakHour)} pétantes, et c'est parti.`, + `Ta journée se termine toujours pareil : ${hourLabel(peakHour)}, plaid, lecture. La routine parfaite.` + ], + highlight: '🛋️', + subtext: '' + }, + { + condition: peakHour >= 12 && peakHour < 18, + title: 'Team après-midi', + subtitle: `Ton pic : ${hourLabel(peakHour)}`, + texts: [ + `C'est en pleine après-midi que tu lances le plus souvent Movix. La pause de ${hourLabel(peakHour)}, c'est sacré.`, + `${hourLabel(peakHour)} : l'heure où ta journée fait une pause et où Movix prend le relais.` + ], + highlight: '☀️', + subtext: '' + }, { condition: true, title: `${hourLabel(peakHour)}, ton heure de pointe`, - subtitle: 'Ton horloge de visionnage', - text: `C'est à ${hourLabel(peakHour)} que tu lances le plus souvent Movix. On connaît tes habitudes maintenant.`, + subtitle: 'Ton horloge Movix', + texts: [ + `C'est vers ${hourLabel(peakHour)} que tu lances Movix le plus souvent. On connaît tes habitudes maintenant.`, + `${hourLabel(peakHour)}, ton rendez-vous quotidien. La ponctualité, c'est une qualité.` + ], highlight: '⏰', subtext: '' } ]; - slides.push({ type: 'listening-clock', ...clockTemplates.find(t => t.condition) }); + const clockTpl = clockTemplates.find(t => t.condition); + slides.push({ type: 'listening-clock', title: clockTpl.title, subtitle: clockTpl.subtitle, text: pick('clock', clockTpl.texts), highlight: clockTpl.highlight, subtext: clockTpl.subtext }); - // === SLIDE 8: STREAK === + // === 9. STREAK (durées réelles : mois/semaines calculés, pas d'à-peu-près) === if (longestStreak >= 3) { + const streakMonths = Math.floor(longestStreak / 30); + const streakWeeks = Math.floor(longestStreak / 7); const streakTemplates = [ + { + condition: longestStreak >= 90, + title: `${longestStreak} jours d'affilée`, + subtitle: `${streakMonths} mois sans lâcher`, + texts: [ + `${longestStreak} jours consécutifs, soit ${streakMonths} mois pleins sans en rater un seul. C'est plus de la constance, c'est un serment.`, + `${longestStreak} jours d'affilée. Des saisons entières ont commencé et fini pendant ta série.` + ], + highlight: '🏆', + subtext: `Sur ${totalActiveDays} jours actifs cette année.` + }, + { + condition: longestStreak >= 60, + title: `${longestStreak} jours d'affilée`, + subtitle: `${streakMonths} mois complets, jour après jour`, + texts: [ + `${longestStreak} jours consécutifs, soit ${streakMonths} mois entiers sans interruption. C'est plus une habitude, c'est un mode de vie.`, + `${longestStreak} jours d'affilée. Le soleil a eu plus de jours off que toi.`, + `${longestStreak} jours sans en manquer un. ${streakMonths} mois de fidélité absolue, ça se signe quelque part ?` + ], + highlight: '🔥', + subtext: `Sur ${totalActiveDays} jours actifs cette année.` + }, { condition: longestStreak >= 30, - title: `${longestStreak} jours de suite`, - subtitle: 'Un mois complet non-stop', - text: `Ta plus longue série de visionnage : ${longestStreak} jours consécutifs. On appelle ça de la détermination.`, + title: `${longestStreak} jours d'affilée`, + subtitle: 'Un mois entier non-stop', + texts: [ + `Ta plus longue série : ${longestStreak} jours consécutifs. On appelle ça une légende.`, + `${longestStreak} jours sans interruption. Un mois complet de rendez-vous quotidien.` + ], highlight: '🔥', subtext: `Sur ${totalActiveDays} jours actifs cette année.` }, { condition: longestStreak >= 14, - title: `${longestStreak} jours d'affilée`, - subtitle: 'Streak impressionnant', - text: `Deux semaines sans lâcher Movix. Tu as une discipline de fer (pour le streaming en tout cas).`, + title: `${longestStreak} jours non-stop`, + subtitle: `${streakWeeks} semaines sans lâcher`, + texts: [ + `${streakWeeks} semaines sans lâcher Movix. Discipline de fer (pour le streaming au moins).`, + `${longestStreak} jours d'affilée. La régularité d'une montre suisse, le plaisir en plus.` + ], highlight: '⚡', - subtext: `${totalActiveDays} jours d'activité au total.` + subtext: `${totalActiveDays} jours actifs au total.` }, { condition: longestStreak >= 7, title: `${longestStreak} jours non-stop`, - subtitle: 'Une semaine de dédi', - text: `Ta meilleure série : ${longestStreak} jours sans interruption. C'est de la constance.`, + subtitle: 'Une semaine de dévotion', + texts: [ + `${longestStreak} jours sans interruption. Ça, c'est de la constance.`, + `Une semaine entière sans rater un jour. Le rituel était bien rodé.` + ], highlight: '💪', subtext: '' }, @@ -1550,124 +1719,250 @@ function generateSlides(data) { condition: true, title: `${longestStreak} jours de streak`, subtitle: 'Ta meilleure série', - text: `${longestStreak} jours de visionnage consécutifs. Pas mal comme streak.`, + texts: [ + `${longestStreak} jours consécutifs. Pas mal du tout.`, + `${longestStreak} jours d'affilée. Une jolie petite série.` + ], highlight: '🎯', subtext: '' } ]; - slides.push({ type: 'streak', ...streakTemplates.find(t => t.condition) }); + const streakTpl = streakTemplates.find(t => t.condition); + slides.push({ type: 'streak', title: streakTpl.title, subtitle: streakTpl.subtitle, text: pick('streak', streakTpl.texts), highlight: streakTpl.highlight, subtext: streakTpl.subtext }); } - // === SLIDE 9: FUN FACT === - const funFacts = []; - - if (totalHours > 24) { - const equivalent = Math.floor(totalHours / 2); // Un film moyen = 2h - funFacts.push({ - title: 'En d\'autres termes...', - subtitle: '', - text: `Tu aurais pu regarder ${equivalent} films de suite. Ou dormir ${totalDays} jours. Tu as fait un choix.`, - highlight: '🤔', - subtext: 'Le bon choix.' - }); - } else if (totalMinutes > 30) { - funFacts.push({ - title: 'En d\'autres termes...', - subtitle: '', - text: `${totalDurationLabel} de streaming. C'est déjà un bon début pour cette année.`, - highlight: '🤔', - subtext: 'Et ce n\'est que le début.' + // === 10. PAGES TIME (conditionnelle, ≥ 60 min hors live-tv) === + const pageLabels = { + 'home': 'la page d\'accueil', 'movies': 'les films', 'tv-shows': 'les séries', + 'movie-details': 'les fiches films', 'tv-details': 'les fiches séries', + 'wishboard': 'le wishboard', 'watchparty': 'les watchparties', 'anime': 'les animes' + }; + const browsePages = (topPages || []).filter(p => p.page_name !== 'live-tv' && pageLabels[p.page_name]); + const browseTotal = browsePages.reduce((s, p) => s + parseInt(p.duration), 0); + if (browsePages.length > 0 && browseTotal >= 60) { + const topPage = browsePages[0]; + const browseLabel = formatDuration(browseTotal); + const pageTexts = { + 'home': [ + `${browseLabel} à scroller la page d'accueil. Hésiter, re-scroller, hésiter encore : un art de vivre.`, + `${browseLabel} sur la home avant de te décider. Le choix, c'est toute une aventure.` + ], + 'movie-details': [ + `${browseLabel} à éplucher les fiches films. Toi, tu lances jamais rien sans avoir lu le dossier complet.`, + `${browseLabel} sur les fiches. Synopsis, note, casting : rien ne t'échappe avant le premier clic.` + ], + 'tv-details': [ + `${browseLabel} à éplucher les fiches séries. Toi, tu lances jamais rien sans avoir lu le dossier complet.`, + `${browseLabel} sur les fiches. Synopsis, note, saisons : rien ne t'échappe avant le premier clic.` + ], + 'wishboard': [ + `${browseLabel} sur ton wishboard. Collectionner les envies, c'est déjà la moitié du plaisir.`, + `${browseLabel} à organiser ta liste. Ta pile « à voir » est une œuvre en soi.` + ], + 'watchparty': [ + `${browseLabel} en watchparty. Regarder seul ? Très peu pour toi.`, + `${browseLabel} passées à mater à plusieurs. Le cinéma, c'est mieux accompagné.` + ] + }; + const genericTexts = [ + `${browseLabel} à naviguer sur Movix, surtout sur ${pageLabels[topPage.page_name]}. L'art de choisir, c'est tout un sport.`, + `${browseLabel} de balade dans le catalogue, ${pageLabels[topPage.page_name]} en tête. Flâner, c'est déjà regarder un peu.` + ]; + slides.push({ + type: 'pages-time', + title: 'Là où tu traînes', + subtitle: 'Avant même de lancer un titre', + text: pick('pages', pageTexts[topPage.page_name] || genericTexts), + highlight: formatDurationShort(browseTotal), + subtext: pick('pages-s', ['L\'indécision, on connaît.', 'Le lèche-vitrine version streaming.']) }); } - - if (isExplorer && uniqueTitles > 30) { - funFacts.push({ - title: 'L\'algorithme t\'adore', - subtitle: '', - text: `${uniqueTitles} titres différents. Tu rends notre job de recommandation vraiment difficile (et intéressant).`, - highlight: '🧠', + + // === 11. WATCH AGE (conditionnelle, paliers d'époque) === + if (watchAgeYear) { + const ageTpl = watchAgeYear >= year - 2 + ? { + title: 'Toujours sur la hype', + texts: [ + `La moitié de ton temps sur des titres tout frais. T'es la hype incarnée.`, + `Tu regardes ce qui vient de sortir, point. Toujours au courant avant tout le monde.` + ] + } + : watchAgeYear < 2000 + ? { + title: `Ton cœur vit en ${watchAgeYear}`, + texts: [ + `La moitié de ton temps sur des titres d'avant ${watchAgeYear}. Le grain de l'ancien, le charme du classique : du patrimoine à l'état pur.`, + `Ton année médiane : ${watchAgeYear}. Pendant que tout le monde court après les nouveautés, toi tu sirotes les classiques.` + ] + } + : watchAgeYear < 2015 + ? { + title: `Ton cœur vit en ${watchAgeYear}`, + texts: [ + `La moitié de ton temps sur des titres d'avant ${watchAgeYear}. La nostalgie te va bien.`, + `${watchAgeYear}, ton point d'équilibre. Cette époque-là avait quelque chose, pas vrai ?` + ] + } + : { + title: `Ton cœur vit en ${watchAgeYear}`, + texts: [ + `Entre deux époques : la moitié de ton visionnage date d'avant ${watchAgeYear}. Le meilleur des deux mondes.`, + `${watchAgeYear} en année médiane. Ni tout neuf, ni vintage : juste ce qui te plaît.` + ] + }; + slides.push({ + type: 'watch-age', + title: ageTpl.title, + subtitle: 'Ton âge ciné', + text: pick('age', ageTpl.texts), + highlight: String(watchAgeYear), subtext: '' }); } + // === 12. FUN FACTS (pool élargi, sélection seedée — 1, +1 si > 50h) === + const funFacts = []; + if (totalHours > 24) { + const equivalent = Math.floor(totalHours / 2); + funFacts.push({ + title: 'En vrai...', subtitle: '', + text: pick('ff-eq', [ + `T'aurais pu enchaîner ${equivalent} films. Ou dormir ${totalDays} jours. T'as fait un choix.`, + `${totalDurationLabel}, c'est ${equivalent} films bout à bout. Ou ${Math.max(2, Math.floor(totalHours / 12))} vols Paris-Tokyo. T'as choisi le canapé, et on comprend.` + ]), + highlight: '🤔', subtext: 'Le bon.' + }); + } else if (totalMinutes > 30) { + funFacts.push({ title: 'En vrai...', subtitle: '', text: `${totalDurationLabel} de streaming. Joli début pour cette année.`, highlight: '🤔', subtext: 'Et c\'est que le début.' }); + } + if (isExplorer && uniqueTitles > 30) { + funFacts.push({ + title: 'L\'algo t\'adore', subtitle: '', + text: pick('ff-explo', [ + `${uniqueTitles} titres différents. Tu rends nos recommandations folles (dans le bon sens).`, + `${uniqueTitles} titres explorés cette année. Notre catalogue te dit merci pour la visite guidée.` + ]), + highlight: '🧠', subtext: '' + }); + } if (isBinger && topShowTitle) { const percentOfTotal = totalMinutes > 0 ? Math.round((topShowMinutes / totalMinutes) * 100) : 0; funFacts.push({ - title: 'Confession time', - subtitle: '', - text: `"${topShowTitle}" représente ${percentOfTotal}% de ton temps total. C'est de l'engagement.`, - highlight: '💍', - subtext: 'Ou de l\'obsession. Mais qui compte ?' + title: 'Petite confession', subtitle: '', + text: pick('ff-binge', [ + `"${topShowTitle}" = ${percentOfTotal}% de ton temps total. Ça, c'est de l'engagement.`, + `${percentOfTotal}% de ton année entière sur "${topShowTitle}". Un placement assumé.` + ]), + highlight: '💍', subtext: 'Ou de l\'obsession. On compte pas.' }); } - - // Percentile fun fact - if (percentile >= 90) { - funFacts.push({ - title: `Top ${100 - percentile}% des viewers`, - subtitle: 'Tu es dans l\'élite', - text: `Tu regardes plus que ${percentile}% des utilisateurs Movix. On devrait te donner un badge.`, - highlight: '🏆', - subtext: '' - }); + if (percentile != null && percentile >= 90) { + funFacts.push({ title: `Top ${100 - percentile}% des viewers`, subtitle: 'Carrément l\'élite', text: `Tu regardes plus que ${percentile}% des gens sur Movix. Médaille méritée.`, highlight: '🏆', subtext: '' }); } - - // Session length fun fact if (avgSessionMinutes > 90) { funFacts.push({ - title: 'Sessions marathon', - subtitle: '', - text: `En moyenne, tes sessions durent ${avgSessionMinutes} minutes. Tu ne fais pas les choses à moitié.`, - highlight: '🍿', - subtext: 'Netflix and actually chill.' + title: 'Sessions marathon', subtitle: '', + text: pick('ff-sess', [ + `Tes sessions durent ${formatDuration(avgSessionMinutes)} en moyenne. Tu fais pas les choses à moitié.`, + `${formatDuration(avgSessionMinutes)} par session en moyenne. Quand tu t'installes, c'est pas pour cinq minutes.` + ]), + highlight: '🍿', subtext: 'Le confort avant tout.' + }); + } + if (Array.isArray(weekday) && weekday.length > 0) { + const topDow = weekday.reduce((a, b) => (b.minutes > a.minutes ? b : a), weekday[0]); + if (topDow && topDow.minutes >= 120) { + const dayNames = ['', 'dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']; + const dayName = dayNames[topDow.dow]; + funFacts.push({ + title: `Le ${dayName}, c'est sacré`, subtitle: '', + text: pick('ff-dow', [ + `Ton jour le plus chargé de l'année : le ${dayName}. ${formatDuration(topDow.minutes)} rien que sur ce créneau hebdo.`, + `S'il fallait te chercher un jour précis, ce serait le ${dayName} : ${formatDuration(topDow.minutes)} cumulées dessus cette année.` + ]), + highlight: '📅', subtext: '' + }); + } + } + if (totalActiveDays > 100) { + const activePct = Math.min(100, Math.round((totalActiveDays / 365) * 100)); + funFacts.push({ + title: `${totalActiveDays} jours actifs`, subtitle: '', + text: pick('ff-days', [ + `T'as lancé Movix ${totalActiveDays} jours cette année — ${activePct}% de tes journées. La fidélité, la vraie.`, + `${totalActiveDays} jours avec au moins une session. Presque ${activePct}% de l'année passée ensemble.` + ]), + highlight: '📆', subtext: '' }); } - - // First watch of the year fun fact if (firstWatchData) { const firstDate = new Date(firstWatchData.created_at); const dayOfYear = Math.ceil((firstDate - new Date(firstDate.getFullYear(), 0, 1)) / (1000 * 60 * 60 * 24)); if (dayOfYear <= 3) { - funFacts.push({ - title: 'Pas de temps à perdre', - subtitle: '', - text: `Ton premier visionnage de ${year} ? "${firstWatchData.content_title}", dès le ${dayOfYear === 1 ? '1er' : dayOfYear + 'ème'} janvier. Tu n'as pas attendu longtemps.`, - highlight: '🎆', - subtext: '' - }); + funFacts.push({ title: 'Aucune minute à perdre', subtitle: '', text: `Ton premier visionnage de ${year} ? "${firstWatchData.content_title}", dès le ${dayOfYear === 1 ? '1er' : dayOfYear + 'e'} janvier. T'as pas traîné.`, highlight: '🎆', subtext: '' }); } } - if (funFacts.length > 0) { - // Pick 1-2 fun facts - const shuffled = funFacts.sort(() => 0.5 - Math.random()); - slides.push({ type: 'fun-fact', ...shuffled[0] }); - if (shuffled.length > 1 && totalHours > 50) { - slides.push({ type: 'fun-fact', ...shuffled[1] }); + const picked = seededShuffle(funFacts, seed); + slides.push({ type: 'fun-fact', ...picked[0] }); + if (picked.length > 1 && totalHours > 50) { + slides.push({ type: 'fun-fact', ...picked[1] }); } } - // === SLIDE 10: DETAILED STATS === + // === 13. PERSONA — révélation climax (avant-dernier acte) === + const typeLabel = { 'anime': 'les animes', 'movie': 'les films', 'tv': 'les séries', 'live-tv': 'la TV en direct' }; + const vibeTemplates = [ + { + condition: dominantPercent > 80, + texts: [ + `${dominantPercent}% de ton temps sur ${typeLabel[dominantType.content_type]}. Tu sais ce que t'aimes, et tu assumes à fond.`, + `${typeLabel[dominantType.content_type]} à ${dominantPercent}% de ton temps : un choix clair, net et totalement assumé.` + ] + }, + { + condition: dominantPercent > 50, + texts: [ + `Ta vibe ? Surtout ${typeLabel[dominantType.content_type]}, avec ce qu'il faut de variété.`, + `${typeLabel[dominantType.content_type]} en majorité, le reste en exploration. Le bon dosage.` + ] + }, + { + condition: true, + texts: [ + `Toi, c'est un peu de tout. L'équilibre, le vrai.`, + `Films, séries, animes : tu refuses de choisir, et t'as bien raison.` + ] + } + ]; slides.push({ - type: 'detailed-stats', - title: 'Tes Statistiques', - subtitle: 'En détail', - text: 'Le résumé complet de ton année.', - highlight: '📊', - subtext: '' + type: 'persona', + title: persona.title, + subtitle: persona.subtitle, + text: pick('persona', vibeTemplates.find(t => t.condition).texts), + highlight: persona.emoji, + subtext: persona.description }); - // === SLIDE 8: CLOSING === + // === 14. DETAILED STATS === + slides.push({ type: 'detailed-stats', title: 'Tes stats', subtitle: 'En détail', text: 'Le récap complet de ton année.', highlight: '📊', subtext: '' }); + + // === 15. CLOSING (rendu générique de fin côté front) === slides.push({ type: 'closing', - title: `C'était ${year}.`, - subtitle: 'Avec toi.', - text: `${totalDurationLabel}. ${uniqueTitles} titres. 1 seul toi. Merci d'avoir passé cette année sur Movix.`, + title: pick('closing-t', [`On remet ça en ${year + 1} ?`, `${year}, c'est dans la boîte.`]), + subtitle: pick('closing-st', ['Nous, on est partants.', 'Clap de fin.']), + text: pick('closing', [ + `${totalDurationLabel}. ${uniqueTitles} titres. 1 seul toi. Merci d'avoir passé l'année sur Movix.`, + `${totalDurationLabel} de visionnage, ${uniqueTitles} titres traversés, et une année qui te ressemble. Merci d'avoir été là.` + ]), highlight: '💜', - subtext: 'À l\'année prochaine ?' + subtext: pick('closing-s', ['Cette année était validée.', 'On garde ta place au chaud.', 'Même heure, même canapé ?']) }); return slides; } + module.exports = { router, initWrappedRoutes, initTables }; diff --git a/API/proxiesembed/server.py b/API/proxiesembed/server.py index 4cd9dab..3424be1 100644 --- a/API/proxiesembed/server.py +++ b/API/proxiesembed/server.py @@ -25,6 +25,7 @@ from bs4 import BeautifulSoup from datetime import datetime, timezone import ssl import random +import socket from aiohttp_socks import ProxyConnector from collections import OrderedDict import gc @@ -591,10 +592,11 @@ DEBRID_PROVIDERS = frozenset({'deepbrid', 'realdebrid'}) SIBNET_PROXY = PROXIES[0] if len(PROXIES) > 0 else None VIDMOLY_PROXY = PROXIES[1] if len(PROXIES) > 1 else (PROXIES[0] if len(PROXIES) > 0 else None) -# Logging -logging.basicConfig(level=logging.INFO) +# Logging — default WARNING (errors/warns only); set LOG_LEVEL=INFO|DEBUG to reenable. +_LOG_LEVEL = getattr(logging, os.environ.get('LOG_LEVEL', 'WARNING').upper(), logging.WARNING) +logging.basicConfig(level=_LOG_LEVEL) logger = logging.getLogger(__name__) -logging.getLogger('aiohttp.access').setLevel(logging.INFO) +logging.getLogger('aiohttp.access').setLevel(logging.WARNING) # Filter to suppress HTTP/2 connection attempts (PRI/Upgrade errors) class HTTP2NoiseFilter(logging.Filter): @@ -918,6 +920,87 @@ def detect_content_type(url: str, accept_header: str = '') -> ContentType: ) +# --------------------------------------------------------------------------- +# curl_cffi (JA3 impersonation) upstream — cinep-proxy only. +# Direct request first; on HTTP 403 retry once through a random SOCKS5 +# proxy from PROXIES (same pool as the other services). +# --------------------------------------------------------------------------- +try: + from curl_cffi.requests import AsyncSession as _CurlAsyncSession + from curl_cffi.requests.exceptions import RequestException as _CurlRequestException + _CURL_CFFI_AVAILABLE = True +except Exception as _curl_import_err: + logger.warning(f'[curl_cffi] Not available, cinep-proxy will use plain aiohttp: {_curl_import_err}') + _CurlRequestException = () # empty except-tuple: matches nothing + _CURL_CFFI_AVAILABLE = False + + +class _CurlContent: + """Mimics aiohttp's StreamReader chunk iteration over an already-buffered body.""" + __slots__ = ('_body',) + + def __init__(self, body: bytes): + self._body = body + + async def iter_chunked(self, chunk_size: int): + for i in range(0, len(self._body), chunk_size): + yield self._body[i:i + chunk_size] + + async def iter_any(self): + if self._body: + yield self._body + + +class _CurlResponseAdapter: + """Wraps a curl_cffi Response so it quacks like the aiohttp response _service_proxy expects.""" + __slots__ = ('status', 'headers', 'content') + + def __init__(self, resp): + self.status = resp.status_code + self.headers = resp.headers + self.content = _CurlContent(resp.content) + + async def read(self) -> bytes: + return self.content._body + + +class _CurlCffiUpstream: + """Async context manager: curl_cffi request with JA3 impersonation. + Retries once through a random SOCKS5 proxy if the direct attempt is a 403.""" + __slots__ = ('_session', '_url', '_headers', '_timeout_s', '_service_name') + + def __init__(self, session, url: str, headers: Dict, timeout_s: float, service_name: str): + self._session = session + self._url = url + self._headers = headers + self._timeout_s = timeout_s + self._service_name = service_name + + async def __aenter__(self): + resp = await self._session.get( + self._url, headers=self._headers, timeout=self._timeout_s, + impersonate='chrome', allow_redirects=False, + ) + if resp.status_code == 403 and PROXIES: + proxy_url = _build_socks5_proxy_url(random.choice(PROXIES)) + if proxy_url: + logger.warning(f'[{self._service_name.upper()}-PROXY] 403 direct — retrying via {_redact_proxy_url(proxy_url)}') + try: + resp = await self._session.get( + self._url, headers=self._headers, timeout=self._timeout_s, + impersonate='chrome', allow_redirects=False, + proxies={'http': proxy_url, 'https': proxy_url}, + ) + except Exception: + logger.exception(f'[{self._service_name.upper()}-PROXY] Proxy retry failed, keeping 403') + else: + logger.warning(f'[{self._service_name.upper()}-PROXY] Proxy retry result: {resp.status_code} for {self._url}') + return _CurlResponseAdapter(resp) + + async def __aexit__(self, *exc_info): + return False + + class ProxyServer: # Compiled regex patterns (class-level for sharing) RE_BANDWIDTH = re.compile(r'bandwidth\.com|edgeon-bandwidth\.com', re.IGNORECASE) @@ -938,7 +1021,7 @@ class ProxyServer: RE_NUMERIC_CDN = re.compile(r'([a-z0-9]+\.\d+\.net|epicquest|questher|hero.*\.com|trainer\.net|dishtrainer)', re.IGNORECASE) RE_DOODSTREAM = re.compile(r'd0000d\.com|doodstream\.com|dood\.(cx|la|pm|sh|so|to|watch|wf|yt|re)|cloudatacdn\.com|dsvplay\.com|doply\.net', re.IGNORECASE) RE_DOODSTREAM_PASS = re.compile(r'/pass_md5/[\w-]+/(?P<token>[\w-]+)') - RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|technicalcatalog\.site|embedseek\.com|seekplayer\.me', re.IGNORECASE) + RE_SEEKSTREAMING = re.compile(r'embed4me\.com|lpayer\.embed4me\.com|servicecatalog\.site|technicalcatalog\.site|embedseek\.com|embedseek\.online|embedseek\.xyz|seekplayer\.me|seekplayer\.vip|seeks\.cloud|seekplays\.com|seekplays\.ink|seekplays\.online|seekplays\.pro', re.IGNORECASE) RE_RANGE = re.compile(r'bytes=(\d+)-(\d*)') RE_M3U8_URI_DQ = re.compile(r'URI="([^"]+)"', re.IGNORECASE) RE_M3U8_URI_SQ = re.compile(r"URI='([^']+)'", re.IGNORECASE) @@ -1076,6 +1159,9 @@ class ProxyServer: ) else: self.sessions['sibnet'] = self.sessions['normal'] + + # curl_cffi session (JA3 impersonation) — cinep-proxy only + self.curl_session = _CurlAsyncSession() if _CURL_CFFI_AVAILABLE else None @staticmethod def _is_francetv_url(url: str) -> bool: @@ -1132,9 +1218,19 @@ class ProxyServer: include_range: bool = False, range_info: Dict = None) -> Dict: """Prepare headers for streaming response""" excluded = frozenset(['transfer-encoding', 'connection', 'content-encoding']) - headers = {k: v for k, v in upstream_headers.items() if k.lower() not in excluded} + # Drop any upstream CORS headers too — we set our own below, and a + # case-mismatched upstream 'access-control-allow-origin' would survive + # the dict.update() and emit a duplicate '*, *' the browser rejects. + headers = {k: v for k, v in upstream_headers.items() + if k.lower() not in excluded and not k.lower().startswith('access-control-')} headers.update(CORS_HEADERS) - + + # Some segment CDNs (e.g. fctv streamas*) mislabel raw MPEG-TS segments as + # 'application/zip'. Relabel to video/mp2t so strict HLS/MSE players accept them. + for _k in list(headers.keys()): + if _k.lower() == 'content-type' and str(headers[_k]).lower().startswith('application/zip'): + headers[_k] = 'video/mp2t' + if content_type: headers['Content-Type'] = content_type @@ -1357,11 +1453,17 @@ class ProxyServer: return random.choice(valid_proxies) if valid_proxies else {} def _create_socks5_connector(self, proxy: Dict) -> ProxyConnector: - """Create SOCKS5 connector with connection pooling""" + """Create SOCKS5 connector with connection pooling. + + Force IPv4 (rdns=False + AF_INET): several stream CDNs are dual-stack + (has A + AAAA) but only their v4 nodes accept our SOCKS egress IPs. With + remote DNS the proxy picks the v6 node and can't route it -> 'Connection + refused by destination host'. Resolving locally to v4 and handing the + proxy a v4 address makes the segment CDN reachable.""" proxy_url = _build_aiohttp_socks_proxy_url(proxy, default_type='socks5') if not proxy_url: raise ValueError('Proxy SOCKS5 invalide') - return ProxyConnector.from_url(proxy_url, rdns=True, limit=0) + return ProxyConnector.from_url(proxy_url, rdns=False, family=socket.AF_INET, limit=0) def setup_routes(self): """Configure server routes""" @@ -1706,11 +1808,15 @@ class ProxyServer: # Parse use_proxy parameter (0=first SOCKS5, 1=second, etc.) use_proxy_param = request.query.get('use_proxy') use_proxy = int(use_proxy_param) if use_proxy_param is not None and use_proxy_param.isdigit() else None - + + # ponytail: use_proxy_key (deterministic SOCKS-pool spread for fctv) removed — + # fctv no longer routed through SOCKS5. Param still stripped below so a stray + # ?use_proxy_key never leaks upstream. Explicit ?use_proxy=N still works. + # Reconstruct split query parameters (handles unencoded URLs) query_params = [] for k, v in request.query.items(): - if k not in ('url', 'headers', 'referer', 'origin', 'user_agent', 'user-agent', 'sosplay', 'use_proxy'): + if k not in ('url', 'headers', 'referer', 'origin', 'user_agent', 'user-agent', 'sosplay', 'use_proxy', 'use_proxy_key'): query_params.append((k, v)) if query_params: @@ -1752,7 +1858,7 @@ class ProxyServer: custom_headers = {**custom_headers, **shortcut_headers} # Clean proxy-specific params from target URL - proxy_params = {'headers', 'referer', 'origin', 'user_agent', 'user-agent', 'url', 'sosplay', 'use_proxy'} + proxy_params = {'headers', 'referer', 'origin', 'user_agent', 'user-agent', 'url', 'sosplay', 'use_proxy', 'use_proxy_key'} if parsed_target.query: existing_params = urllib.parse.parse_qs(parsed_target.query, keep_blank_values=True) for p in proxy_params: @@ -1975,10 +2081,12 @@ class ProxyServer: if response.status >= 400: err_body = await response.read() err_text = err_body.decode('utf-8', errors='replace') if err_body else '(empty)' - logger.warning(f"[PROXY] Upstream HTTP {response.status} for segment {target_url} — headers_sent: {headers}") + logger.warning(f"[PROXY] Upstream HTTP {response.status} for segment {target_url} — headers_sent: {headers} — body: {err_text[:200]}") body = json.dumps({ 'error': f'Upstream HTTP error: {response.status}', 'upstream_status': response.status, + 'upstream_url': target_url, + 'upstream_body': err_text[:2000], }).encode() return (response.status, body, {**CORS_HEADERS, 'Content-Type': 'application/json'}, None) @@ -2706,14 +2814,20 @@ class ProxyServer: return False access_key = raw_key.strip() - - # Fix encoding: aiohttp uses Python's surrogateescape for non-ASCII header bytes. - # Byte 0xe9 (é in Latin-1) becomes surrogate \udce9. We recover the original bytes - # with surrogateescape, then decode as Latin-1 to get the proper Unicode character. + + # Normalize key encoding to match how Mainapi (checkVip.js) looks it up. + # aiohttp hands header bytes back as Latin-1 (or surrogate-escaped). Recover the + # raw wire bytes, then: valid UTF-8 -> decode it; otherwise the bytes are Latin-1 + # (e.g. 'é' = U+00E9 sent as a single 0xE9 byte). This mirrors checkVip.js + # (latin1 bytes -> utf8, keep original when invalid) so the key_value lookup + # matches the stored value and VIP users aren't falsely denied. try: - raw_bytes = access_key.encode('utf-8', 'surrogateescape') - access_key = raw_bytes.decode('latin-1') - except (UnicodeDecodeError, UnicodeEncodeError): + raw = access_key.encode('latin-1', 'surrogateescape') + try: + access_key = raw.decode('utf-8') + except UnicodeDecodeError: + access_key = raw.decode('latin-1') + except UnicodeEncodeError: pass # Check cache first @@ -2746,32 +2860,16 @@ class ProxyServer: self.vip_cache.set(access_key, False) return False - # Check expiration (if set). access_keys.expires_at is BIGINT (Unix epoch ms), - # but legacy/admin paths could still produce DATETIME or ISO strings — handle all three. - # Note: do NOT fall back to datetime.fromisoformat(str(int)) — Python 3.11+ parses - # e.g. "1808043002043" as year 1808, marking every non-null key as expired. + # Check expiration. access_keys.expires_at is epoch MILLISECONDS (BIGINT), + # matching Mainapi (parseAccessKeyExpiresAt -> getTime()). Compare as ms, + # same as checkVip.js: new Date() > new Date(expires_at). if expires_at is not None: - now = datetime.now(timezone.utc) - exp = None - if isinstance(expires_at, bool): - pass # bool is an int subclass — ignore - elif isinstance(expires_at, (int, float)): - try: - exp = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc) - except (ValueError, OSError, OverflowError): - exp = None - elif isinstance(expires_at, datetime): - exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc) - elif isinstance(expires_at, str): - try: - parsed = datetime.fromisoformat(expires_at) - exp = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) - except (ValueError, TypeError): - exp = None - - if exp is not None and exp < now: - self.vip_cache.set(access_key, False) - return False + try: + if int(expires_at) < int(time.time() * 1000): + self.vip_cache.set(access_key, False) + return False + except (ValueError, TypeError): + pass # Key is valid self.vip_cache.set(access_key, True) @@ -3244,19 +3342,15 @@ class ProxyServer: return full_url async def _extract_uqload_mp4_url(self, embed_url: str) -> str: - """Extract video URL from UQLOAD embed. - - Prefers HLS master.m3u8 (audio + video) over the v.mp4 fallback, - because Uqload's v.mp4 is currently a video-only track. - """ + """Extract MP4 URL from UQLOAD embed""" validated = self._validate_uqload_url(embed_url) urls = [validated, validated.replace('embed-', '')] - + headers = { 'User-Agent': 'Mozilla/5.0 Chrome/91.0.0.0', 'Accept': 'text/html,*/*' } - + html = None for url in urls: try: @@ -3268,22 +3362,18 @@ class ProxyServer: break except: continue - + if not html: raise ValueError('No content from UQLOAD') - + if 'File was deleted' in html: raise ValueError('Video deleted') - - m3u8_matches = re.findall(r'https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*', html) - if m3u8_matches: - return m3u8_matches[0] - - mp4_matches = re.findall(r'https?://[^\s"\'<>]+/v\.mp4', html) - if not mp4_matches: - raise ValueError('Video URL not found') - - return mp4_matches[0] + + matches = re.findall(r'https?://.+/v\.mp4', html) + if not matches: + raise ValueError('MP4 URL not found') + + return matches[0] async def uqload_extract_handler(self, request: Request) -> Response: """UQLOAD extraction""" @@ -3405,20 +3495,7 @@ class ProxyServer: return web.json_response({'error': str(e)}, status=500, headers=CORS_HEADERS) # ===== SeekStreaming (Embed4me) Extraction ===== - - async def _check_url_alive(self, url: str, headers: Dict, timeout_s: int = 4) -> bool: - """Quick liveness check: True if upstream returns 2xx within timeout.""" - try: - async with self.sessions['no_ssl'].get( - url, - headers=headers, - timeout=ClientTimeout(total=timeout_s), - allow_redirects=True, - ) as r: - return 200 <= r.status < 300 - except Exception: - return False - + def _decrypt_seekstreaming_data(self, hex_str: str) -> Optional[str]: """Decrypt AES-CBC encrypted data from seekstreaming/embed4me API""" try: @@ -3495,12 +3572,17 @@ class ProxyServer: } session = self.sessions['normal'] - timeout = ClientTimeout(total=10) - - async with session.get(api_url, headers=headers, timeout=timeout) as response: - if response.status != 200: - return web.json_response({'error': f'API error: {response.status}'}, status=502, headers=CORS_HEADERS) - encrypted_text = await response.text() + timeout = ClientTimeout(total=2, connect=2, sock_connect=2, sock_read=2) + + try: + async with session.get(api_url, headers=headers, timeout=timeout) as response: + if response.status != 200: + return web.json_response({'error': f'API error: {response.status}'}, status=502, headers=CORS_HEADERS) + encrypted_text = await asyncio.wait_for(response.text(), timeout=2) + except asyncio.TimeoutError: + return web.json_response({'error': 'Upstream API timeout'}, status=504, headers=CORS_HEADERS) + except aiohttp.ClientConnectorError as e: + return web.json_response({'error': f'Connection failed: {e}'}, status=502, headers=CORS_HEADERS) # Decrypt the response decrypted_raw = self._decrypt_seekstreaming_data(encrypted_text) @@ -3520,34 +3602,13 @@ class ProxyServer: # Pass the correct origin/referer to the proxy proxy_queries = f"&referer=https%3A//{api_domain}/&origin=https%3A//{api_domain}" + if raw_cf: + result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_cf)}{proxy_queries}" + if raw_source: + result['ip_url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(raw_source)}{proxy_queries}" + if not raw_cf and not raw_source: return web.json_response({'error': 'No video source found'}, status=404, headers=CORS_HEADERS) - - # Liveness check: probe both upstreams in parallel and only return - # one that actually responds. CF-fronted hosts (technicalcatalog.site, - # servicecatalog.site, ...) can get flagged as phishing and 403 - # globally; the IP-direct URL (signed token + expiry) keeps working. - # Reuse the player domain from the request as Referer/Origin. - upstream_headers = { - 'Accept': '*/*', - 'Referer': f'https://{api_domain}/', - 'Origin': f'https://{api_domain}', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36', - } - source_alive, cf_alive = await asyncio.gather( - self._check_url_alive(raw_source, upstream_headers) if raw_source else asyncio.sleep(0, result=False), - self._check_url_alive(raw_cf, upstream_headers) if raw_cf else asyncio.sleep(0, result=False), - ) - - if not source_alive and not cf_alive: - return web.json_response( - {'error': 'All upstream sources failed liveness check (likely 403/blocked)'}, - status=502, - headers=CORS_HEADERS, - ) - - chosen = raw_source if source_alive else raw_cf - result['url'] = f"{PROXY_BASE}/seekstreaming-proxy?url={urllib.parse.quote(chosen)}{proxy_queries}" self.seekstreaming_cache.set(cache_key, result) resp = web.json_response(result) @@ -3583,24 +3644,40 @@ class ProxyServer: url_lower = abs_url.lower() if any(ext in url_lower for ext in ('.ts', '.m4s', '.aac', '.mp4', '.fmp4', '.key')): abs_url = f"{abs_url}?{base_query}" - + # Append extra query params (referer, origin, etc.) suffix = f"&{extra_query}" if extra_query else "" return f"{proxy_route}?url={urllib.parse.quote(abs_url)}{suffix}" - + + def proxify_sub(url: str) -> str: + # SUBTITLES URIs must resolve to an m3u8 playlist. When a source + # points directly at a subtitle file, tag it so the proxy returns a + # synthetic wrapper playlist instead of the raw .vtt (which hls.js + # would reject with "Missing #EXTM3U"). Real subtitle playlists + # (.m3u8) are proxied normally. + abs_url = to_absolute(url) + if not abs_url: + return url + low = abs_url.split('?', 1)[0].lower() + if not (low.endswith('.vtt') or low.endswith('.srt')): + return proxify(url) + suffix = f"&{extra_query}" if extra_query else "" + return f"{proxy_route}?url={urllib.parse.quote(abs_url)}&vttwrap=1{suffix}" + re_uri_dq = self.RE_M3U8_URI_DQ re_uri_sq = self.RE_M3U8_URI_SQ re_uri_uq = self.RE_M3U8_URI_UQ re_http = self.RE_M3U8_HTTP - + def rewrite_line(line: str) -> str: trimmed = line.strip() if not trimmed: return line if trimmed.startswith('#'): - line = re_uri_dq.sub(lambda m: f'URI="{proxify(m.group(1).strip())}"', line) - line = re_uri_sq.sub(lambda m: f'URI="{proxify(m.group(1).strip())}"', line) - line = re_uri_uq.sub(lambda m: f'URI="{proxify(m.group(1).strip())}"', line) + fn = proxify_sub if ('TYPE=SUBTITLES' in trimmed.upper()) else proxify + line = re_uri_dq.sub(lambda m: f'URI="{fn(m.group(1).strip())}"', line) + line = re_uri_sq.sub(lambda m: f'URI="{fn(m.group(1).strip())}"', line) + line = re_uri_uq.sub(lambda m: f'URI="{fn(m.group(1).strip())}"', line) return line if re_http.match(trimmed): return proxify(trimmed) @@ -3633,21 +3710,23 @@ class ProxyServer: except: pass - # Range support + # Detect content type + content = detect_content_type(target_url, request.headers.get('accept', '')) + + # Range support — never forward to manifests: we need the full text to + # parse/rewrite, and upstreams that honor Range return a truncated 206 + # that fails M3U8 validation (client then retries forever). range_header = request.headers.get('range') or request.headers.get('Range') - if range_header: + if range_header and not content.is_m3u8: headers['Range'] = range_header - + # Capture extra query params (referer, origin) to pass to rewritten segments extra_params = [] for k, v in request.query.items(): if k != 'url': extra_params.append(f"{k}={urllib.parse.quote(v)}") extra_query = "&".join(extra_params) - - # Detect content type - content = detect_content_type(target_url, request.headers.get('accept', '')) - + # Timeouts based on content if content.is_mp4 and range_header: timeout = ClientTimeout(total=None, connect=10, sock_read=30) @@ -3669,6 +3748,31 @@ class ProxyServer: logger.warning(f'[{service_name.upper()}-PROXY] âš  Session "{session_key}" introuvable, fallback sur "normal" (sans proxy) ! Vérifier PROXIES_SOCKS5_JSON') route = proxy_route or f'/{service_name}-proxy' + # Subtitle wrapper: some sources (e.g. topstream) declare + # #EXT-X-MEDIA:TYPE=SUBTITLES with a URI pointing straight at a .vtt file. + # hls.js expects that URI to be an m3u8 playlist, so synthesize a + # one-segment playlist that references the real VTT (fetched normally + # on the follow-up request without the marker). + if request.query.get('vttwrap'): + inner_params = [f"{k}={urllib.parse.quote(v)}" for k, v in request.query.items() + if k not in ('url', 'vttwrap')] + inner_suffix = ('&' + '&'.join(inner_params)) if inner_params else '' + inner_url = f"{route}?url={urllib.parse.quote(target_url)}{inner_suffix}" + playlist = ( + "#EXTM3U\n" + "#EXT-X-VERSION:3\n" + "#EXT-X-TARGETDURATION:999999\n" + "#EXT-X-MEDIA-SEQUENCE:0\n" + "#EXTINF:999999.0,\n" + f"{inner_url}\n" + "#EXT-X-ENDLIST\n" + ) + return web.Response(text=playlist, headers={ + **CORS_HEADERS, + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache', + }) + logger.info(f'[{service_name.upper()}-PROXY] → session={actual_session_key} url={target_url} headers={headers}') try: @@ -3688,7 +3792,13 @@ class ProxyServer: 'X-Segment-Cache': 'HIT', }) - async with session.get(target_url, headers=headers, timeout=timeout) as response: + if service_name == 'cinep' and self.curl_session is not None: + timeout_s = timeout.total or timeout.sock_read or 30 + upstream_cm = _CurlCffiUpstream(self.curl_session, target_url, headers, timeout_s, service_name) + else: + upstream_cm = session.get(target_url, headers=headers, timeout=timeout) + + async with upstream_cm as response: resp_headers = self._prepare_stream_headers(response.headers) # Pass through redirects while keeping the client inside this service proxy route @@ -3817,7 +3927,7 @@ class ProxyServer: # Default streaming return await self._stream_response(request, response, resp_headers, CHUNK_DEFAULT) - + except asyncio.TimeoutError as e: return web.json_response( { @@ -3829,7 +3939,7 @@ class ProxyServer: status=504, headers=CORS_HEADERS, ) - except aiohttp.ClientError as e: + except (aiohttp.ClientError, _CurlRequestException) as e: return web.json_response( { 'error': 'Upstream request failed', @@ -4090,8 +4200,8 @@ class ProxyServer: """CinePulse proxy""" return await self._service_proxy(request, 'cinep', { 'Accept': 'application/vnd.apple.mpegurl,*/*', - 'Origin': 'https://cinepulse.lol', - 'Referer': 'https://cinepulse.lol/', + 'Origin': 'https://purstream.mx', + 'Referer': 'https://purstream.mx/', 'User-Agent': 'Mozilla/5.0 Chrome/143.0.0.0' }) @@ -4470,7 +4580,9 @@ async def main(): for name, session in server.sessions.items(): if not session.closed: cleanup_tasks.append(session.close()) - + if server.curl_session is not None: + cleanup_tasks.append(server.curl_session.close()) + if cleanup_tasks: await asyncio.gather(*cleanup_tasks, return_exceptions=True) diff --git a/package-lock.json b/package-lock.json index 54739bb..c6a00db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@fontsource/archivo-black": "^5.2.8", + "@fontsource/inter": "^5.2.8", "@headlessui/react": "^2.2.0", "@hono/node-server": "^1.19.14", "@radix-ui/react-dialog": "^1.1.15", @@ -56,6 +58,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.22.3", "react-snowfall": "^2.4.0", + "recharts": "^3.8.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "shaka-player": "^5.0.0", @@ -626,6 +629,24 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@fontsource/archivo-black": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/archivo-black/-/archivo-black-5.2.8.tgz", + "integrity": "sha512-3zNj/o9LzWyDl/UEpY5IOHpAQyUtFr3hQaFS7NSKwCLLkXOfH/CMCt1L2b2Z+OF25OURtOYenCadgAebALz7/A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@headlessui/react": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", @@ -2297,6 +2318,42 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -2613,6 +2670,18 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@svta/cml-608": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@svta/cml-608/-/cml-608-1.0.1.tgz", @@ -2789,6 +2858,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2893,6 +3025,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -4054,6 +4192,15 @@ "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", "license": "MIT" }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", @@ -4101,6 +4248,18 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-time": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", @@ -4229,6 +4388,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4795,6 +4960,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5396,6 +5567,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -8120,6 +8301,29 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -8255,6 +8459,61 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/es-toolkit": { + "version": "1.39.3", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.3.tgz", + "integrity": "sha512-Qb/TCFCldgOy8lZ5uC7nLGdqJwSabkQiYQShmw4jyiPk1pZzaYWTwaYKYP7EgLccWYgZocMrtItrwh683voaww==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/remark-emoji": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-5.0.2.tgz", @@ -8352,6 +8611,12 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "license": "ISC" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9070,6 +9335,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -9511,6 +9782,28 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/video.js": { "version": "8.23.4", "resolved": "https://registry.npmjs.org/video.js/-/video.js-8.23.4.tgz", diff --git a/package.json b/package.json index bed6c62..73f8a99 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,8 @@ "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", + "@fontsource/archivo-black": "^5.2.8", + "@fontsource/inter": "^5.2.8", "@headlessui/react": "^2.2.0", "@hono/node-server": "^1.19.14", "@radix-ui/react-dialog": "^1.1.15", @@ -68,6 +70,7 @@ "react-markdown": "^10.1.0", "react-router-dom": "^6.22.3", "react-snowfall": "^2.4.0", + "recharts": "^3.8.1", "remark-emoji": "^5.0.1", "remark-gfm": "^4.0.1", "shaka-player": "^5.0.0", @@ -104,6 +107,7 @@ "wrangler": "^4.15.1" }, "overrides": { - "framer-motion": "11.11.10" + "framer-motion": "11.11.10", + "es-toolkit": "1.39.3" } } diff --git a/src/App.tsx b/src/App.tsx index acd1a02..1f58dc3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { AlertService } from './services/alertService'; import NotificationToast from './components/NotificationToast'; import { NotificationData } from './types/alerts'; import RedirectPopup from './components/RedirectPopup'; +import RequireUsernameChange from './components/RequireUsernameChange'; import { TopProgressBar } from './components/TopProgressBar'; import SmoothScroll from './components/SmoothScroll'; import AprilFoolsAdminPage from './pages/AprilFoolsAdminPage'; @@ -602,7 +603,16 @@ const PersistenceManager = () => { } }; - const handleAuthChanged = () => handleStorageChange(null); + const handleAuthChanged = () => { + // Re-arm the sync gate while ProfileContext.loadProfiles reloads the new + // account's profiles + data. Without this, the ~100-500ms window before + // the delayed loadProfiles fires lets setItem writes leak to the new + // backend with the previous account's stale selected_profile_id → the + // backend returns 404 PROFILE_NOT_FOUND or, worse, writes against the + // wrong profile if the id collides. + isProfileDataLoadingRef.current = true; + handleStorageChange(null); + }; // Initialize current user info currentUserInfo.current = getUserInfo(); @@ -986,8 +996,22 @@ const PersistenceManager = () => { const delta = computeObjectPatch(oldVal, newVal); if (delta) { const existing = progressOpsMapRef.current.get(key) || { delta: { set: {}, remove: [] as string[] } }; - existing.delta.set = { ...existing.delta.set, ...delta.set }; - existing.delta.remove = Array.from(new Set([...(existing.delta.remove || []), ...delta.remove])); + const newSet = delta.set || {}; + const newRemove = Array.isArray(delta.remove) ? delta.remove : []; + // Newer ops in this delta win over earlier merged ops queued for + // the same key. Without this reconciliation, a remove followed by + // a set (or vice versa) within the 10s flush window produced a + // merged objPatch with BOTH `set.X` and `remove[X]`; the backend + // applies sets first then removes, so `remove` always won and the + // user's most recent intent (re-set) was silently dropped. + existing.delta.set = { ...existing.delta.set, ...newSet }; + existing.delta.remove = (existing.delta.remove || []).filter( + (entryKey: string) => !Object.prototype.hasOwnProperty.call(newSet, entryKey) + ); + for (const entryKey of newRemove) { + delete existing.delta.set[entryKey]; + } + existing.delta.remove = Array.from(new Set([...existing.delta.remove, ...newRemove])); progressOpsMapRef.current.set(key, existing); } else { // If cannot diff, fallback to set @@ -1310,6 +1334,16 @@ const PersistenceManager = () => { // older un-replayed ops every cycle. A hard cap on op count prevents // unbounded growth across many failed cycles; oldest ops are dropped // first since newer ops reflect later state and ops are idempotent. + // Drop ops whose keys are no longer in the syncable allowlist. Legacy + // outboxes may carry keys removed for security (e.g. `is_vip` per audit + // P0); leaving them in causes the next replay to 400 → permanent drop + // of the entire outbox, including the legitimate ops we just merged. + const isValidSyncOp = (op: unknown): op is Record<string, unknown> & { key: string } => { + if (!op || typeof op !== 'object') return false; + const candidateKey = (op as { key?: unknown }).key; + return typeof candidateKey === 'string' && isSyncableStorageKey(candidateKey); + }; + try { const MAX_OUTBOX_OPS = 10000; let mergedOps: Array<Record<string, unknown>> = pending; @@ -1326,18 +1360,26 @@ const PersistenceManager = () => { && parsed.profileId === userInfo.profileId && Array.isArray(parsed.ops) && parsed.ops.length > 0) { + const existingOps = (parsed.ops as unknown[]).filter(isValidSyncOp); mergedOps = [ - ...(parsed.ops as Array<Record<string, unknown>>), + ...existingOps, ...pending ]; } } } catch { /* noop */ } + mergedOps = mergedOps.filter(isValidSyncOp); + if (mergedOps.length > MAX_OUTBOX_OPS) { mergedOps = mergedOps.slice(mergedOps.length - MAX_OUTBOX_OPS); } + if (mergedOps.length === 0) { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + return; + } + const outboxPayload = { userType: userInfo.type, profileId: userInfo.profileId, @@ -1838,7 +1880,7 @@ const EmbedBlockPage = () => ( {i18n.t('embed.message')} </p> <a - href="https://movix.tax" + href="https://movix.date" target="_blank" rel="noopener noreferrer" className="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition sm:py-3 sm:px-6 lg:py-4 lg:px-8" @@ -1920,6 +1962,7 @@ function App() { <TopProgressBar /> <Toaster position="bottom-right" richColors /> <DnsBlockBanner /> + <RequireUsernameChange /> </IntroProvider> </TurnstileProvider> </ProfileProvider> diff --git a/src/components/AccessCodeForm.tsx b/src/components/AccessCodeForm.tsx index 09e7e21..375f7c5 100644 --- a/src/components/AccessCodeForm.tsx +++ b/src/components/AccessCodeForm.tsx @@ -2,9 +2,10 @@ import React, { useState } from 'react'; import { useAuth } from '../context/AuthContext'; import { useNavigate } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; -import { X } from 'lucide-react'; +import { X, Crown } from 'lucide-react'; import { useVipModal } from '../context/VipModalContext'; import { useTranslation } from 'react-i18next'; +import { PrefetchLink as Link } from '@/routing/PrefetchLink'; // Interface SellAuth supprimée car nous utilisons désormais Discord pour les tickets VIP @@ -147,17 +148,25 @@ const AccessCodeForm: React.FC<AccessCodeFormProps> = ({ isModal = false, hideNo <li>{t('vip.noAds')}</li> <li>{t('vip.prioritySupport')}</li> </ul> - <p className="text-sm text-gray-400 mt-6"> - {t('vip.getAccessClicks')} - </p> <p className="text-center py-3 px-4 bg-gray-700/60 rounded-xl text-gray-200"> - {t('vip.joinTelegramForVip')} + {t('vip.donateForKey')} </p> + <Link + to="/vip" + onClick={() => { + handleCloseVipInfo(); + closeVipModal(); + }} + className="flex items-center justify-center gap-2 bg-yellow-500 hover:bg-yellow-400 text-black py-3 px-4 rounded-xl font-medium transition-colors duration-200" + > + <Crown className="w-4 h-4" /> + {t('vip.donate')} + </Link> <a href="https://t.me/movix_site" target="_blank" rel="noopener noreferrer" - className="block text-center bg-[#0088cc] text-white py-3 px-4 rounded-xl font-medium hover:bg-[#006699] transition-colors duration-200 mt-2" + className="block text-center bg-[#0088cc] text-white py-3 px-4 rounded-xl font-medium hover:bg-[#006699] transition-colors duration-200" > {t('telegram.joinTelegram')} </a> @@ -258,17 +267,22 @@ const AccessCodeForm: React.FC<AccessCodeFormProps> = ({ isModal = false, hideNo <li>{t('vip.multiLangSubtitles')}</li> <li>{t('vip.multipleLanguages')}</li> </ul> - <p className="text-sm text-gray-400 mt-6"> - {t('vip.getAccessClicks')} - </p> <p className="text-center py-3 px-4 bg-gray-700/60 rounded-xl text-gray-200"> - {t('vip.contactTelegramForVip')} + {t('vip.donateForKey')} </p> + <Link + to="/vip" + onClick={handleCloseVipInfo} + className="flex items-center justify-center gap-2 bg-yellow-500 hover:bg-yellow-400 text-black py-3 px-4 rounded-xl font-medium transition-colors duration-200" + > + <Crown className="w-4 h-4" /> + {t('vip.donate')} + </Link> <a href="https://t.me/movix_site" target="_blank" rel="noopener noreferrer" - className="block text-center bg-[#0088cc] text-white py-3 px-4 rounded-xl font-medium hover:bg-[#006699] transition-colors duration-200 mt-2" + className="block text-center bg-[#0088cc] text-white py-3 px-4 rounded-xl font-medium hover:bg-[#006699] transition-colors duration-200" > {t('telegram.joinTelegram')} </a> diff --git a/src/components/AdFreePlayerAds.tsx b/src/components/AdFreePlayerAds.tsx index 6dd02df..53bb93c 100644 --- a/src/components/AdFreePlayerAds.tsx +++ b/src/components/AdFreePlayerAds.tsx @@ -1,9 +1,12 @@ -import React, { useState, useCallback, useEffect } from "react"; +import React, { useState, useCallback, useEffect, useRef } from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; import { PrefetchLink as Link } from '@/routing/PrefetchLink'; import { Play, ShieldAlert, Settings, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useAdFreePopup } from "../context/AdFreePopupContext"; +import { getAdPopupMode, subscribeToAdPopupModeChanges, type AdPopupMode } from "../utils/adPopupMode"; +import { getAdTargetUrls } from "../utils/adAdultMode"; +import { SCRIPT_AD_MODE_ENABLED, loadAdScript } from "../utils/adScriptMode"; interface AdFreePlayerAdsProps { onClose?: () => void; @@ -34,9 +37,35 @@ const AdFreePlayerAds: React.FC<AdFreePlayerAdsProps> = ({ const [hasClicked, setHasClicked] = useState(false); const [isClosing, setIsClosing] = useState(false); + const [popupMode, setPopupMode] = useState<AdPopupMode>(() => getAdPopupMode()); + const autoFiredRef = useRef(false); + const scriptAdFiredRef = useRef(false); + const scriptAcceptTimeoutRef = useRef<number | null>(null); + + // Le mode script ne s'applique qu'au popup normal avec bouton. Les modes auto + // et click-anywhere gardent le lien direct. + const scriptAdMode = popupMode === 'normal' && SCRIPT_AD_MODE_ENABLED; + + useEffect(() => subscribeToAdPopupModeChanges(setPopupMode), []); useEffect(() => { - if (!shouldShow) return; + // Reset des gardes quand le popup disparaît, pour laisser le suivant se déclencher. + if (!shouldShow) { + autoFiredRef.current = false; + scriptAdFiredRef.current = false; + if (scriptAcceptTimeoutRef.current !== null) { + window.clearTimeout(scriptAcceptTimeoutRef.current); + scriptAcceptTimeoutRef.current = null; + } + } + }, [shouldShow]); + + useEffect(() => { + if (shouldShow && scriptAdMode) loadAdScript(); + }, [shouldShow, scriptAdMode]); + + useEffect(() => { + if (!shouldShow || popupMode !== 'normal') return; const lenis = ( window as Window & { lenis?: { stop: () => void; start: () => void } } @@ -46,23 +75,66 @@ const AdFreePlayerAds: React.FC<AdFreePlayerAdsProps> = ({ return () => { if (lenis) lenis.start(); }; - }, [shouldShow]); + }, [shouldShow, popupMode]); - // Construire le lien à l'exécution pour éviter le filtrage réseau (Brave Shields / EasyList) - const targetLink = - "https://yawncollaremotion.com/av38fgu9i6?key=77d634ec911d95219d57b78717969034"; + // Ouvre toutes les cibles pub (1 fenêtre par URL) dans le même geste user. + // Anchor créé à l'exécution pour éviter le filtrage réseau (Brave Shields / + // EasyList). Cibles déterminées par le toggle "Publicités +18" : en +18 = les + // 3 directlinks, sinon le lien SFW unique (utils/adAdultMode). Lu frais au clic. + // NB: le navigateur n'autorise qu'1 popup non sollicité par geste -> les autres + // sont souvent bloqués sauf si l'utilisateur autorise les popups pour le site. + const openAdLinks = useCallback(() => { + getAdTargetUrls().forEach((url) => { + const a = document.createElement("a"); + a.href = url; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }); + }, []); - const handleLinkClick = () => { - const a = document.createElement("a"); - a.href = targetLink; - a.target = "_blank"; - a.rel = "noopener noreferrer"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + const handleLinkClick = useCallback(() => { + openAdLinks(); setHasClicked(true); if (onAdClick) onAdClick(); - }; + }, [openAdLinks, onAdClick]); + + const completeScriptAdGesture = useCallback(() => { + if (scriptAcceptTimeoutRef.current !== null) { + window.clearTimeout(scriptAcceptTimeoutRef.current); + scriptAcceptTimeoutRef.current = null; + } + if (onAdClick) onAdClick(); + setHasClicked(true); + }, [onAdClick]); + + const beginScriptAdGesture = useCallback(() => { + if (scriptAdFiredRef.current) return; + scriptAdFiredRef.current = true; + loadAdScript(); + scriptAcceptTimeoutRef.current = window.setTimeout(completeScriptAdGesture, 700); + }, [completeScriptAdGesture]); + + // Combine: ouvre les directlinks +18 ET déclenche le popunder réseau (script) + // sur le même clic. Liens d'abord (geste frais), popunder ensuite. + const handleScriptAdClick = useCallback(() => { + openAdLinks(); + beginScriptAdGesture(); + }, [openAdLinks, beginScriptAdGesture]); + + // Le popunder peut consommer le onClick React. La capture pointerdown prépare + // la détection avant les listeners document du script. + useEffect(() => { + if (!shouldShow || !scriptAdMode || hasClicked) return; + const onCapturePointer = (e: PointerEvent) => { + const target = e.target as Element | null; + if (target && target.closest('[data-ad-view-button]')) beginScriptAdGesture(); + }; + window.addEventListener('pointerdown', onCapturePointer, true); + return () => window.removeEventListener('pointerdown', onCapturePointer, true); + }, [shouldShow, scriptAdMode, hasClicked, beginScriptAdGesture]); // Fermeture avec animation de sortie avant de notifier le parent const handleClose = useCallback(() => { @@ -72,6 +144,17 @@ const AdFreePlayerAds: React.FC<AdFreePlayerAdsProps> = ({ }, 300); }, [finalOnAccept]); + // Auto mode: fire the ad + accept as soon as the popup is requested. The + // guard ref ensures a single trigger per popup lifecycle (re-renders won't + // re-fire it). The reset effect above clears the guard when shouldShow flips + // back to false, so the next popup can trigger again. + useEffect(() => { + if (!shouldShow || popupMode !== 'auto' || autoFiredRef.current) return; + autoFiredRef.current = true; + handleLinkClick(); + finalOnAccept(); + }, [shouldShow, popupMode, handleLinkClick, finalOnAccept]); + // Texte contextualisé const headerText = hasClicked ? variant === "download" @@ -111,6 +194,32 @@ const AdFreePlayerAds: React.FC<AdFreePlayerAdsProps> = ({ if (!shouldShow) return null; + // Auto mode: useEffect above fires the ad + accept; render nothing. + if (popupMode === 'auto') return null; + + // Click-anywhere mode: invisible full-screen catcher, first click opens ad + accepts. + if (popupMode === 'click-anywhere' && !hasClicked) { + return ( + <div + role="button" + tabIndex={0} + aria-label={t('adBlocker.clickAnywhereLabel')} + className="fixed inset-0 z-[100000] cursor-pointer bg-transparent" + onClick={() => { + handleLinkClick(); + finalOnAccept(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleLinkClick(); + finalOnAccept(); + } + }} + /> + ); + } + return ( <DialogPrimitive.Root open={!isClosing} @@ -250,7 +359,8 @@ const AdFreePlayerAds: React.FC<AdFreePlayerAdsProps> = ({ ) : ( <> <button - onClick={handleLinkClick} + data-ad-view-button + onClick={scriptAdMode ? handleScriptAdClick : handleLinkClick} className="flex items-center justify-center font-bold whitespace-nowrap relative overflow-hidden transition-all duration-200 h-12 text-base px-6 rounded-lg bg-blue-600 text-white hover:bg-blue-700 hover:scale-105 active:scale-95 focus:ring-2 focus:ring-blue-400 focus:ring-offset-2 shadow-lg w-full max-w-xs mb-1 cursor-pointer" autoFocus > diff --git a/src/components/AdminComments.tsx b/src/components/AdminComments.tsx index a3b4d0a..efb9adf 100644 --- a/src/components/AdminComments.tsx +++ b/src/components/AdminComments.tsx @@ -1762,7 +1762,7 @@ const AdminComments: React.FC = () => { <Badge variant="rejected" className="bg-red-500/20 text-red-400 border-red-500/30"> {viewComment.moderation_reason} </Badge> - <span className="text-[10px] text-white/40">par Gemini AI</span> + <span className="text-[10px] text-white/40">{t('admin.generatedByGemini')}</span> </div> {viewComment.moderation_details && ( <p className="text-xs text-white/60"> diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 332deaa..06dff0f 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -12,6 +12,7 @@ import { ShieldCheck, Sparkles, Sprout, + Users, Wrench } from 'lucide-react'; @@ -22,6 +23,7 @@ import AdminWishboard from './Greenlight/AdminWishboard'; import AdminOAuthApps from './AdminOAuthApps'; import AdminReports from './AdminReports'; import AdminSharedLists from './AdminSharedLists'; +import AdminTeam from './AdminTeam'; import StreamingLinksManager from './StreamingLinksManager'; import VipInvoicesManager from './VipInvoicesManager'; import VipKeysManager from './VipKeysManager'; @@ -38,7 +40,8 @@ type AdminSection = | 'comments' | 'shared-lists' | 'reports' - | 'help-feedback'; + | 'help-feedback' + | 'team'; interface AdminDashboardProps { role: 'admin' | 'uploader'; @@ -59,6 +62,14 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => { const allSections = useMemo<DashboardSection[]>( () => [ + { + id: 'team', + title: t('admin.team.cardTitle', 'Admins & Uploaders'), + description: t('admin.team.cardDesc', 'Gérer l\'équipe, ajouter des uploaders et voir leur historique'), + icon: Users, + accent: 'text-sky-300', + highlight: '56 189 248' + }, { id: 'links', title: t('admin.manageMoviesSeries'), @@ -237,6 +248,16 @@ const AdminDashboard: React.FC<AdminDashboardProps> = ({ role }) => { backgroundColor="10 10 10" className="p-6 md:p-7" > + {activeSection === 'team' && role === 'admin' && ( + <div> + <h2 className="mb-6 flex items-center gap-3 text-2xl font-bold text-white"> + <Users className="h-6 w-6 text-sky-300" /> + {t('admin.team.cardTitle', 'Admins & Uploaders')} + </h2> + <AdminTeam /> + </div> + )} + {activeSection === 'links' && ( <div> <h2 className="mb-6 flex items-center gap-3 text-2xl font-bold text-white"> diff --git a/src/components/AdminTeam.tsx b/src/components/AdminTeam.tsx new file mode 100644 index 0000000..06c5dca --- /dev/null +++ b/src/components/AdminTeam.tsx @@ -0,0 +1,616 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import axios, { AxiosError } from 'axios'; +import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; +import { + Clock, + Copy, + Minus, + Plus, + RefreshCw, + ShieldCheck, + Trash2, + Upload, + User, +} from 'lucide-react'; + +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import ConfirmDialog from './ui/confirm-dialog'; +import { Input } from './ui/input'; +import ReusableModal from './ui/reusable-modal'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface TeamMember { + id: number; + userId: string; + authType: 'oauth' | 'bip-39'; + role: 'admin' | 'uploader'; + username: string; + avatar: string | null; + createdAt: string; +} + +interface HistoryEntry { + action: 'added' | 'removed'; + link_type: 'streaming' | 'download'; + media_type: 'movie' | 'tv'; + tmdb_id: number; + season: number | null; + episode: number | null; + link_url: string; + changed_at: string; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const API_URL = import.meta.env.VITE_MAIN_API; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function getRequestErrorMessage(error: unknown): string { + if (axios.isAxiosError(error)) { + const ax = error as AxiosError<{ error?: string }>; + return ax.response?.data?.error || ax.message; + } + if (error instanceof Error) return error.message; + return ''; +} + +const getAuth = () => ({ Authorization: `Bearer ${localStorage.getItem('auth_token')}` }); + +// Convert stored authType to query param value +function toApiAuthType(authType: 'oauth' | 'bip-39'): 'oauth' | 'bip39' { + return authType === 'bip-39' ? 'bip39' : 'oauth'; +} + +function formatMediaLabel(entry: HistoryEntry): string { + const base = `${entry.media_type} #${entry.tmdb_id}`; + if (entry.media_type !== 'tv' || (entry.season === null && entry.episode === null)) { + return base; + } + if (entry.season === 0) { + return `${base} – saison complète`; + } + const s = entry.season !== null ? `S${entry.season}` : ''; + const e = entry.episode !== null ? `E${entry.episode}` : ''; + return `${base} ${s}${e}`.trim(); +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +const AdminTeam: React.FC = () => { + const { t } = useTranslation(); + + const [members, setMembers] = useState<TeamMember[]>([]); + const [loading, setLoading] = useState(true); + + // Add form state + const [addOpen, setAddOpen] = useState(false); + const [addUserId, setAddUserId] = useState(''); + const [addAuthType, setAddAuthType] = useState<'oauth' | 'bip39'>('oauth'); + const [addBusy, setAddBusy] = useState(false); + + // Remove confirm state + const [removeCandidate, setRemoveCandidate] = useState<TeamMember | null>(null); + const [removeBusy, setRemoveBusy] = useState(false); + + // History modal state + const [historyMember, setHistoryMember] = useState<TeamMember | null>(null); + const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]); + const [historyTotal, setHistoryTotal] = useState(0); + const [historyHasMore, setHistoryHasMore] = useState(false); + const [historyPage, setHistoryPage] = useState(1); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyLoadingMore, setHistoryLoadingMore] = useState(false); + + // ─── Load members ─────────────────────────────────────────────────────────── + + const loadMembers = useCallback(async () => { + setLoading(true); + try { + const res = await axios.get(`${API_URL}/api/admin/admins`, { headers: getAuth() }); + setMembers(res.data?.members || []); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error', 'Erreur')); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + loadMembers(); + }, [loadMembers]); + + // ─── Add uploader ──────────────────────────────────────────────────────────── + + const handleAdd = async () => { + if (!addUserId.trim()) return; + setAddBusy(true); + try { + await axios.post( + `${API_URL}/api/admin/admins`, + { userId: addUserId.trim(), authType: addAuthType }, + { headers: getAuth() }, + ); + toast.success(t('admin.team.addedSuccess', 'Uploader ajouté avec succès')); + setAddOpen(false); + setAddUserId(''); + setAddAuthType('oauth'); + loadMembers(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error', 'Erreur')); + } finally { + setAddBusy(false); + } + }; + + // ─── Remove uploader ───────────────────────────────────────────────────────── + + const handleRemoveConfirmed = async () => { + if (!removeCandidate) return; + setRemoveBusy(true); + try { + await axios.delete(`${API_URL}/api/admin/admins/${removeCandidate.id}`, { headers: getAuth() }); + toast.success(t('admin.team.removedSuccess', 'Uploader retiré')); + setRemoveCandidate(null); + loadMembers(); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error', 'Erreur')); + } finally { + setRemoveBusy(false); + } + }; + + // ─── History ───────────────────────────────────────────────────────────────── + + const fetchHistory = useCallback( + async (member: TeamMember, page: number, append: boolean) => { + if (append) { + setHistoryLoadingMore(true); + } else { + setHistoryLoading(true); + setHistoryEntries([]); + setHistoryTotal(0); + setHistoryHasMore(false); + } + try { + const res = await axios.get(`${API_URL}/api/admin/team/history`, { + params: { + userId: member.userId, + authType: toApiAuthType(member.authType), + page, + limit: 30, + }, + headers: getAuth(), + }); + const entries: HistoryEntry[] = res.data?.history || []; + if (append) { + setHistoryEntries((prev) => [...prev, ...entries]); + } else { + setHistoryEntries(entries); + } + setHistoryTotal(res.data?.total ?? 0); + setHistoryHasMore(res.data?.hasMore ?? false); + } catch (err) { + toast.error(getRequestErrorMessage(err) || t('common.error', 'Erreur')); + } finally { + setHistoryLoading(false); + setHistoryLoadingMore(false); + } + }, + [t], + ); + + const openHistory = (member: TeamMember) => { + setHistoryMember(member); + setHistoryPage(1); + fetchHistory(member, 1, false); + }; + + const closeHistory = () => { + setHistoryMember(null); + setHistoryEntries([]); + setHistoryTotal(0); + setHistoryHasMore(false); + setHistoryPage(1); + }; + + const handleLoadMore = () => { + if (!historyMember || historyLoadingMore) return; + const nextPage = historyPage + 1; + setHistoryPage(nextPage); + fetchHistory(historyMember, nextPage, true); + }; + + // ─── Render ────────────────────────────────────────────────────────────────── + + return ( + <div className="space-y-6"> + {/* Header row */} + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-center gap-2"> + <h2 className="text-lg font-semibold text-white"> + {t('admin.team.title', 'Équipe admin')} + </h2> + {!loading && ( + <span className="text-xs text-white/40">({members.length})</span> + )} + <Button + variant="outline" + size="icon" + onClick={loadMembers} + title={t('common.refresh', 'Rafraîchir')} + > + <RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> + </Button> + </div> + <Button onClick={() => setAddOpen(true)}> + <Plus className="mr-2 h-4 w-4" /> + {t('admin.team.addUploader', 'Ajouter un uploader')} + </Button> + </div> + + {/* Member list */} + {loading ? ( + <div className="py-12 text-center text-white/60"> + {t('common.loading', 'Chargement…')} + </div> + ) : members.length === 0 ? ( + <div className="rounded-xl border border-dashed border-white/10 py-12 text-center text-white/60"> + {t('admin.team.empty', 'Aucun membre dans l\'équipe')} + </div> + ) : ( + <div className="space-y-3"> + {members.map((member) => ( + <MemberCard + key={member.id} + member={member} + onHistory={() => openHistory(member)} + onRemove={() => setRemoveCandidate(member)} + /> + ))} + </div> + )} + + {/* Add uploader modal */} + <ReusableModal + isOpen={addOpen} + onClose={() => !addBusy && setAddOpen(false)} + title={t('admin.team.addUploader', 'Ajouter un uploader')} + className="max-w-md" + > + <div className="space-y-4"> + <div> + <label className="mb-1 block text-xs font-semibold uppercase tracking-wider text-white/50"> + {t('admin.team.userIdLabel', 'Identifiant utilisateur')} + </label> + <Input + type="text" + value={addUserId} + onChange={(e) => setAddUserId(e.target.value)} + placeholder={t('admin.team.userIdPlaceholder', 'discord:123456789 ou bip39:…')} + className="font-mono" + disabled={addBusy} + /> + </div> + + <div> + <label className="mb-2 block text-xs font-semibold uppercase tracking-wider text-white/50"> + {t('admin.team.authTypeLabel', 'Type d\'authentification')} + </label> + <div className="flex flex-col gap-2 text-sm sm:flex-row sm:gap-4"> + <label className="flex cursor-pointer items-center gap-2 text-white/80"> + <input + type="radio" + name="authType" + checked={addAuthType === 'oauth'} + onChange={() => setAddAuthType('oauth')} + className="accent-yellow-300" + disabled={addBusy} + /> + {t('admin.team.authTypeOAuth', 'Discord / Google (OAuth)')} + </label> + <label className="flex cursor-pointer items-center gap-2 text-white/80"> + <input + type="radio" + name="authType" + checked={addAuthType === 'bip39'} + onChange={() => setAddAuthType('bip39')} + className="accent-yellow-300" + disabled={addBusy} + /> + {t('admin.team.authTypeBip39', 'BIP-39')} + </label> + </div> + </div> + + <div className="flex justify-end gap-2 pt-2"> + <Button + variant="secondary" + onClick={() => { setAddOpen(false); setAddUserId(''); setAddAuthType('oauth'); }} + disabled={addBusy} + > + {t('common.cancel', 'Annuler')} + </Button> + <Button + onClick={handleAdd} + disabled={addBusy || !addUserId.trim()} + > + <Plus className="mr-2 h-4 w-4" /> + {addBusy + ? t('admin.team.adding', 'Ajout…') + : t('admin.team.addUploader', 'Ajouter un uploader')} + </Button> + </div> + </div> + </ReusableModal> + + {/* Remove confirm dialog */} + <ConfirmDialog + isOpen={!!removeCandidate} + title={t('admin.team.removeTitle', 'Retirer cet uploader ?')} + message={ + removeCandidate + ? t('admin.team.removeConfirm', `Retirer @${removeCandidate.username} de l'équipe ?`) + : '' + } + variant="destructive" + busy={removeBusy} + onConfirm={handleRemoveConfirmed} + onCancel={() => !removeBusy && setRemoveCandidate(null)} + /> + + {/* History modal */} + {historyMember && ( + <HistoryModal + member={historyMember} + entries={historyEntries} + total={historyTotal} + hasMore={historyHasMore} + loading={historyLoading} + loadingMore={historyLoadingMore} + onLoadMore={handleLoadMore} + onClose={closeHistory} + /> + )} + </div> + ); +}; + +// ─── MemberCard ─────────────────────────────────────────────────────────────── + +interface MemberCardProps { + member: TeamMember; + onHistory: () => void; + onRemove: () => void; +} + +const MemberCard: React.FC<MemberCardProps> = ({ member, onHistory, onRemove }) => { + const { t } = useTranslation(); + + const handleCopyUserId = async () => { + try { + await navigator.clipboard.writeText(member.userId); + toast.success(t('admin.team.copied', 'Copié')); + } catch { + toast.error(t('common.error', 'Erreur')); + } + }; + + const authTypeLabel = + member.authType === 'bip-39' + ? t('admin.team.authTypeBip39Short', 'BIP-39') + : t('admin.team.authTypeOAuthShort', 'OAuth'); + + return ( + <div className="rounded-2xl border border-white/10 bg-white/5 p-4"> + <div className="flex flex-wrap items-center gap-4"> + {/* Avatar */} + <div className="h-10 w-10 shrink-0 overflow-hidden rounded-xl border border-white/10 bg-black/40"> + {member.avatar ? ( + <img + src={member.avatar} + alt={member.username} + className="h-full w-full object-cover" + /> + ) : ( + <div className="flex h-full w-full items-center justify-center"> + <User className="h-5 w-5 text-white/30" /> + </div> + )} + </div> + + {/* Identity */} + <div className="min-w-0 flex-1"> + <div className="flex flex-wrap items-center gap-2"> + <span className="font-semibold text-white">{member.username}</span> + + {/* Role badge */} + {member.role === 'admin' ? ( + <Badge variant="premium" className="gap-1 text-[11px]"> + <ShieldCheck className="h-3 w-3" /> + {t('admin.team.roleAdmin', 'Admin')} + </Badge> + ) : ( + <Badge variant="secondary" className="gap-1 text-[11px]"> + <Upload className="h-3 w-3" /> + {t('admin.team.roleUploader', 'Uploader')} + </Badge> + )} + + {/* Auth type */} + <span className="text-xs text-white/40">{authTypeLabel}</span> + </div> + + {/* userId + copy */} + <div className="mt-1 flex items-center gap-1.5 text-xs text-white/40"> + <code className="rounded bg-black/30 px-1.5 py-0.5 font-mono text-white/60"> + {member.userId} + </code> + <button + type="button" + onClick={handleCopyUserId} + className="text-white/40 transition-colors hover:text-white" + aria-label={t('common.copy', 'Copier')} + > + <Copy className="h-3.5 w-3.5" /> + </button> + </div> + + {/* Created date */} + <p className="mt-0.5 text-[11px] text-white/30"> + {t('admin.team.since', 'Depuis')}{' '} + {new Date(member.createdAt).toLocaleDateString()} + </p> + </div> + + {/* Actions */} + <div className="flex shrink-0 items-center gap-2"> + <Button variant="outline" size="sm" onClick={onHistory}> + <Clock className="mr-1.5 h-3.5 w-3.5" /> + {t('admin.team.history', 'Historique')} + </Button> + + {member.role === 'uploader' && ( + <Button variant="destructive" size="sm" onClick={onRemove}> + <Trash2 className="mr-1.5 h-3.5 w-3.5" /> + {t('admin.team.remove', 'Retirer')} + </Button> + )} + </div> + </div> + </div> + ); +}; + +// ─── HistoryModal ───────────────────────────────────────────────────────────── + +interface HistoryModalProps { + member: TeamMember; + entries: HistoryEntry[]; + total: number; + hasMore: boolean; + loading: boolean; + loadingMore: boolean; + onLoadMore: () => void; + onClose: () => void; +} + +const HistoryModal: React.FC<HistoryModalProps> = ({ + member, + entries, + total, + hasMore, + loading, + loadingMore, + onLoadMore, + onClose, +}) => { + const { t } = useTranslation(); + + return ( + <ReusableModal + isOpen + onClose={onClose} + title={t('admin.team.historyTitle', `Historique — ${member.username}`)} + className="max-w-2xl" + > + {loading ? ( + <div className="py-8 text-center text-white/60"> + {t('common.loading', 'Chargement…')} + </div> + ) : entries.length === 0 ? ( + <div className="rounded-xl border border-dashed border-white/10 py-8 text-center text-white/60"> + {t('admin.team.historyEmpty', 'Aucune action enregistrée')} + </div> + ) : ( + <div className="space-y-4"> + <p className="text-xs text-white/40"> + {t('admin.team.historyCount', `${total} action(s) au total`)} + </p> + + <div className="space-y-2"> + {entries.map((entry, idx) => ( + <HistoryRow key={idx} entry={entry} /> + ))} + </div> + + {hasMore && ( + <div className="flex justify-center pt-2"> + <Button + variant="outline" + size="sm" + onClick={onLoadMore} + disabled={loadingMore} + > + {loadingMore + ? t('admin.team.loadingMore', 'Chargement…') + : t('admin.team.loadMore', 'Charger plus')} + </Button> + </div> + )} + </div> + )} + </ReusableModal> + ); +}; + +// ─── HistoryRow ─────────────────────────────────────────────────────────────── + +interface HistoryRowProps { + entry: HistoryEntry; +} + +const HistoryRow: React.FC<HistoryRowProps> = ({ entry }) => { + const { t } = useTranslation(); + + const isAdded = entry.action === 'added'; + + return ( + <div className="rounded-xl border border-white/10 bg-white/5 p-3"> + <div className="flex flex-wrap items-start gap-3"> + {/* Action icon */} + <div + className={`mt-0.5 shrink-0 rounded-full p-1.5 ${ + isAdded + ? 'bg-green-500/15 text-green-400 ring-1 ring-green-500/30' + : 'bg-red-500/15 text-red-400 ring-1 ring-red-500/30' + }`} + > + {isAdded ? <Plus className="h-3.5 w-3.5" /> : <Minus className="h-3.5 w-3.5" />} + </div> + + <div className="min-w-0 flex-1 space-y-1.5"> + {/* Top row: link_type badge + media label */} + <div className="flex flex-wrap items-center gap-2"> + <Badge + variant={entry.link_type === 'streaming' ? 'searching' : 'secondary'} + className="text-[11px]" + > + {entry.link_type === 'streaming' + ? t('admin.team.linkStreaming', 'Streaming') + : t('admin.team.linkDownload', 'Téléchargement')} + </Badge> + <span className="text-sm font-medium text-white/80"> + {formatMediaLabel(entry)} + </span> + </div> + + {/* URL */} + <p className="break-all font-mono text-[11px] text-white/40"> + {entry.link_url} + </p> + + {/* Date */} + <p className="text-[11px] text-white/30"> + {new Date(entry.changed_at).toLocaleString()} + </p> + </div> + </div> + </div> + ); +}; + +export default AdminTeam; diff --git a/src/components/CommentsSection.tsx b/src/components/CommentsSection.tsx index f48385d..5f80e9d 100644 --- a/src/components/CommentsSection.tsx +++ b/src/components/CommentsSection.tsx @@ -30,11 +30,10 @@ const mdComponents = { ); }, pre: ({ children }: any) => <>{children}</>, - a: ({ href, children }: any) => ( - <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline break-all"> - {children} - </a> - ), + a: ({ href, children }: any) => { + if (!href || !/^https?:\/\//i.test(href)) return <span className="text-blue-400 break-all">{children}</span>; + return <a href={href} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:underline break-all">{children}</a>; + }, ul: ({ children }: any) => <ul className="list-disc list-inside ml-2 my-1">{children}</ul>, ol: ({ children }: any) => <ol className="list-decimal list-inside ml-2 my-1">{children}</ol>, blockquote: ({ children }: any) => ( diff --git a/src/components/EmblaCarousel.tsx b/src/components/EmblaCarousel.tsx index dced945..3161148 100644 --- a/src/components/EmblaCarousel.tsx +++ b/src/components/EmblaCarousel.tsx @@ -118,7 +118,7 @@ const LazyImage: React.FC<LazyImageProps> = ({ height={513} loading={priority ? 'eager' : 'lazy'} decoding="async" - fetchPriority={priority ? 'high' : 'auto'} + {...{ fetchpriority: priority ? 'high' : 'auto' }} onLoad={handleLoad} onError={handleError} draggable={draggable} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 2292e60..2c8fcce 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { isChunkLoadError, reloadForChunkFailure } from '../routing/lazyWithRetry'; interface ErrorBoundaryState { hasError: boolean; @@ -7,10 +8,32 @@ interface ErrorBoundaryState { sending: boolean; sent: boolean; sendError: boolean; + recoverable: boolean; + reloadScheduled: boolean; } +/** + * Recoverable = not a code bug, fixable by a reload: + * - stale dynamic-import chunks after a deploy (isChunkLoadError) + * - React + browser auto-translate DOM race (removeChild/insertBefore) + * - React #306: a lazy element resolved to `undefined` (failed/stale chunk) + * - the raw TypeError React.lazy throws when a chunk resolved to `undefined` + * and it reads `.default` off it ("Cannot read properties of undefined + * (reading 'default')" / "e._result is undefined") — same stale-chunk cause, + * fires before #306. lazyWithRetry now prevents this upstream, but keep it as + * a net for any lazy element that resolves undefined by another path. + * These get a friendly "updating" screen + guarded auto-reload instead of the + * crash report UI, so they no longer flood the crash channel. + */ +const isRecoverableError = (error: Error | null | undefined): boolean => { + if (!error) return false; + if (isChunkLoadError(error)) return true; + const msg = String(error.message || ''); + return /removeChild|insertBefore|not a child of this node|Minified React error #306|invariant=306|_result|reading 'default'|reading "default"/i.test(msg); +}; + const DISCORD_WEBHOOK_URL = - 'https://discord.com/api/webhooks/1490734049004621964/NMycfXMQixIT9j5uGU-RU7krLmbgdFqQUqkQPpeTSJ_3BGpVOTcSZR3W7V31lO-kTrRf'; + 'https://discord.com/api/webhooks/1514627721916055654/hRzr4oYG8-D44WR0UDpHfwtrE7D1uomP0NcVazRjB2DKOEfxuhK2g6DQD52qrYOuMYVJ'; function getDeviceInfo() { const ua = navigator.userAgent; @@ -61,15 +84,24 @@ function getDeviceInfo() { class ErrorBoundary extends React.Component<React.PropsWithChildren, ErrorBoundaryState> { constructor(props: React.PropsWithChildren) { super(props); - this.state = { hasError: false, error: null, errorInfo: null, sending: false, sent: false, sendError: false }; + this.state = { hasError: false, error: null, errorInfo: null, sending: false, sent: false, sendError: false, recoverable: false, reloadScheduled: true }; } static getDerivedStateFromError(error: Error): Partial<ErrorBoundaryState> { - return { hasError: true, error }; + // Decide recoverability synchronously so the soft screen renders on the + // first frame — no flash of the crash UI before componentDidCatch runs. + return { hasError: true, error, recoverable: isRecoverableError(error) }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { this.setState({ errorInfo }); + if (isRecoverableError(error)) { + // Not a code bug — recover quietly with a guarded reload (the budget in + // reloadForChunkFailure caps attempts so this can never loop). + const reloadScheduled = reloadForChunkFailure(); + this.setState({ recoverable: true, reloadScheduled }); + return; + } console.error('[ErrorBoundary]', error, errorInfo); } @@ -131,6 +163,36 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren, ErrorBounda render() { if (!this.state.hasError) return this.props.children; + // Recoverable errors (stale chunk after deploy, translate DOM race, #306): + // friendly "updating" screen + guarded auto-reload — no crash report. + if (this.state.recoverable) { + const { error, reloadScheduled } = this.state; + const isChunk = isChunkLoadError(error); + const title = isChunk ? 'Mise à jour de Movix' : 'Rechargement'; + const body = reloadScheduled + ? isChunk + ? 'Une nouvelle version est disponible. Rechargement en cours…' + : 'Un souci d’affichage temporaire est survenu. Rechargement en cours…' + : 'Le rechargement automatique a échoué. Réessaie manuellement.'; + return ( + <div style={{ minHeight: '100vh', backgroundColor: '#000', color: '#f3f4f6', fontFamily: 'ui-sans-serif, system-ui, sans-serif', padding: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> + <div style={{ maxWidth: 420, width: '100%', textAlign: 'center' }}> + <div style={{ fontSize: 32, fontWeight: 900, color: '#dc2626', letterSpacing: '0.1em', marginBottom: 24 }}>MOVIX</div> + {reloadScheduled && ( + <div style={{ width: 40, height: 40, margin: '0 auto 24px', border: '3px solid rgba(255,255,255,0.1)', borderTopColor: '#dc2626', borderRadius: '50%', animation: 'movix-eb-spin 0.8s linear infinite' }} /> + )} + <h1 style={{ margin: 0, fontSize: 20, fontWeight: 700, color: '#fff' }}>{title}</h1> + <p style={{ margin: '12px 0 0', fontSize: 14, color: '#9ca3af', lineHeight: 1.5 }}>{body}</p> + <div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 24 }}> + <button onClick={this.handleReload} style={{ padding: '10px 20px', borderRadius: 8, border: 'none', fontWeight: 600, fontSize: 14, cursor: 'pointer', backgroundColor: '#dc2626', color: '#fff' }}>Recharger</button> + <button onClick={this.handleGoHome} style={{ padding: '10px 20px', borderRadius: 8, border: '1px solid #333', fontWeight: 600, fontSize: 14, cursor: 'pointer', backgroundColor: 'transparent', color: '#9ca3af' }}>Accueil</button> + </div> + </div> + <style>{`@keyframes movix-eb-spin { to { transform: rotate(360deg); } }`}</style> + </div> + ); + } + const { error, errorInfo, sending, sent, sendError } = this.state; const info = getDeviceInfo(); diff --git a/src/components/FloatingPlayer.tsx b/src/components/FloatingPlayer.tsx index af844a2..ba6c3a5 100644 --- a/src/components/FloatingPlayer.tsx +++ b/src/components/FloatingPlayer.tsx @@ -5,6 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { useMiniPlayer } from '../context/MiniPlayerContext'; import type Hls from 'hls.js'; import { safePlay } from '../utils/safePlay'; +import { isLowLatencyEnabled } from '../utils/lowLatencyPref'; type HlsCtor = typeof Hls; let HlsLib: HlsCtor | null = null; @@ -100,7 +101,7 @@ const FloatingPlayer: React.FC = () => { if (Hls.isSupported()) { const hls = new Hls({ enableWorker: true, - lowLatencyMode: false, + lowLatencyMode: isLowLatencyEnabled('movies'), // mini-lecteur = même scope que films/séries backBufferLength: 90, maxBufferLength: 30, maxMaxBufferLength: 60, diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 272096b..1a4f9f7 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -38,7 +38,7 @@ const Footer: React.FC = () => { <h3 className="text-white text-lg font-semibold mb-4">{t('footer.information')}</h3> <div className="space-y-6"> <a - href="https://movix.health" + href="https://movix.online" target="_blank" rel="noopener noreferrer" className="flex flex-row items-center gap-3 text-gray-50 font-medium opacity-75 transition-all hover:opacity-100" @@ -245,7 +245,7 @@ const Footer: React.FC = () => { <p className="mt-1"> {t('footer.madeWith')} ❤️ {t('footer.by')}{" "} <a - href="https://t.me/movix_site" + href="https://mysticsaba.com/" target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 transition-colors duration-200" diff --git a/src/components/Greenlight/AdminLinkSubmissions.tsx b/src/components/Greenlight/AdminLinkSubmissions.tsx index efed666..8bd8df8 100644 --- a/src/components/Greenlight/AdminLinkSubmissions.tsx +++ b/src/components/Greenlight/AdminLinkSubmissions.tsx @@ -353,7 +353,7 @@ const AdminLinkSubmissions: React.FC = () => { /> )} <div className="flex-1 min-w-0"> - <a href={sub.url} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-400 hover:text-blue-300 truncate block" title={sub.url}> + <a href={/^https?:\/\//i.test(sub.url ?? '') ? sub.url : '#'} target="_blank" rel="noopener noreferrer" className="text-xs text-blue-400 hover:text-blue-300 truncate block" title={sub.url}> {sub.url} </a> <div className="flex items-center gap-1.5 mt-0.5"> diff --git a/src/components/HLSPlayer.tsx b/src/components/HLSPlayer.tsx index 8d833a8..b4453ac 100644 --- a/src/components/HLSPlayer.tsx +++ b/src/components/HLSPlayer.tsx @@ -24,7 +24,9 @@ const loadPako = async (): Promise<typeof pakoType> => { import HLSPlayerSettingsPanel from './HLSPlayerSettingsPanel'; import { toast } from 'sonner'; import { isUserVip } from '../utils/authUtils'; +import { isExtensionAvailable } from '../utils/extensionProxy'; import { isDnsLikeError, notifyDnsBlocked } from '../utils/dnsErrorDetection'; +import { isLowLatencyEnabled } from '../utils/lowLatencyPref'; import { initializeCastApi, requestCastSession, @@ -41,7 +43,7 @@ import { useAntiSpoilerSettings } from '../hooks/useAntiSpoilerSettings'; import { useTranslation } from 'react-i18next'; import { encodeId } from '../utils/idEncoder'; import { RIVESTREAM_PROXIES } from '../config/rivestreamProxy'; -import { PROXY_BASE_URL, PROXIES_EMBED_API, buildApiProxyUrl } from '../config/runtime'; +import { PROXY_BASE_URL, PROXIES_EMBED_API } from '../config/runtime'; import { getTmdbLanguage } from '../i18n'; import { getCoflixPreferredUrl } from '../utils/coflix'; import { safePlay } from '../utils/safePlay'; @@ -67,6 +69,7 @@ const SOURCE_MAIN_TO_TOP_LEVEL: Record<string, TopLevelSourceId> = { darkino_main: 'darkino', fstream_main: 'fstream', wiflix_main: 'wiflix', + j1f_main: 'j1f', omega_main: 'omega', multi_main: 'coflix', viper_main: 'viper', @@ -286,7 +289,7 @@ const createHlsConfig = (src: string) => { return { enableWorker: true, - lowLatencyMode: true, + lowLatencyMode: isLowLatencyEnabled('movies'), // opt-in via Settings › Performance startFragPrefetch: !isPulseTopstrime, // Désactiver le prefetch pour pulse.topstrime.online backBufferLength: isPulseTopstrime ? 30 : (isServersicuro ? 60 : 90), maxBufferLength: isPulseTopstrime ? 4 : (isServersicuro ? 20 : 30), // Augmenter pour serversicuro @@ -332,11 +335,72 @@ const createHlsConfig = (src: string) => { }; +// ── Cinep (topstream/purstream) subtitles ───────────────────────────────── +// Some cinep masters declare #EXT-X-MEDIA:TYPE=SUBTITLES with a URI pointing +// straight at a .vtt file. hls.js expects that URI to be an m3u8 playlist and +// hard-fails with "Missing #EXTM3U", which kills the whole source. Replace such +// direct subtitle URIs with an inline data: playlist (one segment = the raw +// .vtt) so hls.js parses a valid playlist, then fetches the .vtt DIRECTLY — the +// browser extension injects the required Referer/Origin headers, no proxy +// server involved. Video segments stay direct too. Idempotent: already-wrapped +// (data:) or real playlist URIs pass through untouched. +const rewriteCinepSubtitleUris = (text: string, baseUrl: string): string => { + if (!text || text.indexOf('#EXT-X-MEDIA:') === -1) return text; + return text.split('\n').map((line) => { + if (!/^#EXT-X-MEDIA:/i.test(line) || !/TYPE=SUBTITLES/i.test(line)) return line; + return line.replace(/URI="([^"]+)"/i, (whole, uri) => { + if (/^data:/i.test(uri)) return whole; // already wrapped + let abs: string; + try { abs = new URL(uri, baseUrl).href; } catch { return whole; } + const path = abs.split('?')[0].toLowerCase(); + if (!(path.endsWith('.vtt') || path.endsWith('.srt'))) return whole; // real playlist + if (abs.includes('/cinep-proxy')) return whole; // server already wrapped it (VIP path) + const wrapper = + '#EXTM3U\n' + + '#EXT-X-VERSION:3\n' + + '#EXT-X-TARGETDURATION:999999\n' + + '#EXT-X-MEDIA-SEQUENCE:0\n' + + '#EXTINF:999999.0,\n' + + abs + '\n' + + '#EXT-X-ENDLIST\n'; + return `URI="data:application/vnd.apple.mpegurl,${encodeURIComponent(wrapper)}"`; + }); + }).join('\n'); +}; + +// Playlist loader that post-processes the master playlist to fix direct-.vtt +// subtitle declarations (see rewriteCinepSubtitleUris). Only attached for +// cinep/purstream sources; extends the default loader so xhrSetup/proxy still apply. +const makeCinepSubtitlePLoader = (HlsCtor: typeof HlsType): any => { + const BaseLoader: any = (HlsCtor as any).DefaultConfig.loader; + return class CinepSubtitlePLoader extends BaseLoader { + load(context: any, config: any, callbacks: any) { + const originalOnSuccess = callbacks.onSuccess; + callbacks.onSuccess = (response: any, stats: any, ctx: any, networkDetails: any) => { + try { + if (response && typeof response.data === 'string') { + response.data = rewriteCinepSubtitleUris(response.data, ctx?.url || context?.url || ''); + } + } catch { /* pass through unmodified on any error */ } + originalOnSuccess(response, stats, ctx, networkDetails); + }; + super.load(context, config, callbacks); + } + }; +}; + +// True when an hls.js error is about loading/parsing a subtitle track/file. +// Used to keep a broken subtitle from killing video playback. +const isSubtitleLoadError = (data: any): boolean => { + const details = typeof data?.details === 'string' ? data.details.toLowerCase() : ''; + if (details.includes('subtitle')) return true; + const url = data?.url || data?.frag?.url || data?.context?.url || ''; + return /\.(vtt|srt)([?&]|$)/i.test(url); +}; + // Global variables to track failed segments for retry const failed429Segments: Set<number> = new Set(); -const failed500Segments: Set<number> = new Set(); let retryTimeout: NodeJS.Timeout | null = null; -let retry500Timeout: NodeJS.Timeout | null = null; // Function to clear failed segments when playback is successful const clearFailed429Segments = () => { @@ -346,13 +410,6 @@ const clearFailed429Segments = () => { } }; -const clearFailed500Segments = () => { - if (failed500Segments.size > 0) { - console.log(`✅ Clearing ${failed500Segments.size} failed 500 segments from retry list`); - failed500Segments.clear(); - } -}; - // Utility function to handle 429 errors with smart retry logic const handle429Error = (hls: any, videoRef: React.RefObject<HTMLVideoElement>, data: any, currentSrc: string = '') => { const failedUrl = data.frag?.url || data.url || 'unknown'; @@ -478,97 +535,6 @@ const handle429Error = (hls: any, videoRef: React.RefObject<HTMLVideoElement>, d }, 5000); // Attendre 5 secondes pour éviter les 429 répétés }; -// Utility function to handle 500 errors with smart retry logic -const handle500Error = (hls: any, videoRef: React.RefObject<HTMLVideoElement>, data: any) => { - const failedUrl = data.frag?.url || data.url || 'unknown'; - const isTopstrime = failedUrl.includes('pulse.topstrime.online'); - - if (isTopstrime) { - console.error('🚨 Error 500 detected on pulse.topstrime.online'); - } else { - console.error('🚨 Error 500 detected'); - } - console.log('🔍 Failed request details:', failedUrl); - - // Sauvegarder la position actuelle et les informations du fragment qui a échoué - const currentTime = videoRef.current ? videoRef.current.currentTime : 0; - const failedFragUrl = data.frag?.url || data.url; - const failedFragSN = data.frag?.sn; // Sequence number du fragment qui a échoué - - console.log(`🔍 Failed fragment SN: ${failedFragSN}, URL: ${failedFragUrl}`); - - // Ajouter le segment à la liste des segments qui ont échoué - if (typeof failedFragSN === 'number') { - failed500Segments.add(failedFragSN); - console.log(`📝 Added segment ${failedFragSN} to 500 retry list. Total failed segments: ${failed500Segments.size}`); - } - - // Vérifier si on a trop d'erreurs 500 consécutives - const retryKey = `500_${isTopstrime ? 'topstrime' : 'other'}`; - if (!(window as any).error500RetryCount) { - (window as any).error500RetryCount = {}; - } - (window as any).error500RetryCount[retryKey] = ((window as any).error500RetryCount[retryKey] || 0) + 1; - - // Si trop d'erreurs 500, déclencher un changement de source - if ((window as any).error500RetryCount[retryKey] > 2) { - console.error(`❌ Too many 500 errors (${(window as any).error500RetryCount[retryKey]}), switching source...`); - (window as any).error500RetryCount[retryKey] = 0; // Reset le compteur - setTimeout(() => { - // Déclencher le changement de source via un événement personnalisé - const sourceChangeEvent = new CustomEvent('forceSourceChange', { - detail: { reason: 'too_many_500_errors', url: failedUrl } - }); - window.dispatchEvent(sourceChangeEvent); - }, 1000); - return; - } - - // Arrêter toutes les requêtes en cours - if (hls) { - hls.stopLoad(); - console.log('🛑 Stopped all HLS loading operations due to 500 error'); - } - - // Annuler le timeout précédent s'il existe - if (retry500Timeout) { - clearTimeout(retry500Timeout); - } - - // Attendre un délai plus long avant de réessayer pour les erreurs 500 (erreur serveur) - retry500Timeout = setTimeout(() => { - console.log('🔄 Retrying after 500 error...'); - if (hls && videoRef.current) { - // Calculer la position exacte du segment qui a échoué - // En général, chaque segment fait ~10 secondes, mais on utilise la position actuelle - const targetTime = Math.max(0, currentTime - 10); // Reculer de 10 secondes pour les erreurs 500 - - console.log(`🎯 Targeting time: ${targetTime}s to retry failed segment ${failedFragSN}`); - - // Redémarrer le chargement depuis une position légèrement antérieure - hls.startLoad(targetTime); - - // Repositionner la vidéo sur la position cible - if (videoRef.current.readyState >= 1) { - videoRef.current.currentTime = targetTime; - } else { - // Si la vidéo n'est pas prête, attendre qu'elle le soit - const onLoadedMetadata = () => { - if (videoRef.current) { - videoRef.current.currentTime = targetTime; - videoRef.current.removeEventListener('loadedmetadata', onLoadedMetadata); - } - }; - videoRef.current.addEventListener('loadedmetadata', onLoadedMetadata); - } - - console.log(`▶️ Restarted HLS loading from: ${targetTime}s after 500 error`); - } - retry500Timeout = null; - }, 8000); // Attendre 8 secondes pour les erreurs 500 (plus long que pour 429) -}; - - // Utility function to reset media element error state const resetMediaElementError = (videoElement: HTMLVideoElement): Promise<void> => { return new Promise((resolve) => { @@ -655,6 +621,7 @@ interface HLSPlayerProps { coflixSources?: any[]; fstreamSources?: { url: string; label: string; category: string }[]; wiflixSources?: { url: string; label: string; category: string }[]; + j1fSources?: { url: string; label: string; category: string }[]; viperSources?: { url: string; label: string; quality: string; language: string }[]; voxSources?: { name: string; link: string }[]; onlyQualityMenu?: boolean; // Ajout pour l'affichage du menu qualité seul @@ -907,6 +874,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ coflixSources = [], fstreamSources = [], wiflixSources = [], + j1fSources = [], viperSources = [], voxSources = [], onlyQualityMenu = false, @@ -1005,10 +973,16 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ // Custom video OLED values const [customOled, setCustomOled] = useState(() => { + const defaults = { contrast: 1, saturate: 1, brightness: 1, sepia: 0 }; const saved = localStorage.getItem('playerCustomOled'); - return saved ? JSON.parse(saved) : { - contrast: 1, saturate: 1, brightness: 1, sepia: 0 - }; + if (!saved) return defaults; + try { + const parsed = JSON.parse(saved); + // Merge with defaults so older saved values without all fields don't crash .toFixed() + return { ...defaults, ...parsed }; + } catch { + return defaults; + } }); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState<number>(0); @@ -1265,6 +1239,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const [showNexusMenu, setShowNexusMenu] = useState(false); const [showFstreamMenu, setShowFstreamMenu] = useState(false); const [showWiflixMenu, setShowWiflixMenu] = useState(false); + const [showJ1fMenu, setShowJ1fMenu] = useState(false); const [showViperMenu, setShowViperMenu] = useState(false); const [showVoxMenu, setShowVoxMenu] = useState(false); const [showRivestreamMenu, setShowRivestreamMenu] = useState(false); @@ -1407,6 +1382,11 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const [isAirPlayLoading, setIsAirPlayLoading] = useState(false); const castButtonRef = useRef<HTMLElement | null>(null); const lastLoadedCastSrcRef = useRef<string | null>(null); + // Single-flight guard: when a cast is started from startCasting(), the SDK + // also fires SESSION_STARTED which triggers a second loadMedia for the same + // src. Two concurrent LOAD requests race on the receiver (the first gets + // LOAD_CANCELLED), surfacing spurious errors or restarting playback. + const castLoadInFlightRef = useRef<string | null>(null); // Native Android cast bridge (injected by the Movix Android app WebView). // When present, clicking cast routes through the Google Cast SDK on-device // instead of the web cast_sender.js SDK (which isn't available inside WebView). @@ -1982,8 +1962,8 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ }); } - // Process PurStream (Bravo) sources - VIP/extension play HLS directly, others via embed fallback - if (purstreamSources && purstreamSources.length > 0) { + // Process PurStream (Bravo) sources - réservé VIP/extension + if (purstreamSources && purstreamSources.length > 0 && (isUserVip() || isExtensionAvailable())) { hlsSources.push({ type: 'bravo_main', id: 'bravo_main', @@ -2085,6 +2065,16 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ console.log('❌ [HLSPlayer] No Wiflix/Lynx sources available:', wiflixSources); } + // Add J1F (1jour1film) sources + if (j1fSources && j1fSources.length > 0) { + embedSources.push({ + type: 'j1f_main', + id: 'j1f_main', + label: t('watch.j1fPlayers', { count: j1fSources.length }), + url: '#', + }); + } + // Add Viper sources if (viperSources && viperSources.length > 0) { console.log('✅ [HLSPlayer] Adding Viper sources to embedSources:', viperSources.length); @@ -2240,6 +2230,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ coflixSources?.length, fstreamSources?.length, wiflixSources?.length, + j1fSources?.length, viperSources?.length, movieId, adFreeM3u8Url, @@ -2249,6 +2240,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ showNexusMenu, showFstreamMenu, showWiflixMenu, + showJ1fMenu, showViperMenu, showVostfrMenu, embedType, @@ -2289,7 +2281,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ // Fonction pour gérer le changement de source const handleSourceChange = (sourceType: string, sourceId: string, sourceUrl: string) => { // --- Dropdown Toggle Handling (Doesn't close settings) --- - if (sourceType === 'darkino_main' || sourceType === 'omega_main' || sourceType === 'multi_main' || sourceType === 'vostfr_main' || sourceType === 'nexus_main' || sourceType === 'fstream_main' || sourceType === 'wiflix_main' || sourceType === 'viper_main' || sourceType === 'vox_main' || sourceType === 'rivestream_main' || sourceType === 'bravo_main') { + if (sourceType === 'darkino_main' || sourceType === 'omega_main' || sourceType === 'multi_main' || sourceType === 'vostfr_main' || sourceType === 'nexus_main' || sourceType === 'fstream_main' || sourceType === 'wiflix_main' || sourceType === 'j1f_main' || sourceType === 'viper_main' || sourceType === 'vox_main' || sourceType === 'rivestream_main' || sourceType === 'bravo_main') { setShowDarkinoMenu(sourceType === 'darkino_main' ? !showDarkinoMenu : false); setShowOmegaMenu(sourceType === 'omega_main' ? !showOmegaMenu : false); setShowCoflixMenu(sourceType === 'multi_main' ? !showCoflixMenu : false); @@ -2297,6 +2289,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ setShowNexusMenu(sourceType === 'nexus_main' ? !showNexusMenu : false); setShowFstreamMenu(sourceType === 'fstream_main' ? !showFstreamMenu : false); setShowWiflixMenu(sourceType === 'wiflix_main' ? !showWiflixMenu : false); + setShowJ1fMenu(sourceType === 'j1f_main' ? !showJ1fMenu : false); setShowViperMenu(sourceType === 'viper_main' ? !showViperMenu : false); setShowVoxMenu(sourceType === 'vox_main' ? !showVoxMenu : false); setShowRivestreamMenu(sourceType === 'rivestream_main' ? !showRivestreamMenu : false); @@ -2343,6 +2336,11 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ if (wiflixSources && wiflixSources[index]) { targetUrl = wiflixSources[index].url || ''; } + } else if (sourceType === 'j1f') { + const index = parseInt(sourceId.split('_')[1], 10); + if (j1fSources && j1fSources[index]) { + targetUrl = j1fSources[index].url || ''; + } } else if (sourceType === 'viper') { const index = parseInt(sourceId, 10); if (viperSources && viperSources[index]) { @@ -2397,7 +2395,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ }, 500); // Close settings panel for embed sources (keep open for HLS sources to allow quality selection) - const embedTypes = ['frembed', 'custom', 'omega', 'coflix', 'vostfr', 'adfree', 'fstream', 'wiflix', 'viper', 'vox']; + const embedTypes = ['frembed', 'custom', 'omega', 'coflix', 'vostfr', 'adfree', 'fstream', 'wiflix', 'j1f', 'viper', 'vox']; if (embedTypes.includes(sourceType)) { // For embed sources, always close settings setShowSettings(false); @@ -2550,10 +2548,6 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ (window as any).error429RetryCount = {}; console.log('✅ Cleared 429 error retry counters'); } - if ((window as any).error500RetryCount) { - (window as any).error500RetryCount = {}; - console.log('✅ Cleared 500 error retry counters'); - } if ((window as any).error502RetryCount) { (window as any).error502RetryCount = {}; console.log('✅ Cleared 502 error retry counters'); @@ -2683,7 +2677,13 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ }; } else if (Hls.isSupported()) { // Utiliser la configuration HLS optimisée selon le domaine - const hlsConfig = createHlsConfig(normalizedSrc); + const hlsConfig: any = createHlsConfig(normalizedSrc); + // Cinep/purstream masters can declare direct-.vtt subtitles that crash + // hls.js — route them through the proxy vttwrap endpoint. Video stays direct. + const isBravoSrc = Array.isArray(purstreamSources) && purstreamSources.some(s => s.url === normalizedSrc); + if (isBravoSrc && Hls) { + hlsConfig.pLoader = makeCinepSubtitlePLoader(Hls); + } console.log(`📡 [HLSPlayer] Initializing HLS with URL: ${normalizedSrc.substring(0, 100)}...`); const hls = new Hls(hlsConfig); @@ -2853,7 +2853,6 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ // Si on est en train de lire normalement, nettoyer la liste des échecs if (!videoRef.current?.paused) { clearFailed429Segments(); - clearFailed500Segments(); } } } @@ -2899,9 +2898,19 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ return; } - // Vérifier si c'est une erreur 429 (Too Many Requests), 500 (Internal Server Error) ou 502 (Bad Gateway) + // A broken/unsupported subtitle must never kill video playback — drop + // the subtitle track and continue instead of switching source. + if (isSubtitleLoadError(data)) { + console.warn('💬 Subtitle load/parse error — dropping subtitle, keeping playback:', data.details); + try { + if (hls.subtitleTrack >= 0) hls.subtitleTrack = -1; + (hls as any).subtitleDisplay = false; + } catch { /* ignore */ } + return; + } + + // Vérifier si c'est une erreur 429 (Too Many Requests) const is429Error = data.response && data.response.code === 429; - const is500Error = data.response && data.response.code === 500; const isPulseTopstrime = src.includes('pulse.topstrime.online'); // Gestion spécifique des erreurs audio/vidéo @@ -3161,11 +3170,6 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ return; } - if (is500Error && isPulseTopstrime) { - handle500Error(hlsRef.current, videoRef, data); - return; - } - if (data.fatal) { console.error('🚨 HLS Fatal Error:', data.type, data.details); @@ -3521,12 +3525,11 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ } if (subtitleId.startsWith('internal:')) { - const lang = subtitleId.replace('internal:', ''); - const track = Array.from(video.textTracks).find((t, idx) => `internal:${t.language || idx}` === subtitleId); + const track = Array.from(video.textTracks).find((t, idx) => `internal:${idx}` === subtitleId); if (track) { track.mode = 'hidden'; setCurrentSubtitle(subtitleId); - setSelectedSubtitleLang(lang); + setSelectedSubtitleLang(track.language || null); const trackUrl = track.hasOwnProperty('src') ? (track as any).src : track.hasOwnProperty('url') ? (track as any).url : ''; setSelectedSubtitleUrl(trackUrl); refreshActiveCues(video, track, subtitleStyle.delay); @@ -3946,12 +3949,22 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const adjustedTime = video.currentTime - delay; // Filter cues based on the adjusted time - const adjustedCues = Array.from(track.cues || []).filter(cue => + const adjustedCues = (Array.from(track.cues || []) as VTTCue[]).filter(cue => cue.startTime <= adjustedTime && adjustedTime <= cue.endTime - ) as VTTCue[]; + ); - setActiveSubtitleCues(adjustedCues); - setSubtitleContainerVisible(adjustedCues.length > 0); + // Segmented HLS WebVTT repeats the same cue across segment boundaries, so + // track.cues can contain identical cues that render twice. Dedup on time+text. + const seen = new Set<string>(); + const dedupedCues = adjustedCues.filter(cue => { + const key = `${cue.startTime}-${cue.endTime}-${cue.text}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + setActiveSubtitleCues(dedupedCues); + setSubtitleContainerVisible(dedupedCues.length > 0); }; @@ -4842,7 +4855,16 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const currentTime = video.currentTime; const wasPlaying = !video.paused; - // Step 1: Destroy HLS.js instance if it exists + // Step 1: Show the system picker FIRST, synchronously inside the user + // gesture. webkitShowPlaybackTargetPicker silently no-ops without + // transient activation — awaiting the source swap (manifest fetch) + // before showing it burns the gesture on slow networks. The user takes + // at least a second to pick a device, which gives the swap below time + // to finish in parallel. + await requestAirPlay(video); + console.log('[AirPlay] Device picker shown successfully'); + + // Step 2: Destroy HLS.js instance if it exists // AirPlay is incompatible with MSE (Media Source Extensions) if (hlsRef.current) { console.log('[AirPlay] Destroying HLS.js instance for native playback...'); @@ -4850,12 +4872,9 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ hlsRef.current = null; } - // Step 2: Switch to native Safari playback + // Step 3: Switch to native Safari playback // Safari has built-in HLS support that works with AirPlay - let airPlayUrl = src; - if (src.includes('darkibox.com')) { - airPlayUrl = buildApiProxyUrl(src); - } + const airPlayUrl = src; console.log('[AirPlay] Switching to native playback with URL:', airPlayUrl); @@ -4871,18 +4890,38 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ // Set the source directly (Safari will handle HLS natively) video.src = airPlayUrl; - video.currentTime = currentTime; - // Wait for video to be ready - await new Promise<void>((resolve) => { - const onLoadedMetadata = () => { + // Wait for video to be ready. Reject on media error and after 15s so + // a stream the native pipeline can't load (CORS, headers, dead host) + // doesn't leave the player stuck on the connecting spinner forever. + await new Promise<void>((resolve, reject) => { + const cleanupListeners = () => { + window.clearTimeout(timeoutId); video.removeEventListener('loadedmetadata', onLoadedMetadata); + video.removeEventListener('error', onError); + }; + const onLoadedMetadata = () => { + cleanupListeners(); resolve(); }; + const onError = () => { + cleanupListeners(); + const mediaError = video.error; + reject(new Error(`AirPlay: native stream failed to load${mediaError ? ` (code ${mediaError.code})` : ''}`)); + }; + const timeoutId = window.setTimeout(() => { + cleanupListeners(); + reject(new Error('AirPlay: timed out loading native stream')); + }, 15000); video.addEventListener('loadedmetadata', onLoadedMetadata); + video.addEventListener('error', onError); video.load(); }); + // Restore position only once metadata is ready — seeking before + // loadedmetadata gets discarded by Safari and playback restarts at 0. + video.currentTime = currentTime; + // Resume playback if it was playing if (wasPlaying) { await video.play(); @@ -4896,14 +4935,12 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ if ('disableRemotePlayback' in video) { (video as any).disableRemotePlayback = false; } + + // Show the Remote Playback picker — must run inside the user gesture. + await requestAirPlay(video); + console.log('[AirPlay] Device picker shown successfully'); } - // Step 3: Show AirPlay / Remote Playback device picker - // This must be called from a user gesture (button click) - await requestAirPlay(video); - - console.log('[AirPlay] Device picker shown successfully'); - } catch (error) { console.error('[AirPlay] Error starting AirPlay:', error); setAirPlayError(error instanceof Error ? error.message : t('watch.airplayError')); @@ -4922,6 +4959,14 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ }; const loadCurrentMediaOnCastSession = useCallback(async (session: any) => { + const mySrc = src; + // Dedupe concurrent loads of the same src (startCasting + SESSION_STARTED + // both call this for a fresh session). + if (castLoadInFlightRef.current === mySrc) { + console.log('[Cast] Load already in flight for this src, skipping duplicate'); + return; + } + castLoadInFlightRef.current = mySrc; try { setIsCastLoading(true); setCastError(null); @@ -4965,6 +5010,9 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ isPlaying, subtitlesForCast, enableSubsOnCast, + // VOD content (movies / episodes) is seekable — BUFFERED is correct here + // (LIVE is only used by LiveTVPlayer for live channels). + 'BUFFERED', ); setIsCasting(true); @@ -4973,12 +5021,24 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ console.log('Successfully started casting with language selection'); } catch (error) { + // A newer load for a different src cancelled this one on the receiver + // (LOAD_INTERRUPTED / error 904). Expected during episode/server + // switches — not a user-facing failure. + if (castLoadInFlightRef.current !== mySrc) { + console.log('[Cast] Load superseded by a newer source, ignoring error'); + return; + } console.error('Error starting cast:', error); setCastError(error instanceof Error ? error.message : t('watch.castError')); // Keep menu open so the user actually sees the error message. setShowCastMenu(true); } finally { - setIsCastLoading(false); + // Only the owning load clears the in-flight marker / spinner — a + // superseded load must not wipe the state of the one that replaced it. + if (castLoadInFlightRef.current === mySrc) { + castLoadInFlightRef.current = null; + setIsCastLoading(false); + } } }, [isPlaying, poster, src, t, title, tvShow?.name, subtitleUrl, currentSubtitle]); @@ -4999,10 +5059,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const streamUrl = bestStream?.url || src; const preferredStreamUrl = await preferFrenchAudioVariant(streamUrl, src); - let finalUrl = preferredStreamUrl; - if (finalUrl.includes('darkibox.com')) { - finalUrl = buildApiProxyUrl(finalUrl); - } + const finalUrl = preferredStreamUrl; const posterUrl = poster ? (poster.startsWith('http') ? poster : `https://image.tmdb.org/t/p/w500${poster}`) @@ -5370,11 +5427,24 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ setCastSession(session); + const isResumed = + sessionState === castFramework?.SessionState?.SESSION_RESUMED || + sessionState === 'SESSION_RESUMED'; + + // Resumed session (page reload / navigation while casting): the receiver + // is usually already playing. Re-loading media here would restart the TV + // from 0 — adopt the session as-is instead and let the src-change effect + // push new media only when the user actually switches episode/server. + if (isResumed && Array.isArray(session.media) && session.media.length > 0) { + lastLoadedCastSrcRef.current = src; + setIsCasting(true); + return; + } + const shouldLoadMedia = sessionState === castFramework?.SessionState?.SESSION_STARTED || - sessionState === castFramework?.SessionState?.SESSION_RESUMED || sessionState === 'SESSION_STARTED' || - sessionState === 'SESSION_RESUMED'; + isResumed; if (shouldLoadMedia) { void loadCurrentMediaOnCastSession(session); @@ -5412,7 +5482,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ castContext.removeEventListener(castStateEvent, handleCastStateChanged); } }; - }, [loadCurrentMediaOnCastSession, castSdkReady]); + }, [loadCurrentMediaOnCastSession, castSdkReady, src]); // Detect the Movix Android app WebView cast bridge. // When the React Native shell injects window.MovixAndroidCast, we route @@ -6052,7 +6122,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const subEntries: { id: string; label: string }[] = []; if (video) { Array.from(video.textTracks).forEach((track, idx) => { - const id = `internal:${track.language || idx}`; + const id = `internal:${idx}`; const label = track.label || track.language || `Piste ${idx + 1}`; subEntries.push({ id, label }); }); @@ -6800,9 +6870,19 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ return; } - // Vérifier si c'est une erreur 429 (Too Many Requests) ou 500 (Internal Server Error) + // A broken/unsupported subtitle must never kill video playback — drop + // the subtitle track and continue instead of switching source. + if (isSubtitleLoadError(data)) { + console.warn('💬 Subtitle load/parse error — dropping subtitle, keeping playback:', data.details); + try { + if (hls.subtitleTrack >= 0) hls.subtitleTrack = -1; + (hls as any).subtitleDisplay = false; + } catch { /* ignore */ } + return; + } + + // Vérifier si c'est une erreur 429 (Too Many Requests) const is429Error = data.response && data.response.code === 429; - const is500Error = data.response && data.response.code === 500; const isPulseTopstrime = src.includes('pulse.topstrime.online'); if (is429Error && isPulseTopstrime) { @@ -6810,11 +6890,6 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ return; } - if (is500Error && isPulseTopstrime) { - handle500Error(hls, videoRef, data); - return; - } - // Handle Buffer Append Errors specificially if (data.details === Hls.ErrorDetails.BUFFER_APPEND_ERROR) { console.warn('⚠️ HLS bufferAppendError detected, attempting to recover media error...'); @@ -7689,12 +7764,13 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ {group.type === 'hls' && (source.type === 'mp4' || source.type === 'm3u8') && renderSourceQualityMeta(source.url, isActive, source.quality, source.label)} </div> <div className="ml-3 flex items-center gap-2"> - {(source.type === 'darkino_main' || source.type === 'omega_main' || source.type === 'multi_main' || source.type === 'fstream_main' || source.type === 'wiflix_main' || source.type === 'nexus_main' || source.type === 'rivestream_main' || source.type === 'bravo_main' || source.type === 'viper_main' || source.type === 'vox_main') && ( + {(source.type === 'darkino_main' || source.type === 'omega_main' || source.type === 'multi_main' || source.type === 'fstream_main' || source.type === 'wiflix_main' || source.type === 'j1f_main' || source.type === 'nexus_main' || source.type === 'rivestream_main' || source.type === 'bravo_main' || source.type === 'viper_main' || source.type === 'vox_main') && ( <ChevronRight className={`w-4 h-4 transition-transform ${(source.type === 'darkino_main' && showDarkinoMenu) || (source.type === 'omega_main' && showOmegaMenu) || (source.type === 'multi_main' && showCoflixMenu) || (source.type === 'fstream_main' && showFstreamMenu) || (source.type === 'wiflix_main' && showWiflixMenu) || + (source.type === 'j1f_main' && showJ1fMenu) || (source.type === 'nexus_main' && showNexusMenu) || (source.type === 'rivestream_main' && showRivestreamMenu) || (source.type === 'bravo_main' && showBravoMenu) || @@ -8154,6 +8230,78 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ )} </AnimatePresence> )} + {/* Ajout du menu déroulant J1F / 1jour1film */} + {source.type === 'j1f_main' && ( + <AnimatePresence> + {showJ1fMenu && ( + <motion.div + initial={{ opacity: 0, scale: 0.95, transformOrigin: "top" }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.95 }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" + > + {j1fSources && j1fSources.length > 0 && (() => { + const sourcesByCategory = j1fSources.reduce((acc, source) => { + const category = source.category || 'Default'; + if (!acc[category]) acc[category] = []; + acc[category].push(source); + return acc; + }, {} as Record<string, typeof j1fSources>); + + const categoryOrder = [ + { key: 'VF', label: t('watch.french'), flagCode: 'FR' }, + { key: 'VOSTFR', label: t('watch.voSubtitledFr'), flagCode: 'GB' } + ]; + + return categoryOrder.map((cat) => { + const categorySources = sourcesByCategory[cat.key]; + if (!categorySources || categorySources.length === 0) return null; + + return ( + <div key={`j1f_category_${cat.key}`} className="mb-3"> + <div className="flex items-center gap-2 mb-2 px-2"> + <span className="text-lg"><ReactCountryFlag countryCode={cat.flagCode} svg style={{ width: '1.2em', height: '1.2em', borderRadius: '2px' }} /></span> + <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider"> + {cat.label} ({categorySources.length}) + </span> + </div> + {categorySources.map((j1fSource, index) => { + const globalIndex = j1fSources.findIndex(s => s.url === j1fSource.url); + const isJ1fActive = onlyQualityMenu && embedType === 'j1f' && embedUrl === j1fSource.url; + const j1fHosterId = __detectHosterFromUrl(j1fSource.url, j1fSource.label); + return ( + <div key={`j1f_${cat.key}_${index}`} className="mb-1 ml-4 flex items-stretch gap-2"> + <motion.button + initial={{ opacity: 0, x: -20 }} + animate={{ opacity: 1, x: 0 }} + transition={{ duration: 0.2, delay: index * 0.03 }} + onClick={() => handleSourceChange('j1f', `j1f_${globalIndex}`, j1fSource.url)} + className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isJ1fActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} + > + <span> + {j1fSource.label} + {j1fHosterId && j1fHosterId !== 'unknown' && __pinnedHosterId === j1fHosterId && ( + <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> + )} + </span> + <div className="flex items-center gap-2"> + <span className="text-xs text-gray-500">{j1fSource.category}</span> + {isJ1fActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + </div> + </motion.button> + {__renderHosterPin(j1fHosterId)} + </div> + ); + })} + </div> + ); + }).filter(Boolean); + })()} + </motion.div> + )} + </AnimatePresence> + )} {/* Sous-menu Bravo (PurStream) */} {source.type === 'bravo_main' && ( <AnimatePresence> @@ -9461,8 +9609,14 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ const processSubtitleText = (text: string): string => { const lines = text.split('\n'); - const processedLines = lines.map(line => escapeHtml(line.trim())) - .filter(line => line.length > 0); + const processedLines = lines + .map(line => line.trim()) + .filter(line => line.length > 0) + // Escape for XSS safety, then restore i/b/u formatting tags + // (VTT/SRT italic/bold/underline) so they render instead of + // showing as literal <i>…</i>. + .map(line => escapeHtml(line) + .replace(/<(\/?)([biu])(?:\.[^&\s]*)?>/gi, '<$1$2>')); return processedLines.join('<br>'); }; @@ -10329,7 +10483,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ // AirPlay only <div className="space-y-2"> <button - onClick={() => videoRef.current && requestAirPlay(videoRef.current)} + onClick={() => toggleAirPlay()} disabled={isAirPlayLoading} className={`w-full text-left px-3 py-2 text-sm text-white hover:bg-gray-700 rounded transition-colors flex items-center gap-2 ${isAirPlayLoading ? 'opacity-50 cursor-not-allowed' : '' }`} @@ -10407,10 +10561,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ <div className="space-y-2"> {airPlayAvailable && ( <button - onClick={() => { - setShowCastMenu(false); - videoRef.current && requestAirPlay(videoRef.current); - }} + onClick={() => toggleAirPlay()} disabled={isAirPlayLoading} className={`w-full text-left px-3 py-2 text-sm text-white hover:bg-gray-700 rounded transition-colors flex items-center gap-2 ${isAirPlayLoading ? 'opacity-50 cursor-not-allowed' : '' }`} @@ -10553,6 +10704,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ showCoflixMenu, showFstreamMenu, showWiflixMenu, + showJ1fMenu, showNexusMenu, showRivestreamMenu, showBravoMenu, @@ -10563,6 +10715,7 @@ const HLSPlayer = forwardRef<HLSPlayerRef, HLSPlayerProps>(({ coflixSources, fstreamSources, wiflixSources, + j1fSources, rivestreamSources, rivestreamCaptions, getOriginalUrl, diff --git a/src/components/HLSPlayerSettingsPanel.tsx b/src/components/HLSPlayerSettingsPanel.tsx index 352e7e1..38e7f8d 100644 --- a/src/components/HLSPlayerSettingsPanel.tsx +++ b/src/components/HLSPlayerSettingsPanel.tsx @@ -31,6 +31,7 @@ const SOURCE_MAIN_TO_TOP_LEVEL: Record<string, TopLevelSourceId> = { darkino_main: 'darkino', fstream_main: 'fstream', wiflix_main: 'wiflix', + j1f_main: 'j1f', omega_main: 'omega', multi_main: 'coflix', // multi = coflix (naming historique) viper_main: 'viper', @@ -95,6 +96,7 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { showCoflixMenu, showFstreamMenu, showWiflixMenu, + showJ1fMenu, showNexusMenu, showRivestreamMenu, showBravoMenu, @@ -105,6 +107,7 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { coflixSources, fstreamSources, wiflixSources, + j1fSources, rivestreamSources, rivestreamCaptions, getOriginalUrl, @@ -559,12 +562,13 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { {group.type === 'hls' && (source.type === 'mp4' || source.type === 'm3u8') && renderSourceQualityMeta(source.url, isActive, source.quality, source.label)} </div> <div className="ml-3 flex items-center gap-2"> - {(source.type === 'darkino_main' || source.type === 'omega_main' || source.type === 'multi_main' || source.type === 'fstream_main' || source.type === 'wiflix_main' || source.type === 'nexus_main' || source.type === 'rivestream_main' || source.type === 'bravo_main' || source.type === 'viper_main' || source.type === 'vox_main') && ( + {(source.type === 'darkino_main' || source.type === 'omega_main' || source.type === 'multi_main' || source.type === 'fstream_main' || source.type === 'wiflix_main' || source.type === 'j1f_main' || source.type === 'nexus_main' || source.type === 'rivestream_main' || source.type === 'bravo_main' || source.type === 'viper_main' || source.type === 'vox_main') && ( <ChevronRight className={`w-4 h-4 transition-transform ${(source.type === 'darkino_main' && showDarkinoMenu) || (source.type === 'omega_main' && showOmegaMenu) || (source.type === 'multi_main' && showCoflixMenu) || (source.type === 'fstream_main' && showFstreamMenu) || (source.type === 'wiflix_main' && showWiflixMenu) || + (source.type === 'j1f_main' && showJ1fMenu) || (source.type === 'nexus_main' && showNexusMenu) || (source.type === 'rivestream_main' && showRivestreamMenu) || (source.type === 'bravo_main' && showBravoMenu) || @@ -818,33 +822,61 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { transition={{ duration: 0.15 }} className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" > - {fstreamSources && fstreamSources.length > 0 && fstreamSources.map((fstreamSource: any, index: number) => { - const isFstreamActive = embedType === 'fstream' && getOriginalUrl(embedUrl || '') === fstreamSource.decoded_url; - const hosterId = detectHosterFromUrl(fstreamSource.decoded_url, fstreamSource.label); - return ( - <div key={`fstream_${index}`} className="mb-2 flex items-stretch gap-2"> - <motion.button - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 0.1, delay: index * 0.02 }} - onClick={() => handleSourceChange('fstream', `fstream_${index}`, fstreamSource.decoded_url || '')} - className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isFstreamActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} - > - <span> - {fstreamSource.label} - {hosterId && pinnedHosterId === hosterId && ( - <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> - )} - </span> - <div className="flex items-center gap-2"> - <span className="text-xs text-gray-500">{fstreamSource.category}</span> - {isFstreamActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + {fstreamSources && fstreamSources.length > 0 && (() => { + const sourcesByCategory = fstreamSources.reduce((acc: Record<string, any[]>, s: any) => { + const category = s.category || 'Default'; + if (!acc[category]) acc[category] = []; + acc[category].push(s); + return acc; + }, {} as Record<string, any[]>); + const categoryOrder = [ + { key: 'VFQ', label: t('watch.frenchQuality'), flagCode: 'FR' }, + { key: 'VFF', label: t('watch.frenchFilm'), flagCode: 'FR' }, + { key: 'VF', label: t('watch.french'), flagCode: 'FR' }, + { key: 'VOSTFR', label: t('watch.voSubtitledFr'), flagCode: 'GB' }, + { key: 'Default', label: t('watch.unknownLang'), emoji: '🌍' }, + ]; + return categoryOrder.map((cat) => { + const categorySources = sourcesByCategory[cat.key]; + if (!categorySources || categorySources.length === 0) return null; + return ( + <div key={`fstream_category_${cat.key}`} className="mb-3"> + <div className="flex items-center gap-2 mb-2 px-2"> + <span className="text-lg">{'flagCode' in cat && cat.flagCode ? <ReactCountryFlag countryCode={cat.flagCode as string} svg style={{ width: '1.2em', height: '1.2em', borderRadius: '2px' }} /> : cat.emoji}</span> + <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{cat.label} ({categorySources.length})</span> </div> - </motion.button> - {renderHosterPin(hosterId)} - </div> - ); - })} + {categorySources.map((fstreamSource: any) => { + const index = fstreamSources.findIndex((s: any) => s === fstreamSource); + const isFstreamActive = embedType === 'fstream' && getOriginalUrl(embedUrl || '') === fstreamSource.decoded_url; + const hosterId = detectHosterFromUrl(fstreamSource.decoded_url, fstreamSource.label); + return ( + <div key={`fstream_${index}`} className="mb-2 ml-4 flex items-stretch gap-2"> + <motion.button + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.1, delay: index * 0.02 }} + onClick={() => handleSourceChange('fstream', `fstream_${index}`, fstreamSource.decoded_url || '')} + className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isFstreamActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} + > + <span> + {fstreamSource.label} + {hosterId && pinnedHosterId === hosterId && ( + <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> + )} + </span> + <div className="flex items-center gap-2"> + <span className="text-xs text-gray-500">{fstreamSource.category}</span> + {isFstreamActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + </div> + </motion.button> + {renderHosterPin(hosterId)} + </div> + ); + })} + </div> + ); + }).filter(Boolean); + })()} </motion.div> )} </AnimatePresence> @@ -860,33 +892,125 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { transition={{ duration: 0.15 }} className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" > - {wiflixSources && wiflixSources.length > 0 && wiflixSources.map((wiflixSource: any, index: number) => { - const isWiflixActive = embedType === 'wiflix' && embedUrl === wiflixSource.url; - const hosterId = detectHosterFromUrl(wiflixSource.url, wiflixSource.label); - return ( - <div key={`wiflix_${index}`} className="mb-2 flex items-stretch gap-2"> - <motion.button - initial={{ opacity: 0 }} - animate={{ opacity: 1 }} - transition={{ duration: 0.1, delay: index * 0.02 }} - onClick={() => handleSourceChange('wiflix', `wiflix_${index}`, wiflixSource.url || '')} - className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isWiflixActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} - > - <span> - {wiflixSource.label} - {hosterId && pinnedHosterId === hosterId && ( - <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> - )} - </span> - <div className="flex items-center gap-2"> - <span className="text-xs text-gray-500">{wiflixSource.category}</span> - {isWiflixActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + {wiflixSources && wiflixSources.length > 0 && (() => { + const sourcesByCategory = wiflixSources.reduce((acc: Record<string, any[]>, s: any) => { + const category = s.category || 'Default'; + if (!acc[category]) acc[category] = []; + acc[category].push(s); + return acc; + }, {} as Record<string, any[]>); + const categoryOrder = [ + { key: 'VF', label: t('watch.french'), flagCode: 'FR' }, + { key: 'VOSTFR', label: t('watch.voSubtitledFr'), flagCode: 'GB' }, + ]; + return categoryOrder.map((cat) => { + const categorySources = sourcesByCategory[cat.key]; + if (!categorySources || categorySources.length === 0) return null; + return ( + <div key={`wiflix_category_${cat.key}`} className="mb-3"> + <div className="flex items-center gap-2 mb-2 px-2"> + <span className="text-lg"><ReactCountryFlag countryCode={cat.flagCode} svg style={{ width: '1.2em', height: '1.2em', borderRadius: '2px' }} /></span> + <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{cat.label} ({categorySources.length})</span> </div> - </motion.button> - {renderHosterPin(hosterId)} - </div> - ); - })} + {categorySources.map((wiflixSource: any) => { + const index = wiflixSources.findIndex((s: any) => s === wiflixSource); + const isWiflixActive = embedType === 'wiflix' && embedUrl === wiflixSource.url; + const hosterId = detectHosterFromUrl(wiflixSource.url, wiflixSource.label); + return ( + <div key={`wiflix_${index}`} className="mb-2 ml-4 flex items-stretch gap-2"> + <motion.button + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.1, delay: index * 0.02 }} + onClick={() => handleSourceChange('wiflix', `wiflix_${index}`, wiflixSource.url || '')} + className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isWiflixActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} + > + <span> + {wiflixSource.label} + {hosterId && pinnedHosterId === hosterId && ( + <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> + )} + </span> + <div className="flex items-center gap-2"> + <span className="text-xs text-gray-500">{wiflixSource.category}</span> + {isWiflixActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + </div> + </motion.button> + {renderHosterPin(hosterId)} + </div> + ); + })} + </div> + ); + }).filter(Boolean); + })()} + </motion.div> + )} + </AnimatePresence> + )} + {/* Ajout du menu déroulant J1F / 1jour1film */} + {source.type === 'j1f_main' && ( + <AnimatePresence> + {showJ1fMenu && ( + <motion.div + initial={{ opacity: 0, y: -5 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.15 }} + className="ml-4 pl-2 border-l-2 border-gray-700 mb-2" + > + {j1fSources && j1fSources.length > 0 && (() => { + const sourcesByCategory = j1fSources.reduce((acc: Record<string, any[]>, s: any) => { + const category = s.category || 'Default'; + if (!acc[category]) acc[category] = []; + acc[category].push(s); + return acc; + }, {} as Record<string, any[]>); + const categoryOrder = [ + { key: 'VF', label: t('watch.french'), flagCode: 'FR' }, + { key: 'VOSTFR', label: t('watch.voSubtitledFr'), flagCode: 'GB' }, + ]; + return categoryOrder.map((cat) => { + const categorySources = sourcesByCategory[cat.key]; + if (!categorySources || categorySources.length === 0) return null; + return ( + <div key={`j1f_category_${cat.key}`} className="mb-3"> + <div className="flex items-center gap-2 mb-2 px-2"> + <span className="text-lg"><ReactCountryFlag countryCode={cat.flagCode} svg style={{ width: '1.2em', height: '1.2em', borderRadius: '2px' }} /></span> + <span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{cat.label} ({categorySources.length})</span> + </div> + {categorySources.map((j1fSource: any) => { + const index = j1fSources.findIndex((s: any) => s === j1fSource); + const isJ1fActive = embedType === 'j1f' && embedUrl === j1fSource.url; + const hosterId = detectHosterFromUrl(j1fSource.url, j1fSource.label); + return ( + <div key={`j1f_${index}`} className="mb-2 ml-4 flex items-stretch gap-2"> + <motion.button + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.1, delay: index * 0.02 }} + onClick={() => handleSourceChange('j1f', `j1f_${index}`, j1fSource.url || '')} + className={`w-full flex-1 px-4 py-2 text-sm text-left hover:bg-gray-800/80 rounded-lg flex justify-between items-center bg-gray-900/40 text-gray-300 ${isJ1fActive ? 'ring-2 ring-red-500 bg-gray-800/80' : ''}`} + > + <span> + {j1fSource.label} + {hosterId && pinnedHosterId === hosterId && ( + <span className="ml-2 text-xs text-amber-400 font-semibold">#1</span> + )} + </span> + <div className="flex items-center gap-2"> + <span className="text-xs text-gray-500">{j1fSource.category}</span> + {isJ1fActive && <span className="text-xs px-2 py-1 bg-red-600 text-white rounded-full">{t('watch.inProgress')}</span>} + </div> + </motion.button> + {renderHosterPin(hosterId)} + </div> + ); + })} + </div> + ); + }).filter(Boolean); + })()} </motion.div> )} </AnimatePresence> @@ -1418,7 +1542,11 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { <div className="mb-4"> <h4 className="text-gray-400 text-xs uppercase tracking-wider mb-2 px-2">{t('watch.builtInSubtitles')}</h4> {subtitles.map((track, idx) => { - const id = `internal:${track.language || idx}`; + // Key on idx, not language: two tracks can share a language + // (e.g. "Français (forced)" + "Français"), and a language-based + // id collides so both rows would show "Actif" and only the first + // would ever be selectable. + const id = `internal:${idx}`; return ( <button key={id} @@ -2483,12 +2611,12 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { <div> <div className="flex justify-between items-center mb-1"> <span className="text-xs text-gray-300">{t('watch.contrastLabel')}</span> - <span className="text-xs text-cyan-400 font-mono">{customOled.contrast.toFixed(2)}</span> + <span className="text-xs text-cyan-400 font-mono">{(customOled.contrast ?? 1).toFixed(2)}</span> </div> - <input type="range" min="0.5" max="2" step="0.01" value={customOled.contrast} + <input type="range" min="0.5" max="2" step="0.01" value={customOled.contrast ?? 1} onChange={(e) => handleCustomOledChange('contrast', parseFloat(e.target.value))} className="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" - style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((customOled.contrast - 0.5) / 1.5) * 100}%, #374151 ${((customOled.contrast - 0.5) / 1.5) * 100}%, #374151 100%)` }} + style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${(((customOled.contrast ?? 1) - 0.5) / 1.5) * 100}%, #374151 ${(((customOled.contrast ?? 1) - 0.5) / 1.5) * 100}%, #374151 100%)` }} /> <div className="flex justify-between text-[10px] text-gray-500 mt-0.5"><span>0.50</span><span>1.00</span><span>2.00</span></div> </div> @@ -2497,12 +2625,12 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { <div> <div className="flex justify-between items-center mb-1"> <span className="text-xs text-gray-300">{t('watch.saturationLabel')}</span> - <span className="text-xs text-cyan-400 font-mono">{customOled.saturate.toFixed(2)}</span> + <span className="text-xs text-cyan-400 font-mono">{(customOled.saturate ?? 1).toFixed(2)}</span> </div> - <input type="range" min="0" max="3" step="0.01" value={customOled.saturate} + <input type="range" min="0" max="3" step="0.01" value={customOled.saturate ?? 1} onChange={(e) => handleCustomOledChange('saturate', parseFloat(e.target.value))} className="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" - style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${(customOled.saturate / 3) * 100}%, #374151 ${(customOled.saturate / 3) * 100}%, #374151 100%)` }} + style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((customOled.saturate ?? 1) / 3) * 100}%, #374151 ${((customOled.saturate ?? 1) / 3) * 100}%, #374151 100%)` }} /> <div className="flex justify-between text-[10px] text-gray-500 mt-0.5"><span>{t('watch.bwLabel')}</span><span>{t('watch.normalLabel')}</span><span>3.00</span></div> </div> @@ -2511,12 +2639,12 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { <div> <div className="flex justify-between items-center mb-1"> <span className="text-xs text-gray-300">{t('watch.brightnessLabel')}</span> - <span className="text-xs text-cyan-400 font-mono">{customOled.brightness.toFixed(2)}</span> + <span className="text-xs text-cyan-400 font-mono">{(customOled.brightness ?? 1).toFixed(2)}</span> </div> - <input type="range" min="0.3" max="1.8" step="0.01" value={customOled.brightness} + <input type="range" min="0.3" max="1.8" step="0.01" value={customOled.brightness ?? 1} onChange={(e) => handleCustomOledChange('brightness', parseFloat(e.target.value))} className="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" - style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${((customOled.brightness - 0.3) / 1.5) * 100}%, #374151 ${((customOled.brightness - 0.3) / 1.5) * 100}%, #374151 100%)` }} + style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${(((customOled.brightness ?? 1) - 0.3) / 1.5) * 100}%, #374151 ${(((customOled.brightness ?? 1) - 0.3) / 1.5) * 100}%, #374151 100%)` }} /> <div className="flex justify-between text-[10px] text-gray-500 mt-0.5"><span>{t('watch.darkLabel')}</span><span>{t('watch.normalLabel')}</span><span>{t('watch.brightLabel')}</span></div> </div> @@ -2525,12 +2653,12 @@ const HLSPlayerSettingsPanel = (props: HLSPlayerSettingsPanelProps) => { <div> <div className="flex justify-between items-center mb-1"> <span className="text-xs text-gray-300">{t('watch.sepiaLabel')}</span> - <span className="text-xs text-cyan-400 font-mono">{customOled.sepia.toFixed(2)}</span> + <span className="text-xs text-cyan-400 font-mono">{(customOled.sepia ?? 0).toFixed(2)}</span> </div> - <input type="range" min="0" max="1" step="0.01" value={customOled.sepia} + <input type="range" min="0" max="1" step="0.01" value={customOled.sepia ?? 0} onChange={(e) => handleCustomOledChange('sepia', parseFloat(e.target.value))} className="w-full h-1.5 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-cyan-500" - style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${customOled.sepia * 100}%, #374151 ${customOled.sepia * 100}%, #374151 100%)` }} + style={{ background: `linear-gradient(to right, #06b6d4 0%, #06b6d4 ${(customOled.sepia ?? 0) * 100}%, #374151 ${(customOled.sepia ?? 0) * 100}%, #374151 100%)` }} /> <div className="flex justify-between text-[10px] text-gray-500 mt-0.5"><span>0</span><span>0.50</span><span>1.00</span></div> </div> diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b4b0844..f1c42a9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -130,7 +130,7 @@ const Header: React.FC = () => { { name: t('nav.settings'), path: '/settings', icon: <Settings size={20} />, color: 'gray', desc: t('nav.settingsDesc') }, { name: t('nav.helpHub'), path: '/help', icon: <HelpCircle size={20} />, color: 'indigo', desc: t('nav.helpHubDesc') }, { name: t('nav.github'), path: 'https://github.com/movixcorp/MovixOpenSource', icon: <Github size={20} />, color: 'gray', desc: t('nav.githubDesc'), external: true }, - { name: t('footer.ourUrls'), path: 'https://movix.health', icon: <ExternalLink size={20} />, color: 'gray', desc: t('nav.officialLinksDesc'), external: true }, + { name: t('footer.ourUrls'), path: 'https://movix.online', icon: <ExternalLink size={20} />, color: 'gray', desc: t('nav.officialLinksDesc'), external: true }, ] }, ].filter(g => g.items.length > 0), [t, isVip]); diff --git a/src/components/HeroSlider.tsx b/src/components/HeroSlider.tsx index 0ddaa8f..ba803fa 100644 --- a/src/components/HeroSlider.tsx +++ b/src/components/HeroSlider.tsx @@ -303,7 +303,7 @@ const HeroSliderInner: React.FC<HeroSliderProps> = ({ items }) => { draggable={false} loading={isActive ? 'eager' : 'lazy'} decoding="async" - fetchPriority={isActive ? 'high' : 'low'} + {...{ fetchpriority: isActive ? 'high' : 'low' }} /> {/* Smooth combined gradient — no harsh transitions */} diff --git a/src/components/LiveTVPlayer.tsx b/src/components/LiveTVPlayer.tsx index 0e5303a..d235fe7 100644 --- a/src/components/LiveTVPlayer.tsx +++ b/src/components/LiveTVPlayer.tsx @@ -32,6 +32,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { Play, Pause, Volume2, VolumeX, Maximize, Minimize, X, Loader2, Volume1, Cast, Airplay, Settings, ArrowLeft, ExternalLink } from 'lucide-react'; import { isExtensionAvailable, fetchFromExtension } from '../utils/extensionProxy'; import { isLiveTvSourceEnabled, type LiveTvSourceKey } from '../utils/extractionPrefs'; +import { isLowLatencyEnabled } from '../utils/lowLatencyPref'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { getVipHeaders } from '../utils/vipUtils'; @@ -1105,14 +1106,6 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({ player.on(MediaPlayer.events.ERROR, (e: any) => { console.error('DASH Error:', e); - // If network error/download error and not using proxy yet, try proxy - // DashJS error objects vary, we check for common download issues - if (!useProxy) { - console.log('DASH Error encountered, switching to proxy...'); - setUseProxy(true); - return; - } - // Try next server if available if (currentStreamIndex < streams.length - 1) { setCurrentStreamIndex(prev => prev + 1); @@ -1129,7 +1122,7 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({ // Initialize HLS Player const hlsConfig: any = { enableWorker: true, - lowLatencyMode: false, + lowLatencyMode: isLowLatencyEnabled('livetv'), // opt-in via Settings › Performance startPosition: -1, // Start at live edge instead of beginning backBufferLength: 30, maxBufferLength: 30, @@ -1398,21 +1391,6 @@ const LiveTVPlayer: React.FC<LiveTVPlayerProps> = ({ return; } - // Détecter les erreurs réseau critiques (CORS, 403, 406) même non fatales pour hls.js - if (!useProxy && data.type === Hls.ErrorTypes.NETWORK_ERROR) { - const netStatus = data.response?.code; - // 0 = souvent erreur CORS ou network down - // 403 = Forbidden (geo-block etc) - // 406 = Not Acceptable (headers incorrects) - if (netStatus === 0 || netStatus === 403 || netStatus === 406 || data.fatal) { - console.log(`Switching to proxy due to network error (Status: ${netStatus})`); - hls.destroy(); - hlsRef.current = null; - setUseProxy(true); - return; - } - } - // Non-fatal fragment loading errors: just let HLS.js retry, no action needed if (!data.fatal && (details === 'fragLoadError' || details === 'fragLoadTimeOut')) { console.log('[HLS] Non-fatal frag error, HLS.js will auto-retry'); diff --git a/src/components/RedirectPopup.tsx b/src/components/RedirectPopup.tsx index 82a86a3..b0b5e7e 100644 --- a/src/components/RedirectPopup.tsx +++ b/src/components/RedirectPopup.tsx @@ -83,7 +83,7 @@ const RedirectPopup: React.FC<RedirectPopupProps> = ({ {t('redirect.ourNewAddress')} </h2> <div className="bg-red-600/20 border-2 border-red-500 rounded-lg p-4 mb-4"> - <p className="text-3xl font-bold text-white">movix.tax</p> + <p className="text-3xl font-bold text-white">movix.date</p> </div> <p className="text-gray-300 text-sm"> {t('redirect.joinTelegramNews')} diff --git a/src/components/RequireUsernameChange.tsx b/src/components/RequireUsernameChange.tsx new file mode 100644 index 0000000..bea3933 --- /dev/null +++ b/src/components/RequireUsernameChange.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useState } from 'react'; +import axios, { AxiosError } from 'axios'; +import { useTranslation } from 'react-i18next'; +import { motion, AnimatePresence } from 'framer-motion'; +import { AlertTriangle, User } from 'lucide-react'; +import { toast } from 'sonner'; + +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +const API_URL = import.meta.env.VITE_MAIN_API; +const PROFILE_NAME_MAX = 32; +const STORAGE_KEY = 'requires_username_change'; + +function readStoredAuthUsername(): string { + try { + const raw = localStorage.getItem('auth'); + if (!raw) return ''; + const parsed = JSON.parse(raw); + return String(parsed?.userProfile?.username || ''); + } catch { + return ''; + } +} + +function getRequestErrorMessage(error: unknown): string { + if (axios.isAxiosError(error)) { + const ax = error as AxiosError<{ error?: string }>; + return ax.response?.data?.error || ax.message; + } + if (error instanceof Error) return error.message; + return ''; +} + +/** + * Modale BLOQUANTE qui apparaît si le pseudo OAuth (Discord/Google) stocké + * viole la policy serveur (trop long, caractères de contrôle, zero-width). + * + * - Lit le flag `requires_username_change` posé en localStorage par + * `persistResolvedSession()` au login. + * - Aucun moyen de fermer la modale sans soumettre un pseudo valide. + * - POST /api/auth/username pour persister le nouveau pseudo. + * - À la réussite, met à jour `auth` en localStorage + clear le flag + + * notifie le reste de l'app via `auth-changed` event. + */ +const RequireUsernameChange: React.FC = () => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [originalUsername, setOriginalUsername] = useState(''); + const [newUsername, setNewUsername] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState<string | null>(null); + + // Lecture initiale + écoute des changements du flag (login/logout). + useEffect(() => { + const refresh = () => { + const flag = localStorage.getItem(STORAGE_KEY) === '1'; + if (flag) { + const stored = readStoredAuthUsername(); + setOriginalUsername(stored); + setNewUsername(''); + setError(null); + setOpen(true); + } else { + setOpen(false); + } + }; + refresh(); + const handler = () => refresh(); + window.addEventListener('storage', handler); + window.addEventListener('auth-changed', handler); + return () => { + window.removeEventListener('storage', handler); + window.removeEventListener('auth-changed', handler); + }; + }, []); + + // Empêche le scroll de l'arrière-plan tant que la modale est ouverte. + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { document.body.style.overflow = prev; }; + }, [open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + const cleaned = newUsername.trim(); + if (!cleaned) { + setError(t('requireUsernameChange.errorEmpty')); + return; + } + if (cleaned.length > PROFILE_NAME_MAX) { + setError(t('requireUsernameChange.errorTooLong', { max: PROFILE_NAME_MAX })); + return; + } + setSubmitting(true); + try { + const token = localStorage.getItem('auth_token'); + const res = await axios.post( + `${API_URL}/api/auth/username`, + { username: cleaned }, + { headers: { Authorization: `Bearer ${token}` } }, + ); + + // Met à jour le bloc `auth` en localStorage avec le nouveau username. + if (res.data?.authData) { + try { + localStorage.setItem('auth', JSON.stringify(res.data.authData)); + } catch { /* quota plein */ } + } else { + try { + const stored = JSON.parse(localStorage.getItem('auth') || '{}'); + if (stored?.userProfile) { + stored.userProfile.username = cleaned; + localStorage.setItem('auth', JSON.stringify(stored)); + } + } catch { /* ignore */ } + } + + localStorage.removeItem(STORAGE_KEY); + window.dispatchEvent(new Event('auth-changed')); + toast.success(t('requireUsernameChange.success')); + setOpen(false); + } catch (err) { + setError(getRequestErrorMessage(err) || t('common.error')); + } finally { + setSubmitting(false); + } + }; + + return ( + <AnimatePresence> + {open && ( + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + transition={{ duration: 0.2 }} + className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/85 backdrop-blur-sm p-4" + // Pas de onClick backdrop : modale non-fermable. + role="dialog" + aria-modal="true" + aria-labelledby="require-username-title" + > + <motion.div + initial={{ y: 24, opacity: 0, scale: 0.96 }} + animate={{ y: 0, opacity: 1, scale: 1 }} + exit={{ y: 12, opacity: 0, scale: 0.97 }} + transition={{ type: 'spring', stiffness: 320, damping: 26 }} + className="w-full max-w-md rounded-2xl border border-yellow-300/30 bg-gray-950 p-6 shadow-2xl" + > + <div className="mb-4 flex items-start gap-3"> + <div className="shrink-0 rounded-full bg-yellow-300/15 p-2 ring-1 ring-yellow-300/40"> + <AlertTriangle className="h-6 w-6 text-yellow-300" /> + </div> + <div className="flex-1"> + <h2 id="require-username-title" className="text-lg font-semibold text-white"> + {t('requireUsernameChange.title')} + </h2> + <p className="mt-1 text-sm leading-5 text-white/70"> + {t('requireUsernameChange.description', { max: PROFILE_NAME_MAX })} + </p> + </div> + </div> + + {originalUsername && ( + <div className="mb-4 rounded-lg border border-white/10 bg-black/30 p-3 text-xs"> + <div className="text-white/40">{t('requireUsernameChange.currentLabel')}</div> + <div className="mt-1 break-all font-mono text-white/80">{originalUsername}</div> + <div className="mt-1 text-white/40"> + {t('requireUsernameChange.currentLength', { count: originalUsername.length })} + </div> + </div> + )} + + <form onSubmit={handleSubmit} className="space-y-3"> + <div> + <label htmlFor="new-username" className="mb-1 block text-xs font-semibold uppercase tracking-wider text-white/50"> + {t('requireUsernameChange.inputLabel')} + </label> + <div className="relative"> + <User className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40" /> + <Input + id="new-username" + type="text" + value={newUsername} + onChange={(e) => { setNewUsername(e.target.value); setError(null); }} + placeholder={t('requireUsernameChange.placeholder')} + maxLength={PROFILE_NAME_MAX} + autoFocus + autoComplete="off" + className="pl-9" + /> + </div> + <div className="mt-1 flex justify-between text-[11px] text-white/40"> + <span>{t('requireUsernameChange.hint', { max: PROFILE_NAME_MAX })}</span> + <span>{newUsername.length}/{PROFILE_NAME_MAX}</span> + </div> + </div> + + {error && ( + <div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-300"> + {error} + </div> + )} + + <Button + type="submit" + disabled={submitting || !newUsername.trim()} + className="w-full" + > + {submitting ? t('requireUsernameChange.submitting') : t('requireUsernameChange.submit')} + </Button> + </form> + </motion.div> + </motion.div> + )} + </AnimatePresence> + ); +}; + +export default RequireUsernameChange; diff --git a/src/components/Settings/SourceCard.tsx b/src/components/Settings/SourceCard.tsx index 1078ef4..f794c8c 100644 --- a/src/components/Settings/SourceCard.tsx +++ b/src/components/Settings/SourceCard.tsx @@ -26,6 +26,7 @@ export const SOURCE_LABELS: Record<TopLevelSourceId, string> = { bravo: 'Bravo/PurStream', fstream: 'FStream', wiflix: 'Lynx', + j1f: '1jour1film', omega: 'Omega', coflix: 'Coflix', frembed: 'Frembed', diff --git a/src/components/StreamingLinksManager.tsx b/src/components/StreamingLinksManager.tsx index efd8499..a2ba17c 100644 --- a/src/components/StreamingLinksManager.tsx +++ b/src/components/StreamingLinksManager.tsx @@ -38,6 +38,20 @@ interface Episode { name: string; } +// API stores streaming links as either a plain URL string or a VIP-gated object. +// Links added via the panel also carry an `added_by` owner stamp (used to scope +// who may delete them). +type StreamingLink = + | string + | { + url: string; + isVip?: boolean; + label?: string; + language?: string; + added_by?: { id: string; auth_type: string }; + added_at?: string; + }; + const StreamingLinksManager: React.FC = () => { const { t } = useTranslation(); const [currentMedia, setCurrentMedia] = useState<'movie' | 'tv'>('movie'); @@ -50,11 +64,12 @@ const StreamingLinksManager: React.FC = () => { const [selectedSeason, setSelectedSeason] = useState(''); const [selectedEpisode, setSelectedEpisode] = useState(''); const [links, setLinks] = useState<string[]>(['']); - const [currentLinks, setCurrentLinks] = useState<string[]>([]); + const [currentLinks, setCurrentLinks] = useState<StreamingLink[]>([]); const [isLoading, setIsLoading] = useState(false); const [isMassAddModalOpen, setIsMassAddModalOpen] = useState(false); const [massLinksText, setMassLinksText] = useState(''); const [startEpisode, setStartEpisode] = useState(1); + const [rulesOpen, setRulesOpen] = useState(true); // États pour la pagination const [currentPage, setCurrentPage] = useState(1); @@ -74,6 +89,10 @@ const StreamingLinksManager: React.FC = () => { }); const [customHost, setCustomHost] = useState(''); const [isFullSeason, setIsFullSeason] = useState(false); + // Current admin identity — used to hide the delete button on download links + // an uploader didn't add (admins keep it on all). The backend enforces this + // regardless; this is just so uploaders don't see buttons that would 403. + const [currentAdmin, setCurrentAdmin] = useState<{ userId: string; userType: string; role: string } | null>(null); const API_URL = import.meta.env.VITE_MAIN_API; const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY || ''; @@ -175,20 +194,25 @@ const StreamingLinksManager: React.FC = () => { const loadCurrentLinks = async () => { if (!selectedItem) return; + // Use the authed admin endpoint (not the public /api/links) so the response + // includes the `added_by` owner stamp needed to gate the delete buttons. try { - let url = `${API_URL}/api/links/${currentMedia}/${selectedItem.id}`; + let url = `${API_URL}/api/admin/streaming-links/${currentMedia}/${selectedItem.id}`; - if (currentMedia === 'tv' && selectedSeason && selectedEpisode) { + if (currentMedia === 'tv') { + if (!selectedSeason || !selectedEpisode) { + setCurrentLinks([]); + return; + } url += `?season=${selectedSeason}&episode=${selectedEpisode}`; } - const response = await axios.get(url); + const response = await axios.get(url, { + headers: { Authorization: `Bearer ${getAuthToken()}` }, + }); - if (response.data.success) { - const links = currentMedia === 'movie' - ? response.data.data.links - : response.data.data[0]?.links || []; - setCurrentLinks(Array.isArray(links) ? links : []); + if (response.data?.success) { + setCurrentLinks(Array.isArray(response.data.links) ? response.data.links : []); } else { setCurrentLinks([]); } @@ -375,49 +399,31 @@ const StreamingLinksManager: React.FC = () => { if (!confirm(t('streamingLinks.confirmDeleteOne'))) return; if (!selectedItem) return; + // Delete the single link by URL. The backend enforces ownership (uploaders + // may only remove links they added), so we no longer replace the whole array. + const link = currentLinks[index]; + const linkUrl = typeof link === 'string' ? link : link.url; + try { - const updatedLinks = currentLinks.filter((_, i) => i !== index); + const body: any = { + type: currentMedia, + id: selectedItem.id.toString(), + url: linkUrl, + }; - if (updatedLinks.length === 0) { - // Supprimer complètement l'entrée - const body: any = { - type: currentMedia, - id: selectedItem.id.toString() - }; - - if (currentMedia === 'tv' && selectedSeason && selectedEpisode) { - body.season = parseInt(selectedSeason); - body.episode = parseInt(selectedEpisode); - } - - await axios.delete(`${API_URL}/api/admin/links`, { - headers: { - 'Authorization': `Bearer ${getAuthToken()}`, - 'Content-Type': 'application/json' - }, - data: body - }); - } else { - // Mettre à jour avec les liens restants - const body: any = { - type: currentMedia, - id: selectedItem.id.toString(), - links: updatedLinks - }; - - if (currentMedia === 'tv' && selectedSeason && selectedEpisode) { - body.season = parseInt(selectedSeason); - body.episode = parseInt(selectedEpisode); - } - - await axios.put(`${API_URL}/api/admin/links`, body, { - headers: { - 'Authorization': `Bearer ${getAuthToken()}`, - 'Content-Type': 'application/json' - } - }); + if (currentMedia === 'tv' && selectedSeason && selectedEpisode) { + body.season = parseInt(selectedSeason); + body.episode = parseInt(selectedEpisode); } + await axios.delete(`${API_URL}/api/admin/links`, { + headers: { + 'Authorization': `Bearer ${getAuthToken()}`, + 'Content-Type': 'application/json' + }, + data: body + }); + toast.success(t('streamingLinks.linkDeleted')); await loadCurrentLinks(); } catch (error: any) { @@ -533,6 +539,49 @@ const StreamingLinksManager: React.FC = () => { } }, [selectedSeason, selectedEpisode, selectedItem, currentMedia, mode, isFullSeason]); + // Fetch current admin identity once so we can scope download-link deletion. + useEffect(() => { + (async () => { + try { + const res = await axios.get(`${API_URL}/api/admin/check`, { + headers: { Authorization: `Bearer ${getAuthToken()}` }, + }); + if (res.data?.success && res.data.admin) { + setCurrentAdmin({ + userId: String(res.data.admin.userId), + userType: String(res.data.admin.userType), + role: String(res.data.admin.role || 'admin'), + }); + } + } catch { + // Non-blocking: leave identity unknown and defer to backend enforcement. + } + })(); + }, [API_URL]); + + // An uploader may only delete the download links they uploaded; admins may + // delete any. Mirrors the server check (added_by stamp). Fails open when the + // identity is still unknown — the backend is the real authority. + const canDeleteDownloadLink = (l: AdminDownloadLink): boolean => { + if (!currentAdmin) return true; + if (currentAdmin.role === 'admin') return true; + const stamp = l.added_by; + if (!stamp) return false; + const myAuthType = currentAdmin.userType === 'bip39' ? 'bip-39' : 'oauth'; + return String(stamp.id) === String(currentAdmin.userId) && stamp.auth_type === myAuthType; + }; + + // Same ownership rule for streaming links. Legacy links (plain strings, or + // objects with no `added_by`) have no owner → only admins can delete them. + const canDeleteStreamingLink = (l: StreamingLink): boolean => { + if (!currentAdmin) return true; + if (currentAdmin.role === 'admin') return true; + const stamp = typeof l === 'object' ? l.added_by : undefined; + if (!stamp) return false; + const myAuthType = currentAdmin.userType === 'bip39' ? 'bip-39' : 'oauth'; + return String(stamp.id) === String(currentAdmin.userId) && stamp.auth_type === myAuthType; + }; + return ( <div className="space-y-6"> {/* Mode Toggle: Streaming / Téléchargement */} @@ -696,6 +745,78 @@ const StreamingLinksManager: React.FC = () => { > {selectedItem && ( <div className="space-y-6"> + {/* Uploader guidelines — applies to both streaming & download. + Toggle + grid-rows 0fr/1fr trick animates the collapse (native + <details> can't tween height). */} + <div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-4"> + <button + type="button" + onClick={() => setRulesOpen((o) => !o)} + className="flex w-full items-center gap-2 font-semibold text-amber-300" + > + {/* warning triangle */} + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={1.8} + className="h-5 w-5 flex-shrink-0" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" + /> + </svg> + <span>Règles avant d'ajouter un lien</span> + {/* chevron flips with state */} + <svg + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={2} + className={`ml-auto h-4 w-4 flex-shrink-0 transition-transform duration-300 ${ + rulesOpen ? 'rotate-180' : '' + }`} + > + <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> + </svg> + </button> + <div + className={`grid transition-all duration-300 ease-out ${ + rulesOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0' + }`} + > + <ul className="list-inside list-disc space-y-2 overflow-hidden pt-3 text-sm text-amber-100/90"> + <li> + <strong>Pas de doublons :</strong> ne réuploadez pas un lien déjà présent dans la + liste, ni la même version (même hébergeur + même langue + même qualité) deux fois. + </li> + <li> + <strong>Pas de chevauchement de versions :</strong> si un lien couvre déjà une + langue (ex. MULTI ou VF+VOSTFR), n'ajoutez pas en plus le même hébergeur en VF + seul. Une version par hébergeur / qualité suffit. + </li> + <li> + <strong>Remplacez les CAM :</strong> dès qu'une meilleure qualité (HD / WEB / + BluRay) est disponible, supprimez vos liens CAM / TS / basse qualité. + </li> + <li> + <strong>Testez avant de poster :</strong> le lien doit lire le bon film / épisode, + sans lien mort, redirection pub, survey ou .exe. + </li> + <li> + <strong>Nettoyez :</strong> supprimez vos propres liens morts ou périmés. + </li> + <li> + <strong>Une question / un doute ?</strong> MP : Discord{' '} + <span className="font-mono text-amber-200">mysticsaba_alt</span> · Telegram{' '} + <span className="font-mono text-amber-200">MysticSaba</span>. + </li> + </ul> + </div> + </div> + {/* Season/Episode Selectors for TV */} {currentMedia === 'tv' && ( <div className="space-y-4"> @@ -786,13 +907,15 @@ const StreamingLinksManager: React.FC = () => { <div className="space-y-2"> {currentLinks.map((link, index) => ( <div key={index} className="flex items-center justify-between bg-gray-800 p-3 rounded-lg"> - <span className="text-gray-300 text-sm flex-1 break-all">{link}</span> - <button - onClick={() => deleteSpecificLink(index)} - className="ml-3 px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700" - > - {t('streamingLinks.deleteLink')} - </button> + <span className="text-gray-300 text-sm flex-1 break-all">{typeof link === 'string' ? link : link.url}</span> + {canDeleteStreamingLink(link) && ( + <button + onClick={() => deleteSpecificLink(index)} + className="ml-3 px-3 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700" + > + {t('streamingLinks.deleteLink')} + </button> + )} </div> ))} </div> @@ -940,12 +1063,14 @@ const StreamingLinksManager: React.FC = () => { </div> </div> </div> - <button - onClick={() => deleteDownloadLinkFromList(l.url, Boolean(l.full_saison))} - className="px-3 py-1 rounded bg-red-600 text-white text-sm" - > - Supprimer - </button> + {canDeleteDownloadLink(l) && ( + <button + onClick={() => deleteDownloadLinkFromList(l.url, Boolean(l.full_saison))} + className="px-3 py-1 rounded bg-red-600 text-white text-sm" + > + Supprimer + </button> + )} </li> ))} </ul> diff --git a/src/components/TopProgressBar.tsx b/src/components/TopProgressBar.tsx index 5eb8432..872ec13 100644 --- a/src/components/TopProgressBar.tsx +++ b/src/components/TopProgressBar.tsx @@ -23,18 +23,21 @@ type Phase = 'idle' | 'loading' | 'completing' | 'fading'; */ export const TopProgressBar = () => { const [phase, setPhase] = useState<Phase>('idle'); - const [progress, setProgress] = useState(0); const startTimeRef = useRef<number>(0); const progressRef = useRef<number>(0); + const barRef = useRef<HTMLDivElement | null>(null); const showTimerRef = useRef<number | null>(null); const fillTimerRef = useRef<number | null>(null); const fadeTimerRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null); useEffect(() => { + // Writes the bar fill directly on the DOM node (scaleX = compositor-only) + // instead of setState per rAF tick — no React re-render, no layout/paint. const updateProgress = (p: number) => { progressRef.current = p; - setProgress(p); + const bar = barRef.current; + if (bar) bar.style.transform = `scaleX(${p})`; }; const startTick = () => { @@ -164,11 +167,11 @@ export const TopProgressBar = () => { // During `completing`, use the full fill duration as the CSS transition so // the catch-up to 100% interpolates smoothly. During `loading`, use a short // transition so the rAF-driven micro-updates blend without lag. - const widthTransition = reduceMotion + const barTransition = reduceMotion ? 'none' : phase === 'completing' - ? `width ${COMPLETE_FILL_MS}ms ease-out` - : 'width 200ms ease-out'; + ? `transform ${COMPLETE_FILL_MS}ms ease-out` + : 'transform 200ms ease-out'; return ( <div @@ -186,13 +189,16 @@ export const TopProgressBar = () => { }} > <div + ref={barRef} style={{ height: '100%', - width: `${progress * 100}%`, + width: '100%', background: COLOR, boxShadow: `0 0 6px ${COLOR}, 0 0 3px ${COLOR}`, - transition: widthTransition, - willChange: 'width, opacity', + transform: `scaleX(${progressRef.current})`, + transformOrigin: '0 50%', + transition: barTransition, + willChange: 'transform', }} /> </div> diff --git a/src/components/stats/ChartCard.tsx b/src/components/stats/ChartCard.tsx new file mode 100644 index 0000000..7fec390 --- /dev/null +++ b/src/components/stats/ChartCard.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; +import AnimatedBorderCard from '../ui/animated-border-card'; + +interface ChartCardProps { + title: string; + highlight?: string; + action?: ReactNode; + children: ReactNode; +} + +const ChartCard = ({ title, highlight = '56 189 248', action, children }: ChartCardProps) => ( + <AnimatedBorderCard highlightColor={highlight} backgroundColor="10 10 10" className="p-5"> + <div className="mb-4 flex items-center justify-between gap-3"> + <h3 className="text-sm font-semibold uppercase tracking-wide text-white/70">{title}</h3> + {action} + </div> + <div className="h-64 w-full">{children}</div> + </AnimatedBorderCard> +); + +export default ChartCard; diff --git a/src/components/stats/chartTheme.ts b/src/components/stats/chartTheme.ts new file mode 100644 index 0000000..93c7b99 --- /dev/null +++ b/src/components/stats/chartTheme.ts @@ -0,0 +1,34 @@ +import type { CSSProperties } from 'react'; + +export const CHART_COLORS = { + grid: 'rgba(255,255,255,0.08)', + axis: 'rgba(255,255,255,0.45)', + registrations: '#38bdf8', + sessions: '#f43f5e', + dau: '#a855f7', + comments: '#f97316', + lists: '#14b8a6', + vip: '#fbbf24', +}; + +export const PROVIDER_COLORS: Record<string, string> = { + bip39: '#38bdf8', + discord: '#5865F2', + google: '#ef4444', + unknown: '#64748b', +}; + +export const tooltipStyle: CSSProperties = { + background: 'rgba(10,10,10,0.95)', + border: '1px solid rgba(255,255,255,0.12)', + borderRadius: 12, + color: '#fff', + fontSize: 12, +}; + +/** 'YYYY-MM-DD' -> 'DD/MM' for compact axis/tooltip labels. */ +export const formatDayLabel = (d: string): string => { + const parts = String(d).split('-'); + if (parts.length !== 3) return String(d); + return `${parts[2]}/${parts[1]}`; +}; diff --git a/src/components/ui/blur-text.tsx b/src/components/ui/blur-text.tsx index b279eb1..e2ca363 100644 --- a/src/components/ui/blur-text.tsx +++ b/src/components/ui/blur-text.tsx @@ -45,6 +45,9 @@ const BlurText: React.FC<BlurTextProps> = ({ }) => { const elements = animateBy === 'words' ? text.split(' ') : text.split(''); const [inView, setInView] = useState(false); + // will-change est posé uniquement pendant l'animation (one-shot) puis + // relâché — évite de garder un layer GPU par mot pour toute la vie de la page. + const [animDone, setAnimDone] = useState(false); const ref = useRef<HTMLParagraphElement>(null); useEffect(() => { @@ -64,7 +67,7 @@ const BlurText: React.FC<BlurTextProps> = ({ const defaultFrom = useMemo( () => - direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 }, + direction === 'top' ? { filter: 'blur(8px)', opacity: 0, y: -50 } : { filter: 'blur(8px)', opacity: 0, y: 50 }, [direction] ); @@ -105,10 +108,17 @@ const BlurText: React.FC<BlurTextProps> = ({ initial={fromSnapshot} animate={inView ? animateKeyframes : fromSnapshot} transition={spanTransition} - onAnimationComplete={index === elements.length - 1 ? onAnimationComplete : undefined} + onAnimationComplete={ + index === elements.length - 1 + ? () => { + setAnimDone(true); + onAnimationComplete?.(); + } + : undefined + } style={{ display: 'inline-block', - willChange: 'transform, filter, opacity' + willChange: inView && !animDone ? 'transform, filter, opacity' : 'auto' }} > {segment === ' ' ? '\u00A0' : segment} diff --git a/src/config/discord.ts b/src/config/discord.ts index 2bd182a..2a2ca90 100644 --- a/src/config/discord.ts +++ b/src/config/discord.ts @@ -5,7 +5,7 @@ const getRedirectUri = () => { }; export const DISCORD_CONFIG = { - CLIENT_ID: '1259926474174238741', + CLIENT_ID: '1517232036425895996', GUILD_ID: '1183496423535288401', // Liste des IDs de rôle pour l'accès admin ADMIN_ROLE_IDS: [ diff --git a/src/config/runtime.ts b/src/config/runtime.ts index aec0246..4fe6c4e 100644 --- a/src/config/runtime.ts +++ b/src/config/runtime.ts @@ -8,8 +8,6 @@ export const WATCHPARTY_API = normalizeBaseUrl(import.meta.env.VITE_WATCHPARTY_A export const PROXY_BASE_URL = normalizeBaseUrl(import.meta.env.VITE_PROXY_BASE_URL as string); export const MAIN_API = normalizeBaseUrl(import.meta.env.VITE_MAIN_API as string); -export const API_PROXY_BASE_URL = normalizeBaseUrl(import.meta.env.VITE_API_PROXY_BASE_URL as string) || - normalizeBaseUrl(import.meta.env.VITE_MAIN_API as string); export const PROXIES_EMBED_API = normalizeBaseUrl(import.meta.env.VITE_PROXIES_EMBED_API as string); export const BESTDEBRID_API_BASE = 'https://bestdebrid.com/api/v1'; @@ -22,8 +20,3 @@ export const buildProxyUrl = (url: string): string => { if (!PROXY_BASE_URL) return url; return `${PROXY_BASE_URL}/proxy/${url}`; }; - -export const buildApiProxyUrl = (url: string): string => { - if (!API_PROXY_BASE_URL) return url; - return `${API_PROXY_BASE_URL}/proxy/${url}`; -}; diff --git a/src/context/AdFreePopupContext.tsx b/src/context/AdFreePopupContext.tsx index 97c1851..67e57ab 100644 --- a/src/context/AdFreePopupContext.tsx +++ b/src/context/AdFreePopupContext.tsx @@ -1,5 +1,7 @@ import React, { createContext, useContext, useState, useCallback, useEffect, useMemo } from 'react'; import { isUserVip } from '../utils/vipUtils'; +import { getAdPopupMode, subscribeToAdPopupModeChanges } from '../utils/adPopupMode'; +import { SCRIPT_AD_MODE_ENABLED, loadAdScript } from '../utils/adScriptMode'; interface AdFreePopupContextType { @@ -50,6 +52,16 @@ export const AdFreePopupProvider: React.FC<{ children: React.ReactNode }> = ({ c }; }, []); + // Précharge le script popunder quand le popup est en mode normal/bouton. + // Auto et click-anywhere continuent d'utiliser le lien direct. + useEffect(() => { + const sync = () => { + if (getAdPopupMode() === 'normal' && SCRIPT_AD_MODE_ENABLED) loadAdScript(); + }; + sync(); + return subscribeToAdPopupModeChanges(sync); + }, []); + const showPopupForPlayer = useCallback((playerType: string, additionalInfo?: any) => { // First check VIP status via server-verified utility const isVipUser = isUserVip() || is_vip; @@ -89,6 +101,8 @@ export const AdFreePopupProvider: React.FC<{ children: React.ReactNode }> = ({ c console.log(`[AdFreePopupContext] FStream player detected`); } else if (playerType === 'wiflix') { console.log(`[AdFreePopupContext] Wiflix/Lynx player detected`); + } else if (playerType === 'j1f') { + console.log(`[AdFreePopupContext] 1jour1film player detected`); } else if (playerType === 'frembed') { console.log(`[AdFreePopupContext] Frembed player detected`); } else if (playerType === 'coflix') { @@ -104,7 +118,11 @@ export const AdFreePopupProvider: React.FC<{ children: React.ReactNode }> = ({ c // Always show popup for any player type setPlayerToShow(playerType); setShowAdFreePopup(true); - setShouldLoadIframe(false); + // Click-anywhere preloads the player (iframe/HLS) behind the transparent + // catcher so the user sees the video instead of a black screen. The catcher + // (z-100000) intercepts the first click to open the ad, then unmounts. Other + // modes keep the player gated until the user accepts via the popup. + setShouldLoadIframe(getAdPopupMode() === 'click-anywhere'); console.log(`[AdFreePopupContext] Popup shown for ${playerType}`); }, [is_vip]); @@ -176,4 +194,4 @@ export const useAdFreePopup = () => { throw new Error('useAdFreePopup must be used within an AdFreePopupProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/src/context/ProfileContext.tsx b/src/context/ProfileContext.tsx index 4c5b193..0b1f1a5 100644 --- a/src/context/ProfileContext.tsx +++ b/src/context/ProfileContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useEffect, useMemo, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useEffect, useMemo, useRef, ReactNode } from 'react'; import { Profile, ProfileContextType } from '../types/profile'; import axios from 'axios'; import { useLocation } from 'react-router-dom'; @@ -70,12 +70,40 @@ async function replayOutboxIfAny(): Promise<void> { return; } + // Cross-user guard: if a different account is logged in than the one that + // wrote the outbox, do NOT replay. Replaying would POST with the outbox's + // userId, the backend would respond 401 "UserId mismatch", and the catch + // below would mark the outbox permanently failed → clearing the original + // owner's pending writes forever. Keep the outbox idle instead; the 7-day + // TTL evicts stale entries, and the original owner can return and replay. + const currentUserId = localStorage.getItem('resolved_user_id') || localStorage.getItem('user_id'); + if (payload.userId && currentUserId && payload.userId !== currentUserId) { + console.warn('[outbox] skipping replay — different user logged in than outbox owner'); + return; + } + + // Filter out ops whose keys are no longer syncable. Legacy outboxes may + // contain keys removed from the allowlist after a backend security update + // (e.g. `is_vip` per audit P0). Without filtering, the backend rejects the + // whole batch with 400 INVALID_SYNC_KEY → replayOutboxIfAny treats 400 as + // permanent → outbox is dropped, taking legitimate co-batched ops with it. + const validOps = payload.ops.filter((op): op is Record<string, unknown> & { key: string } => { + if (!op || typeof op !== 'object') return false; + const candidateKey = (op as { key?: unknown }).key; + return typeof candidateKey === 'string' && isSyncableStorageKey(candidateKey); + }); + + if (validOps.length === 0) { + try { localStorage.removeItem(SYNC_OUTBOX_STORAGE_KEY); } catch { /* noop */ } + return; + } + try { await axios.post(`${API_URL}/api/sync`, { userType: payload.userType, profileId: payload.profileId, userId: payload.userId, - ops: payload.ops + ops: validOps }, { headers: { Authorization: `Bearer ${authToken}` } }); @@ -121,6 +149,50 @@ async function replayOutboxIfAny(): Promise<void> { const API_URL = import.meta.env.VITE_MAIN_API; +// Per-device record of which profiles have been confirmed to hold server-side +// data on a prior load. Used by loadProfileData to detect the "server returned +// empty for a profile that previously had data" scenario (corrupted profile +// file, transient backend bug, partial response) and preserve local state +// instead of wiping it. NOT synced — prefixed to fall outside the syncable +// allowlist and not in PROFILE_LOAD_PRESERVED_KEYS removal path. +const KNOWN_PROFILE_DATA_KEY = '__movix_known_profile_data'; + +interface KnownProfileDataMap { + [profileId: string]: number; +} + +function readKnownProfileData(): KnownProfileDataMap { + try { + const raw = localStorage.getItem(KNOWN_PROFILE_DATA_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as KnownProfileDataMap; + } catch { + return {}; + } +} + +function markProfileHasData(profileId: string) { + try { + const current = readKnownProfileData(); + current[profileId] = Date.now(); + localStorage.setItem(KNOWN_PROFILE_DATA_KEY, JSON.stringify(current)); + } catch { /* noop — markers are best-effort */ } +} + +function hasLocalSyncableContent(): boolean { + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index); + if (!isSyncableStorageKey(key)) continue; + const value = localStorage.getItem(key); + if (typeof value !== 'string') continue; + if (value.trim() === '' || value === '[]' || value === '{}') continue; + return true; + } + return false; +} + const ProfileContext = createContext<ProfileContextType | undefined>(undefined); interface ProfileProviderProps { @@ -133,6 +205,12 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children }) => const [isLoading, setIsLoading] = useState(false); const location = useLocation(); + // Dedupes the two useEffects below (mount timer + auth_changed listener) + // that can both call loadProfiles within ~500ms of each other. Without + // this guard, two concurrent GET /api/profiles → loadProfileData chains + // race the wipe-and-restore against in-flight user writes. + const loadProfilesInFlightRef = useRef(false); + // Check if we're on a watch route - if so, disable profile data loading (but allow sync) const isWatchRoute = location.pathname.startsWith('/watch/'); @@ -160,6 +238,8 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children }) => // Load profiles from server const loadProfiles = async () => { + if (loadProfilesInFlightRef.current) return; + loadProfilesInFlightRef.current = true; try { setIsLoading(true); const authToken = localStorage.getItem('auth_token'); @@ -225,6 +305,7 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children }) => } catch (error) { console.error('Error loading profiles:', error); } finally { + loadProfilesInFlightRef.current = false; setIsLoading(false); // Libère la garde anti-sync (App.tsx l'init à true au boot pour bloquer // tout push pendant la fenêtre où des composants pourraient écrire des @@ -385,7 +466,31 @@ export const ProfileProvider: React.FC<ProfileProviderProps> = ({ children }) => ) ) as Record<string, string>; + const serverHasData = Object.keys(profileEntries).length > 0; + const known = readKnownProfileData(); + const profileWasKnownToHaveData = Object.prototype.hasOwnProperty.call(known, profileId); + + // Wipe guard: if the server returns an empty profile for a profile + // we previously confirmed had data on this device, AND we still have + // local syncable content, do NOT wipe. A transient backend error or + // corrupted profile file would otherwise silently destroy the user's + // local state. Preserve local; the next sync push will restore the + // server side from localStorage. + if (!serverHasData && profileWasKnownToHaveData && hasLocalSyncableContent()) { + console.warn( + `[sync] Server returned empty profile ${profileId} that previously held data — preserving local state` + ); + window.dispatchEvent(new CustomEvent('sync_storage_updated')); + refreshVipState(); + return; + } + applyProfileEntriesToLocalStorage(profileEntries); + + if (serverHasData) { + markProfileHasData(profileId); + } + window.dispatchEvent(new CustomEvent('sync_storage_updated')); refreshVipState(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c1247a..275f393 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -800,7 +800,7 @@ "loadingVoVostfr": "Loading VO/VOSTFR...", "selectSource": "Select a source", "noSources": "No sources available", - "loading": "Loading player...", + "loading": "Waking up the player…", "error": "Playback error", "tryAnotherSource": "Try another source", "quality": "Quality", @@ -1043,8 +1043,8 @@ "castUnavailable": "Casting unavailable", "castUnavailableNoDevices": "No receiver detected on your Wi-Fi network.", "castUnavailableUnsupportedBrowser": "Your browser doesn't support casting on this page.", - "castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.chat and reload the page.", - "castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.chat (it often blocks Google's Cast SDK).", + "castUnavailableSdkBlocked": "The Google Cast SDK couldn't load (likely blocked by an ad blocker or your ISP). Disable your blocker on movix.date and reload the page.", + "castUnavailableHelpChromecast": "Chromecast: use Chrome or Edge on the same Wi-Fi as the TV. If you have an ad blocker, disable it on movix.date (it often blocks Google's Cast SDK).", "castUnavailableHelpAirPlay": "AirPlay: open the page in Safari on iPhone, iPad or Mac.", "castUnavailableSeeHelp": "View the Chromecast guide", "cast": "Cast", @@ -1098,15 +1098,15 @@ "preparingNightflix": "Preparing Nightflix source...", "verifyingAccess": "Verifying access...", "unknownSeries": "Unknown series", - "loadingEpisode": "Loading episode...", + "loadingEpisode": "Fetching your episode… our snail's on the case.", "animeLoadError": "Unable to load anime details", "noAnimeSource": "No anime source found for this anime", "animeDataError": "Error loading anime data", "languageFrench": "French", - "loadingSources": "Loading sources...", - "finalizingSearch": "Finalizing search...", - "optimizingConnection": "Optimizing connection...", - "pleaseWait": "Please wait.", + "loadingSources": "Rounding up the best streams for you…", + "finalizingSearch": "Almost there — tidying up the results…", + "optimizingConnection": "Greasing the pipes for a smooth stream…", + "pleaseWait": "Good things buffer slowly… (unless the server dozed off again).", "movieNotFoundDesc": "We couldn't find this movie in our French sources. You can try VO/VOSTFR players, but note: there may be no subtitles or the player may not work correctly.", "vostfrWarning": "VO/VOSTFR players may sometimes display the wrong movie or encounter issues. Don't try if the movie hasn't been released yet.", "chooseVostfrPlayer": "Choose a VO/VOSTFR player", @@ -1114,7 +1114,7 @@ "loadingVostfrSources": "Loading VO/VOSTFR sources...", "openInNewPage": "Open in a new page", "loadingTmdb": "Loading TMDB...", - "dataLoadError": "Error loading data", + "dataLoadError": "Oof — the server tripped over a cable. Give it another shot?", "episodeNotFoundDesc": "We couldn't find this episode in our French sources. You can try VO/VOSTFR players, but note: there may be no subtitles or the player may not work correctly.", "noDescriptionAvailable": "No description available.", "watching": "Watching", @@ -1147,6 +1147,7 @@ "multiPlayers": "🎬 Multi players ({{count}})", "fstreamPlayers": "🎬 FStream players ({{count}})", "lynxPlayers": "🦁 Lynx players ({{count}})", + "j1fPlayers": "🎬 1jour1film players ({{count}})", "viperPlayers": "🐍 Viper players ({{count}})", "voxPlayers": "🟢 Vox players ({{count}})", "watchParty": "Watch Party", @@ -1692,6 +1693,11 @@ "hideCommentsSectionDesc": "Gray out and hide the comments area on movie and series pages.", "hideHero": "Hide home hero banner", "hideHeroDesc": "Hides the auto-scrolling movie/series carousel at the top of the home page. Recommended on TVs or low-power devices to prevent freezes.", + "lowLatency": "Low latency streaming", + "lowLatencyDesc": "Sticks close to the live edge and trims buffering to cut latency. Best kept for fast connections.", + "lowLatencyWarning": "Requires a stable, fast connection. Because the player stays as close to live as possible, it has almost no buffer margin: if your network or the streaming server slows down or glitches, playback will stall too.", + "lowLatencyMovies": "Movies / series player", + "lowLatencyLiveTv": "Live TV player", "personalizedRecommendations": "Personalized recommendations", "personalizedRecommendationsDesc": "Show recommendations based on your viewing history.", "myId": "My ID", @@ -6030,7 +6036,7 @@ "blocked": "🚫 Access Blocked 🚫", "unauthorized": "Embed Not Authorized", "message": "This site cannot be displayed in an embed. Please visit our official site directly.", - "goToSite": "Go to movix.chat" + "goToSite": "Go to movix.date" }, "maintenance": { "title": "🔧 Our services are temporarily unavailable 🔧", @@ -6239,7 +6245,7 @@ "whyTitle": "Why a 12-word seed?", "whyBody": "Movix has no email or password: your identity is a <1>12-word phrase</1> (<2>BIP39</2> standard, the same as Bitcoin). No data collection, no password reset, no phishing emails. You own your account.", "stepsTitle": "How to create", - "step1": "Go to movix.chat → Create account.", + "step1": "Go to movix.date → Create account.", "step2": "Movix generates 12 random words — shown ONLY ONCE on screen.", "step3": "Save those 12 words in a password manager, an encrypted file, or on paper. This is your one-and-only key.", "step4": "Confirm by retyping the words. Your account is created, you're logged in.", @@ -6312,9 +6318,9 @@ "heroSub": "Add Movix to your home screen for quick access, no app store.", "introBody": "Movix is a <1>PWA</1> (Progressive Web App): you can install it like a real app from your browser. No Play Store or App Store needed. Works on iOS, Android, Windows, macOS, Linux.", "iosTitle": "iOS (Safari)", - "iosBody": "Open movix.chat in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.", + "iosBody": "Open movix.date in Safari. Tap the share icon (square with an up arrow) → 'Add to Home Screen' → Add. The Movix icon appears on your home screen, fullscreen when you open it.", "androidTitle": "Android (Chrome)", - "androidBody": "Open movix.chat in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.", + "androidBody": "Open movix.date in Chrome. An 'Install app' popup appears at the bottom. Otherwise, menu (⋮) → 'Install app' or 'Add to home screen'.", "desktopTitle": "Desktop (Chrome / Edge / Brave)", "desktopBody": "In the URL bar, look for the install icon (monitor with a down arrow, right of the URL). Click it to install Movix as a standalone app.", "advantagesTitle": "Upsides", @@ -6342,7 +6348,7 @@ "whyTitle": "Why open-source?", "whyBody": "Transparency first: you can verify what your browser runs and what the server stores. Your BIP39 account (12-word seed) is handled client-side — the server never sees your seed, and you can go read the code to prove it. Resilience next: if the main domain goes down or if Movix disappears, anyone can clone, build, and host. The project doesn't depend on any proprietary service to run. Trust last: many streaming platforms are closed and opaque — here everything is auditable, and every commit is public.", "selfhostTitle": "Self-host your own instance", - "selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.chat — your users, your database, your control.", + "selfhostBody": "You can clone the <1>repo</1> and run Movix locally or on your own server. The README and CLAUDE.md at the root document the stack, env vars (~100 for the main API), commands (npm run dev for the frontend on :3000, node server.js for the main API on :25565, python server.py for the embed proxies on :25569, etc.) and the service architecture. You'll need MySQL, Redis, and TMDB / Turnstile / Discord&Google OAuth keys depending on what you enable. The instance you spin up is fully independent of movix.date — your users, your database, your control.", "contribTitle": "Contribute", "contribBody": "PRs are welcome on the <1>GitHub repo</1>. Bugs, translations (especially English — French is the primary language, many EN strings still need polish), new sources, UX improvements, new tutorials to add to the hub: all helpful. For small fixes, PR directly. For big architecture changes, open an issue first so we can talk it through before you spend three days on it.", "limitsTitle": "What the license does NOT allow", @@ -6371,7 +6377,7 @@ "cause1Body": "French ISPs (Orange, SFR, Free, Bouygues) regularly block our domains and streaming sources by court order (<1>ARCOM</1>). Most of the time, this is the culprit.", "cause1Cta": "How to bypass", "cause2Title": "Overzealous adblocker", - "cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.chat and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.", + "cause2Body": "Some adblockers kill extraction scripts or block source domains. Try whitelisting movix.date and disable temporarily to test. We recommend Brave + uBlock Origin, the most stable combo on Movix.", "cause2CtaBrave": "Download Brave", "cause2CtaUblock": "Install uBlock Origin", "cause3Title": "Movix domain unreachable", @@ -6506,11 +6512,11 @@ "heroSub": "How Movix redirects you to an alive domain when the main one is blocked, and where to find the official list.", "introBody": "French ISPs (Orange, SFR, Free, Bouygues) regularly block Movix domains on <1>ARCOM</1> orders. To keep you connected through blocks, Movix installs a service worker in your browser that detects network failure and automatically redirects you to a still-alive mirror — no manual URL lookup needed.", "howTitle": "How the redirect works", - "howBody": "When you visit movix.chat, a service worker (a silent piece of code) installs in your browser. If movix.chat later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.", + "howBody": "When you visit movix.date, a service worker (a silent piece of code) installs in your browser. If movix.date later becomes unreachable (3-second timeout or DNS error), the service worker fetches the alive-mirror list from an external non-blocked source (rentry.co/movix, a pastebin service hosted outside France) and redirects you to the first available mirror. Transparent, instant, zero config.", "officialListTitle": "Where to see the official list", "officialListBody": "Two places publish the canonical alive-mirror list: <1>movix.online</1> (domain status with real-time up/down indicator) and <2>rentry.co/movix</2> (raw list, edited by the Movix team, always current). If you doubt a URL claiming to be Movix, check it against one of these — anything NOT on the list is a fake (sacrificial domains designed to fool anti-piracy bots, or phishing attempts).", "newDeviceTitle": "New phone / new browser — the redirect doesn't kick in", - "newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.chat, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.online or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.", + "newDeviceBody": "The service worker can only redirect you if it's been installed at least once before the block. On a brand-new device that has never visited movix.date, if the domain is already ISP-blocked, you'll hit an ISP error page with no redirect. Solution: open movix.online or rentry.co/movix from any browser (those pages are hosted outside the ISP block), grab an alive mirror, and visit it. Or join <1>Telegram @movix_site</1> where the list is always up to date.", "reliabilityTitle": "How often things change", "reliabilityBody": "A domain can go down overnight (new ARCOM order) or stay stable for months. The team rotates multiple domains in parallel — when one goes down, another takes over automatically. The rentry.co list is updated within 24h of any change.", "limitsTitle": "Limits", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1456b55..e2532ef 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -800,7 +800,7 @@ "loadingVoVostfr": "Chargement VO/VOSTFR...", "selectSource": "Sélectionner une source", "noSources": "Aucune source disponible", - "loading": "Chargement du lecteur...", + "loading": "On réveille les lecteurs…", "error": "Erreur de lecture", "tryAnotherSource": "Essayer une autre source", "quality": "Qualité", @@ -1043,8 +1043,8 @@ "castUnavailable": "Diffusion non disponible", "castUnavailableNoDevices": "Aucun récepteur détecté sur ton réseau Wi-Fi.", "castUnavailableUnsupportedBrowser": "Ton navigateur ne gère pas la diffusion sur cette page.", - "castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.chat et recharge la page.", - "castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.chat (il bloque souvent le SDK Cast de Google).", + "castUnavailableSdkBlocked": "Le SDK Google Cast n'a pas pu charger (probablement bloqué par un bloqueur de pubs ou ton FAI). Désactive ton bloqueur sur movix.date et recharge la page.", + "castUnavailableHelpChromecast": "Chromecast : utilise Chrome ou Edge sur le même Wi-Fi que la TV. Si tu as un bloqueur de pubs, désactive-le sur movix.date (il bloque souvent le SDK Cast de Google).", "castUnavailableHelpAirPlay": "AirPlay : ouvre la page dans Safari sur iPhone, iPad ou Mac.", "castUnavailableSeeHelp": "Voir le guide Chromecast", "cast": "Caster", @@ -1098,15 +1098,15 @@ "preparingNightflix": "Préparation de la source Nightflix...", "verifyingAccess": "Vérification des accès...", "unknownSeries": "Série inconnue", - "loadingEpisode": "Chargement de l'épisode...", + "loadingEpisode": "On va chercher ton épisode… l'escargot s'en occupe.", "animeLoadError": "Impossible de charger les détails de l'anime", "noAnimeSource": "Aucune source anime trouvée pour cet anime", "animeDataError": "Erreur lors du chargement des données anime", "languageFrench": "Français", - "loadingSources": "Chargement des sources...", - "finalizingSearch": "Finalisation de la recherche...", - "optimizingConnection": "Optimisation de la connexion...", - "pleaseWait": "Veuillez patienter.", + "loadingSources": "On rassemble les meilleurs flux pour toi…", + "finalizingSearch": "Presque fini, on range les résultats…", + "optimizingConnection": "On graisse les tuyaux pour un flux bien fluide…", + "pleaseWait": "Les bonnes choses se chargent lentement… (sauf si le serveur s'est rendormi).", "movieNotFoundDesc": "Nous n'avons pas trouvé ce film dans nos sources françaises. Vous pouvez essayer les lecteurs VO/VOSTFR, mais attention : il pourrait ne pas y avoir de sous-titres ou le lecteur pourrait ne pas fonctionner correctement.", "vostfrWarning": "Les lecteurs VO/VOSTFR peuvent parfois afficher le mauvais film ou rencontrer des problèmes. N'essayez pas si le film n'est pas encore sorti.", "chooseVostfrPlayer": "Choisir un lecteur VO/VOSTFR", @@ -1114,7 +1114,7 @@ "loadingVostfrSources": "Chargement des sources VO/VOSTFR...", "openInNewPage": "Ouvrir dans une nouvelle page", "loadingTmdb": "Chargement TMDB...", - "dataLoadError": "Erreur lors du chargement des données", + "dataLoadError": "Aïe — le serveur s'est pris les pieds dans un câble. On réessaie ?", "episodeNotFoundDesc": "Nous n'avons pas trouvé cet épisode dans nos sources françaises. Vous pouvez essayer les lecteurs VO/VOSTFR, mais attention : il pourrait ne pas y avoir de sous-titres ou le lecteur pourrait ne pas fonctionner correctement.", "noDescriptionAvailable": "Pas de description disponible.", "watching": "En cours", @@ -1147,6 +1147,7 @@ "multiPlayers": "🎬 Lecteurs Multi ({{count}})", "fstreamPlayers": "🎬 Lecteurs FStream ({{count}})", "lynxPlayers": "🦁 Lecteurs Lynx ({{count}})", + "j1fPlayers": "🎬 Lecteurs 1jour1film ({{count}})", "viperPlayers": "🐍 Lecteurs Viper ({{count}})", "voxPlayers": "🟢 Lecteurs Vox ({{count}})", "watchParty": "Watch Party", @@ -1692,6 +1693,11 @@ "hideCommentsSectionDesc": "Grise et masque la zone des commentaires sur les pages film et série.", "hideHero": "Masquer le bandeau d'accueil", "hideHeroDesc": "Cache le carrousel de films/séries en haut de la page d'accueil. Recommandé sur TV ou appareils peu puissants pour éviter les freezes.", + "lowLatency": "Streaming basse latence", + "lowLatencyDesc": "Colle au direct et réduit la mise en mémoire tampon pour diminuer la latence. À réserver aux bonnes connexions.", + "lowLatencyWarning": "Nécessite une connexion stable et rapide. Comme le lecteur suit le direct au plus près, il n'a quasiment aucune marge : si votre réseau ou le serveur de streaming ralentit ou bug, la lecture calera aussi.", + "lowLatencyMovies": "Lecteur films / séries", + "lowLatencyLiveTv": "Lecteur chaînes TV", "personalizedRecommendations": "Recommandations personnalisées", "personalizedRecommendationsDesc": "Afficher des recommandations basées sur votre historique de visionnage.", "myId": "Mon identifiant", @@ -6030,7 +6036,7 @@ "blocked": "🚫 Accès bloqué 🚫", "unauthorized": "Embed non autorisé", "message": "Ce site ne peut pas être affiché dans un embed. Veuillez visiter directement notre site officiel.", - "goToSite": "Aller sur movix.chat" + "goToSite": "Aller sur movix.date" }, "maintenance": { "title": "🔧 Nos services sont momentanément indisponibles 🔧", @@ -6239,7 +6245,7 @@ "whyTitle": "Pourquoi une seed de 12 mots ?", "whyBody": "Movix n'a ni mail ni mot de passe : ton identité est une <1>phrase de 12 mots</1> (standard <2>BIP39</2>, celui de Bitcoin). Pas de collecte de données, pas de reset de mot de passe, pas d'email de phishing. Tu es maître de ton compte.", "stepsTitle": "Comment créer", - "step1": "Va sur movix.chat → Créer un compte.", + "step1": "Va sur movix.date → Créer un compte.", "step2": "Movix génère 12 mots aléatoires — affichés UNE SEULE FOIS à l'écran.", "step3": "Sauvegarde ces 12 mots dans un gestionnaire de mots de passe, un fichier chiffré, ou sur papier. C'est ta clé unique.", "step4": "Confirme en retapant les mots. Ton compte est créé, tu es connecté.", @@ -6312,9 +6318,9 @@ "heroSub": "Ajouter Movix à ton écran d'accueil pour un accès rapide, sans app store.", "introBody": "Movix est une <1>PWA</1> (Progressive Web App) : tu peux l'installer comme une vraie app depuis ton navigateur. Pas besoin du Play Store ou App Store. Marche sur iOS, Android, Windows, macOS, Linux.", "iosTitle": "iOS (Safari)", - "iosBody": "Ouvre movix.chat dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.", + "iosBody": "Ouvre movix.date dans Safari. Touche l'icône de partage (carré avec flèche vers le haut) → « Sur l'écran d'accueil » → Ajouter. L'icône Movix apparaît sur ton écran d'accueil, plein écran quand tu l'ouvres.", "androidTitle": "Android (Chrome)", - "androidBody": "Ouvre movix.chat dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».", + "androidBody": "Ouvre movix.date dans Chrome. Un popup « Installer l'app » apparaît en bas. Sinon, menu (⋮) → « Installer l'application » ou « Ajouter à l'écran d'accueil ».", "desktopTitle": "Desktop (Chrome / Edge / Brave)", "desktopBody": "Dans la barre d'URL, cherche l'icône d'installation (monitor avec flèche vers le bas, à droite de l'URL). Clique dessus pour installer Movix comme une vraie app standalone.", "advantagesTitle": "Avantages", @@ -6342,7 +6348,7 @@ "whyTitle": "Pourquoi open-source ?", "whyBody": "Transparence d'abord : tu peux vérifier ce que ton navigateur exécute et ce que le serveur stocke. Ton compte BIP39 (12 mots seed) est géré côté client — le serveur ne connaît pas ta seed, et tu peux aller lire le code pour le prouver. Résilience ensuite : si le domaine principal tombe ou si Movix disparaît, n'importe qui peut cloner, compiler et héberger. Le projet ne dépend d'aucun service propriétaire pour tourner. Confiance enfin : beaucoup de plateformes streaming sont fermées et opaques — ici tout est auditable, et chaque commit est public.", "selfhostTitle": "Self-hoster sa propre instance", - "selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.chat — tes utilisateurs, ta base, ton contrôle.", + "selfhostBody": "Tu peux cloner le <1>repo</1> et faire tourner Movix localement ou sur ton propre serveur. Le README et CLAUDE.md à la racine documentent la stack, les variables d'env (~100 pour le main API), les commandes (npm run dev pour le front sur :3000, node server.js pour le main API sur :25565, python server.py pour les proxies embed sur :25569, etc.) et l'architecture des services. Prévoir MySQL, Redis, et des clés TMDB / Turnstile / OAuth Discord&Google selon ce que tu actives. L'instance que tu montes est totalement indépendante de movix.date — tes utilisateurs, ta base, ton contrôle.", "contribTitle": "Contribuer", "contribBody": "Les PRs sont les bienvenues sur le <1>repo GitHub</1>. Bugs, traductions (surtout anglais — le français est la langue principale, beaucoup de strings EN sont encore à polir), nouvelles sources, améliorations UX, tuto à ajouter dans le hub : tout est utile. Pour les petits fix, PR direct. Pour les gros changements d'architecture, ouvre une issue d'abord pour qu'on en parle avant que tu passes trois jours dessus.", "limitsTitle": "Ce que la licence NE permet PAS", @@ -6371,7 +6377,7 @@ "cause1Body": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement nos domaines et nos sources de streaming sur décision <1>ARCOM</1>. C'est le cas 8 fois sur 10.", "cause1Cta": "Comment contourner", "cause2Title": "Adblocker trop agressif", - "cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.chat et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.", + "cause2Body": "Certains adblockers coupent les scripts d'extraction ou bloquent des domaines de sources. Essaie de whitelist movix.date et désactive temporairement pour tester. On recommande Brave + uBlock Origin, le combo le plus stable sur Movix.", "cause2CtaBrave": "Télécharger Brave", "cause2CtaUblock": "Installer uBlock Origin", "cause3Title": "Domaine Movix injoignable", @@ -6506,11 +6512,11 @@ "heroSub": "Comment Movix te redirige vers un domaine alive quand le principal est bloqué, et où trouver la liste officielle.", "introBody": "Les FAI français (Orange, SFR, Free, Bouygues) bloquent régulièrement les domaines Movix sur décision <1>ARCOM</1>. Pour éviter que tu perdes l'accès à chaque blocage, Movix installe un service worker dans ton navigateur qui détecte l'échec réseau et te redirige automatiquement vers un miroir encore alive — sans que tu aies besoin de connaître les URLs à la main.", "howTitle": "Comment la redirection marche", - "howBody": "Quand tu visites movix.chat, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.chat devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.", + "howBody": "Quand tu visites movix.date, un service worker (un petit bout de code silencieux) se met en place dans ton navigateur. Si un jour movix.date devient injoignable (timeout 3 secondes ou erreur DNS), le service worker va chercher la liste des miroirs alive sur une source externe non-bloquée (rentry.co/movix, un service de pastebin hébergé hors de France), et te redirige vers le premier miroir disponible. Transparent, instantané, sans config.", "officialListTitle": "Où voir la liste officielle", "officialListBody": "Deux endroits publient la liste canonique des miroirs alive : <1>movix.online</1> (statut des domaines avec indicateur up/down en temps réel) et <2>rentry.co/movix</2> (liste brute, éditée par l'équipe Movix, toujours à jour). Si tu doutes d'une URL qui prétend être Movix, vérifie-la sur un de ces deux endroits — tout ce qui n'y figure PAS est un faux (domaines sacrificiels pour tromper les bots anti-piratage ou tentatives de phishing).", "newDeviceTitle": "Nouveau téléphone / nouveau navigateur — la redirection marche pas", - "newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.chat, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.online ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.", + "newDeviceBody": "Le service worker ne peut te rediriger QUE s'il a été installé au moins une fois avant le blocage. Sur un appareil neuf qui n'a jamais visité movix.date, si le domaine est déjà bloqué par ton FAI, tu arriveras sur une page d'erreur du FAI sans aucune redirection. Solution : ouvre movix.online ou rentry.co/movix depuis n'importe quel navigateur (ces pages sont hébergées hors bloc FAI), récupère un miroir alive, et visite-le. Ou rejoins <1>Telegram @movix_site</1> où la liste est toujours à jour.", "reliabilityTitle": "Fréquence des changements", "reliabilityBody": "Un domaine peut tomber du jour au lendemain (nouvelle décision ARCOM) ou rester stable des mois. L'équipe tourne plusieurs domaines en parallèle — quand un tombe, un autre prend le relais automatiquement. La liste sur rentry.co est mise à jour dans la journée qui suit un changement.", "limitsTitle": "Limites", diff --git a/src/main.tsx b/src/main.tsx index 6ce7cc5..43db452 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -51,7 +51,7 @@ const MOVIX_CONSOLE_SAFETY_WARNING_LINES: MovixConsoleWarningLine[] = [ ].join('; '), }, { - text: 'Coller quelque chose ici peut donner a un attaquant acces a ton compte Movix.', + text: 'Coller quelque chose ici peut donner à un attaquant accès à ton compte Movix.', style: [ 'font-size: 22px', 'font-weight: 900', @@ -78,6 +78,18 @@ const MOVIX_CONSOLE_SAFETY_WARNING_LINES: MovixConsoleWarningLine[] = [ 'box-shadow: 0 0 0 3px #22c55e inset' ].join('; '), }, + { + text: "Fuck les sites qui bloquent le devtool et qui mettent des screamers quand on l'ouvre — et merci à LibreWolf pour bypass les détections.", + style: [ + 'font-size: 10px', + 'font-weight: 600', + 'line-height: 1.4', + 'color: #94a3b8', + 'background: transparent', + 'padding: 2px 4px', + 'text-shadow: none' + ].join('; '), + }, ]; const emitMovixConsoleSafetyWarning = () => { @@ -126,6 +138,62 @@ startMovixConsoleSafetyWarning(); registerBlockDetection(axios) registerBlockDetection(api) +// --------------------------------------------------------------------------- +// Resilience patches — run before the app mounts. +// --------------------------------------------------------------------------- + +// 1. Stale dynamic-import chunks after a deploy. Vite dispatches +// `vite:preloadError` when a chunk preload (the helper behind every lazy +// route and hover-prefetch) fails — e.g. the previous build's hashed chunk +// now 404s on the production domain. +// +// We must NOT call `event.preventDefault()` here. Vite's preload helper only +// rethrows the failure when the event is left un-prevented: +// return baseModule().catch(handlePreloadError) +// function handlePreloadError(err){ …dispatch…; if (!e.defaultPrevented) throw err } +// Calling preventDefault() makes the failed `import()` RESOLVE TO `undefined` +// instead of rejecting. React.lazy then reads `.default` off that `undefined` +// and crashes the whole app with "Cannot read properties of undefined +// (reading 'default')" — the exact opposite of recovery, and it bypasses every +// handler below (a resolved promise has no error to catch). +// +// So we let the rejection propagate to where the import was triggered: +// lazyWithRetry retries + does a budgeted reload for interactive route loads, +// PrefetchLink's `.catch` swallows background prefetches, and ErrorBoundary +// shows a soft "new version available" screen once the reload budget is spent. +// (lazyWithRetry also defensively treats a nullish resolved module as a chunk +// failure, so re-introducing preventDefault here can never silently crash again.) + +// 2. Guard against the React + browser-translation (Google Translate, Edge, +// Samsung Internet, …) crash: the translation engine swaps text nodes +// underneath React, so React's reconciler later calls removeChild / +// insertBefore on a node whose parent has since changed → +// "Failed to execute 'removeChild' on 'Node': The node to be removed is not +// a child of this node." Making these DOM ops no-ops when the parent no +// longer matches lets React recover instead of tearing down the whole tree. +// https://github.com/facebook/react/issues/11538#issuecomment-417504600 +if (typeof Node === 'function' && Node.prototype) { + const originalRemoveChild = Node.prototype.removeChild; + Node.prototype.removeChild = function <T extends Node>(this: Node, child: T): T { + if (child.parentNode !== this) { + return child; + } + return originalRemoveChild.call(this, child) as T; + }; + + const originalInsertBefore = Node.prototype.insertBefore; + Node.prototype.insertBefore = function <T extends Node>( + this: Node, + newNode: T, + referenceNode: Node | null + ): T { + if (referenceNode && referenceNode.parentNode !== this) { + return newNode; + } + return originalInsertBefore.call(this, newNode, referenceNode) as T; + }; +} + createRoot(document.getElementById('root')!).render( <StrictMode> <ErrorBoundary> diff --git a/src/pages/DownloadPage.tsx b/src/pages/DownloadPage.tsx index 89ffd8b..c1481e0 100644 --- a/src/pages/DownloadPage.tsx +++ b/src/pages/DownloadPage.tsx @@ -7,7 +7,6 @@ import AdFreePlayerAds from '../components/AdFreePlayerAds'; import { toast } from 'sonner'; import { getTmdbLanguage } from '../i18n'; import { motion, AnimatePresence } from 'framer-motion'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; const MAIN_API = import.meta.env.VITE_MAIN_API; const TMDB_API_KEY = import.meta.env.VITE_TMDB_API_KEY || ''; @@ -514,39 +513,16 @@ const EpisodeDropdown: React.FC<{ ); }; -const RateLimitInfoButton: React.FC<{ error: string | null }> = ({ error }) => { +const SourceBanner: React.FC = () => { const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - const ratelimitedTemplate = t('download.rateLimited', { retryAt: '__X__' }); - const ratelimitedPrefix = ratelimitedTemplate.split('__X__')[0]; - const isRateLimited = !!error && error.startsWith(ratelimitedPrefix); - - if (!isRateLimited) return null; - return ( - <> - <button - type="button" - onClick={() => setOpen(true)} - className="self-start text-xs sm:text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2 transition-colors mt-1" - > - {t('download.rateLimitedLearnMore')} - </button> - <Dialog open={open} onOpenChange={setOpen}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>{t('download.rateLimitInfoTitle')}</DialogTitle> - <DialogDescription className="text-sm sm:text-base text-gray-300 leading-relaxed pt-2"> - {t('download.rateLimitInfoBody')} - </DialogDescription> - </DialogHeader> - </DialogContent> - </Dialog> - </> + <div className="self-stretch mt-2 mb-1 border-l-2 border-gray-500/40 pl-3 text-xs sm:text-sm italic text-gray-400 leading-relaxed"> + {t('download.sourceBannerBody')} + </div> ); }; + // Popup de sélection de liens (similaire à AvatarSelector) const LinkSelector: React.FC<{ isOpen: boolean; @@ -683,12 +659,11 @@ const LinkSelector: React.FC<{ )} </div> ) : error ? ( - <div className="flex flex-col text-red-400 text-sm sm:text-base"> + <div className="flex items-center text-red-400 text-sm sm:text-base"> <div className="flex items-center"> <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 mr-2 flex-shrink-0" /> <span className="break-words">{error}</span> </div> - <RateLimitInfoButton error={error} /> </div> ) : decodedLink ? ( <div className="space-y-3 sm:space-y-4"> @@ -796,6 +771,8 @@ const LinkSelector: React.FC<{ )} </div> ) : null} + + <SourceBanner /> </div> )} </div> @@ -1672,6 +1649,26 @@ const DownloadPage: React.FC = () => { </div> ) : null} + {/* Loadix.fun DDL ad */} + <a + href="https://loadix.fun" + target="_blank" + rel="noopener noreferrer" + className="mt-4 sm:mt-6 flex items-center gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-800/50 rounded-xl border border-gray-700/50 hover:border-red-500/50 transition-colors cursor-pointer group" + > + <div className="flex items-center justify-center w-10 h-10 sm:w-12 sm:h-12 rounded-lg bg-red-600/10 border border-red-500/20 shrink-0"> + <Download className="w-5 h-5 sm:w-6 sm:h-6 text-red-500" /> + </div> + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2 mb-0.5"> + <span className="text-sm sm:text-base font-semibold text-white group-hover:text-red-400 transition-colors">{t('download.adTitle')}</span> + <span className="px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded bg-red-500/10 text-red-400 border border-red-500/20">{t('download.adTag')}</span> + </div> + <p className="text-xs sm:text-sm text-gray-400 leading-relaxed truncate">{t('download.adDescription')}</p> + </div> + <ExternalLink className="w-4 h-4 text-gray-500 group-hover:text-red-400 transition-colors shrink-0" /> + </a> + {/* Bouton pour récupérer les liens */} <div className="mt-4 sm:mt-6"> <button @@ -1691,18 +1688,7 @@ const DownloadPage: React.FC = () => { </button> </div> - {/* Note de désengagement */} - <div className="mt-4 sm:mt-6 p-3 sm:p-4 bg-yellow-900/20 border border-yellow-500/50 rounded-xl"> - <div className="flex items-start gap-2 sm:gap-3"> - <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-400 mt-0.5 flex-shrink-0" /> - <div className="text-xs sm:text-sm text-yellow-200"> - <p className="font-medium mb-1">{t('download.betaWarning')}</p> - <p className="leading-relaxed"> - {t('download.betaWarningText')} - </p> - </div> - </div> - </div> + <SourceBanner /> {/* Affichage des liens */} {downloadLinks.length > 0 && ( @@ -1792,7 +1778,7 @@ const DownloadPage: React.FC = () => { <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" /> <span className="break-words">{error}</span> </div> - <RateLimitInfoButton error={error} /> + <SourceBanner /> </div> </div> )} diff --git a/src/pages/MovieDetails.tsx b/src/pages/MovieDetails.tsx index f5ff79f..77ad967 100644 --- a/src/pages/MovieDetails.tsx +++ b/src/pages/MovieDetails.tsx @@ -3239,7 +3239,7 @@ const MovieDetails = (): JSX.Element => { <h3 className="text-lg font-semibold mb-2">{t('details.ratingLabel')}</h3> <div className="flex items-center gap-2"> <Star className="w-5 h-5 text-yellow-500 fill-yellow-500" /> - <p className="text-gray-300 text-lg font-bold">{movie.vote_average.toFixed(1)}<span className="text-sm font-normal text-gray-400">/10</span></p> + <p className="text-gray-300 text-lg font-bold">{movie.vote_average != null ? movie.vote_average.toFixed(1) : 'N/A'}<span className="text-sm font-normal text-gray-400">/10</span></p> </div> </div> diff --git a/src/pages/OAuthAuthorizePage.tsx b/src/pages/OAuthAuthorizePage.tsx index f53323a..f7e1a24 100644 --- a/src/pages/OAuthAuthorizePage.tsx +++ b/src/pages/OAuthAuthorizePage.tsx @@ -8,6 +8,7 @@ import { Button } from '../components/ui/button'; import { discordAuth } from '../services/discordAuth'; import { googleAuth } from '../services/googleAuth'; import { broadcastAuthChange, clearPendingAuthAction, clearStoredAuthSession, setPendingAuthAuthorize } from '../utils/accountAuth'; +import { useProfile } from '../context/ProfileContext'; const API_URL = import.meta.env.VITE_MAIN_API; const DEFAULT_AVATAR = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp'; @@ -201,27 +202,23 @@ const FAKE_NOT_REQUESTED_FALLBACK: string[] = [ const FakePermissionsTeasingCard: React.FC = () => { const { t } = useTranslation(); - // Charge la liste traduite via returnObjects (i18next), avec fallback en - // dur si la clé n'existe pas (ex : bundle de traduction incomplet). const fakeListRaw = t('oauthAuthorize.fakeNotRequested', { returnObjects: true }); const fakeList = Array.isArray(fakeListRaw) && fakeListRaw.length > 0 ? (fakeListRaw as string[]) : FAKE_NOT_REQUESTED_FALLBACK; - // useMemo sur la longueur — évite que les changements de référence du - // tableau retourné par t() repick une nouvelle ligne à chaque re-render. const randomIdx = useMemo( () => Math.floor(Math.random() * fakeList.length), [fakeList.length], ); const randomFake = fakeList[randomIdx] ?? ''; return ( - <div className="rounded-xl border border-white/10 bg-black/20 px-3 py-2.5"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400"> + <div> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> 🚫 {t('oauthAuthorize.fakeNotRequestedTitle', 'Ce que Movix ne demande pas')} </p> - <div className="mt-2 flex items-center gap-2.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-2"> - <span className="shrink-0 text-red-400/70 leading-none">✗</span> - <p className="min-w-0 flex-1 text-sm text-gray-300 leading-snug">{randomFake}</p> + <div className="mt-2 flex items-start gap-2.5"> + <span className="mt-0.5 shrink-0 text-red-400/70 leading-none">✗</span> + <p className="min-w-0 flex-1 text-sm italic text-gray-300 leading-snug">{randomFake}</p> </div> </div> ); @@ -243,18 +240,18 @@ interface PermissionsAccordionProps { const PermissionsAccordion: React.FC<PermissionsAccordionProps> = ({ requestedScopes, label }) => { const [isOpen, setIsOpen] = useState(false); return ( - <div className="rounded-xl border border-white/10 bg-black/20"> + <div> <button type="button" onClick={() => setIsOpen((v) => !v)} aria-expanded={isOpen} - className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left transition-colors hover:bg-white/[0.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40 rounded-xl" + className="flex w-full items-center justify-between gap-2 rounded-lg py-1 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/40" > <div className="flex items-center gap-2"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400"> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> {label} </p> - <span className="rounded-md border border-white/10 bg-white/5 px-1.5 py-0.5 text-[0.6rem] font-medium text-gray-300"> + <span className="rounded-full bg-white/10 px-2 py-0.5 text-[0.6rem] font-semibold text-gray-200"> {requestedScopes.length} </span> </div> @@ -279,17 +276,15 @@ const PermissionsAccordion: React.FC<PermissionsAccordionProps> = ({ requestedSc }} className="overflow-hidden" > - <div className="space-y-1.5 px-3 pb-3 pt-1"> + <div className="mt-2 grid gap-1.5 sm:grid-cols-2"> {requestedScopes.map((scopeItem) => { const Icon = scopeItem.icon; return ( <div key={scopeItem.scope} - className="flex items-center gap-2.5 rounded-lg border border-white/[0.06] bg-white/[0.02] px-2.5 py-2" + className="flex min-w-0 items-center gap-2.5 rounded-lg bg-white/[0.04] px-2.5 py-2" > - <div className="rounded-lg border border-white/10 bg-white/5 p-1.5 text-red-200"> - <Icon className="h-3.5 w-3.5" /> - </div> + <Icon className="h-3.5 w-3.5 shrink-0 text-red-300" /> <p className="min-w-0 truncate text-sm font-medium text-white"> {scopeItem.title} </p> @@ -315,6 +310,9 @@ const OAuthAuthorizePage: React.FC = () => { const [error, setError] = useState<string | null>(null); const authToken = localStorage.getItem('auth_token'); const accountIdentity = useMemo(() => getStoredIdentity(), []); + const { currentProfile } = useProfile(); + const displayName = currentProfile?.name || accountIdentity?.username || 'Movix'; + const displayAvatar = currentProfile?.avatar || accountIdentity?.avatar || DEFAULT_AVATAR; const returnTo = `${location.pathname}${location.search}`; const isAlreadyConnected = Boolean(authToken); @@ -505,48 +503,42 @@ const OAuthAuthorizePage: React.FC = () => { return ( <div className="min-h-[100svh] overflow-x-hidden bg-[radial-gradient(circle_at_top,_rgba(239,68,68,0.18),_transparent_35%),linear-gradient(180deg,_#050505,_#0b0b10_45%,_#111827)] text-white"> - <div className="mx-auto flex min-h-[100svh] w-full max-w-6xl items-center justify-center overflow-y-auto px-3 py-3 sm:px-4 sm:py-4 lg:overflow-hidden"> + <div className="mx-auto w-full max-w-5xl px-4 py-6 sm:px-6 sm:py-10"> <motion.div initial={{ opacity: 0, y: 18 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.45, ease: 'easeOut' }} - className="grid w-full gap-4 lg:grid-cols-[1.25fr_0.75fr]" + className="grid gap-5 lg:grid-cols-[1.5fr_1fr] lg:items-start" > - <section className="rounded-3xl border border-white/10 bg-white/5 p-4 shadow-2xl backdrop-blur-md sm:p-5"> - <div className="mb-3 flex items-start justify-between gap-3"> - <div className="space-y-1.5"> - <div className="inline-flex items-center gap-2.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1.5 shadow-lg shadow-black/20"> - <span className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border border-white/10 bg-black/30"> - <img src={MOVIX_LOGO_SRC} alt="Movix" className="h-5 w-5 object-contain" /> - </span> - <span className="text-left"> - <span className="block text-[0.6rem] font-semibold uppercase tracking-[0.35em] text-red-200/80"> - Movix - </span> - <span className="block text-xs font-medium text-white">OAuth</span> - </span> - </div> - <p className="text-[0.65rem] font-semibold uppercase tracking-[0.35em] text-red-300/80"> - {t('oauthAuthorize.eyebrow')} - </p> - <h1 className="text-xl font-semibold tracking-tight text-white sm:text-2xl"> - {t('oauthAuthorize.title')} - </h1> - <p className="max-w-2xl text-sm leading-5 text-gray-300"> - {t('oauthAuthorize.subtitle')} - </p> + <section className="min-w-0 rounded-3xl border border-white/10 bg-white/[0.04] p-5 shadow-xl backdrop-blur sm:p-7"> + <div className="flex items-start justify-between gap-3"> + <div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5"> + <img src={MOVIX_LOGO_SRC} alt="Movix" className="h-4 w-4 object-contain" /> + <span className="text-xs font-medium text-white">Movix OAuth</span> </div> - <div className="hidden rounded-2xl border border-red-500/30 bg-red-500/10 p-2 text-red-200 lg:block"> - <ShieldCheck className="h-6 w-6" /> + <div className="rounded-xl border border-red-500/30 bg-red-500/10 p-2 text-red-200"> + <ShieldCheck className="h-5 w-5" /> </div> </div> + <div className="mt-5 space-y-2"> + <p className="text-[0.65rem] font-semibold uppercase tracking-[0.3em] text-red-300/80"> + {t('oauthAuthorize.eyebrow')} + </p> + <h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl"> + {t('oauthAuthorize.title')} + </h1> + <p className="text-sm leading-relaxed text-gray-300"> + {t('oauthAuthorize.subtitle')} + </p> + </div> + {isLoading ? ( - <div className="rounded-2xl border border-white/10 bg-black/20 p-5 text-sm text-gray-300"> + <p className="mt-6 text-sm text-gray-400"> {t('oauthAuthorize.loading')} - </div> + </p> ) : error ? ( - <div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100"> + <div className="mt-6 rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100"> <p className="font-medium">{t('oauthAuthorize.errorTitle')}</p> <p className="mt-2 text-red-100/80">{error}</p> {previewClient?.homepageUrl && ( @@ -566,104 +558,106 @@ const OAuthAuthorizePage: React.FC = () => { </Link> </div> ) : preview ? ( - <div className="space-y-3"> - <div className="rounded-2xl border border-white/10 bg-black/20 p-3"> - <div className="flex items-center gap-3"> - <div className="flex h-11 w-11 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/5"> - {(() => { - // iconUrl est servie par l'API (`/oauth-icons/...`) — préfixée par API_URL. - // logoUrl peut être absolue (legacy) → on l'utilise telle quelle si présente. - const iconSrc = preview.client.iconUrl - ? `${API_URL}${preview.client.iconUrl}` - : preview.client.logoUrl || null; - return iconSrc ? ( - <img src={iconSrc} alt={preview.client.clientName} className="h-full w-full object-cover" /> - ) : ( - <ShieldCheck className="h-5 w-5 text-red-300" /> - ); - })()} - </div> - <div className="min-w-0 flex-1"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400"> - {t('oauthAuthorize.appLabel')} + <div className="mt-6 space-y-5"> + <div className="flex items-center gap-4"> + <div className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-white/5"> + {(() => { + const iconSrc = preview.client.iconUrl + ? `${API_URL}${preview.client.iconUrl}` + : preview.client.logoUrl || null; + return iconSrc ? ( + <img src={iconSrc} alt={preview.client.clientName} className="h-full w-full object-cover" /> + ) : ( + <ShieldCheck className="h-5 w-5 text-red-300" /> + ); + })()} + </div> + <div className="min-w-0 flex-1"> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> + {t('oauthAuthorize.appLabel')} + </p> + <h2 className="truncate text-lg font-semibold text-white"> + {preview.client.clientName} + </h2> + {preview.client.description && ( + <p className="line-clamp-2 text-xs leading-snug text-gray-400"> + {preview.client.description} </p> - <h2 className="text-lg font-semibold text-white"> - {preview.client.clientName} - </h2> - {preview.client.description && ( - <p className="text-xs leading-4 text-gray-300"> - {preview.client.description} - </p> - )} - </div> - {preview.client.homepageUrl && ( - <a - href={preview.client.homepageUrl} - target="_blank" - rel="noreferrer" - className="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-2.5 py-1.5 text-xs text-white transition hover:bg-white/10" - > - {t('oauthAuthorize.visitSite')} - <ExternalLink className="h-3.5 w-3.5" /> - </a> )} </div> + {preview.client.homepageUrl && ( + <a + href={preview.client.homepageUrl} + target="_blank" + rel="noreferrer" + aria-label={t('oauthAuthorize.visitSite')} + className="hidden shrink-0 items-center gap-1.5 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-white transition hover:bg-white/10 sm:inline-flex" + > + {t('oauthAuthorize.visitSite')} + <ExternalLink className="h-3.5 w-3.5" /> + </a> + )} </div> - <div className="grid gap-2.5 sm:grid-cols-2"> - <div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400"> + <div className="h-px bg-white/[0.06]" /> + + <dl className="grid gap-4 text-sm sm:grid-cols-2"> + <div className="min-w-0"> + <dt className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> {t('oauthAuthorize.redirectLabel')} - </p> - <p className="mt-1 text-sm font-medium text-white"> + </dt> + <dd className="mt-1.5 truncate font-medium text-white"> {redirectHost || preview.request.redirectUri} - </p> + </dd> </div> - <div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2.5"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-gray-400"> + <div className="min-w-0"> + <dt className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> {t('oauthAuthorize.tokenLabel')} - </p> - <p className="mt-1 text-sm font-medium text-white"> + </dt> + <dd className="mt-1.5 font-medium text-white"> {formatTokenLifetime(preview.request.accessTokenExpiresInMs, t)} - </p> + </dd> </div> - </div> + </dl> + + <div className="h-px bg-white/[0.06]" /> <PermissionsAccordion requestedScopes={requestedScopes} label={t('oauthAuthorize.permissionsLabel')} /> <FakePermissionsTeasingCard /> - {authToken ? ( - <div className="rounded-xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2.5"> - <div className="flex items-center gap-3"> + <div className="space-y-4 border-t border-white/10 pt-5"> + <div className="flex min-w-0 items-center gap-3"> <img - src={accountIdentity?.avatar || DEFAULT_AVATAR} + src={displayAvatar} alt="" - className="h-10 w-10 shrink-0 rounded-full border border-white/10 object-cover" + className="h-10 w-10 shrink-0 rounded-full border border-emerald-400/30 object-cover" /> <div className="min-w-0 flex-1"> - <p className="text-[0.65rem] uppercase tracking-[0.25em] text-emerald-200/80">{t('oauthAuthorize.signedInAs')}</p> - <p className="truncate font-medium text-white">{accountIdentity?.username || t('oauthAuthorize.connectedAccount')}</p> - </div> - <div className="flex shrink-0 gap-2"> - <Button - variant="outline" - onClick={() => handleDecision(false)} - disabled={isSubmitting} - className="border-white/15 bg-transparent px-3 py-1.5 text-sm" - > - {t('oauthAuthorize.deny')} - </Button> - <Button - onClick={() => handleDecision(true)} - disabled={isSubmitting} - className="bg-red-600 px-4 py-1.5 text-sm hover:bg-red-700" - > - {isSubmitting ? t('oauthAuthorize.processing') : t('oauthAuthorize.approve')} - </Button> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-emerald-200/80"> + {t('oauthAuthorize.signedInAs')} + </p> + <p className="truncate font-medium text-white">{displayName}</p> </div> </div> + <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"> + <Button + variant="outline" + onClick={() => handleDecision(false)} + disabled={isSubmitting} + className="border-white/15 bg-transparent sm:w-auto" + > + {t('oauthAuthorize.deny')} + </Button> + <Button + onClick={() => handleDecision(true)} + disabled={isSubmitting} + className="bg-red-600 hover:bg-red-700 sm:w-auto sm:min-w-[10rem]" + > + {isSubmitting ? t('oauthAuthorize.processing') : t('oauthAuthorize.approve')} + </Button> + </div> </div> ) : ( <div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 p-4"> @@ -679,43 +673,41 @@ const OAuthAuthorizePage: React.FC = () => { ) : null} </section> - <aside className="rounded-3xl border border-white/10 bg-black/25 p-4 shadow-2xl backdrop-blur-md"> - <p className="text-[0.65rem] font-semibold uppercase tracking-[0.35em] text-gray-400"> + <aside className="min-w-0 rounded-3xl border border-white/10 bg-white/[0.02] p-5 shadow-xl backdrop-blur sm:p-6"> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.3em] text-gray-400"> {isAlreadyConnected ? t('oauthAuthorize.connectedAccount') : t('oauthAuthorize.connectMovix')} </p> {isAlreadyConnected ? ( - <div className="mt-2.5 rounded-2xl border border-emerald-400/20 bg-emerald-500/10 p-3.5"> - <div className="flex items-center gap-3"> + <> + <div className="mt-4 flex items-center gap-3"> <img - src={accountIdentity?.avatar || DEFAULT_AVATAR} - alt={accountIdentity?.username || 'Movix'} - className="h-10 w-10 rounded-full border border-white/10 object-cover" + src={displayAvatar} + alt={displayName} + className="h-12 w-12 shrink-0 rounded-full border border-emerald-400/30 object-cover" /> <div className="min-w-0"> - <p className="text-[0.65rem] uppercase tracking-[0.28em] text-emerald-200/80"> + <p className="text-[0.6rem] font-semibold uppercase tracking-[0.28em] text-emerald-200/80"> {t('oauthAuthorize.signedInAs')} </p> - <p className="truncate text-base font-semibold text-white"> - {accountIdentity?.username || t('oauthAuthorize.connectedAccount')} - </p> + <p className="truncate text-base font-semibold text-white">{displayName}</p> </div> </div> - <h2 className="mt-3 text-lg font-semibold text-white"> - {t('oauthAuthorize.notYouPrompt', { username: accountIdentity?.username || t('oauthAuthorize.connectedAccount') })} + <h2 className="mt-5 text-lg font-semibold text-white"> + {t('oauthAuthorize.notYouPrompt', { username: displayName })} </h2> - <p className="mt-1.5 text-sm leading-5 text-emerald-50/80"> + <p className="mt-1.5 text-sm leading-5 text-gray-300"> {t('oauthAuthorize.switchAccountDescription')} </p> <Button onClick={handleSwitchAccount} disabled={!preview} - className="mt-3 w-full justify-center bg-red-600 hover:bg-red-700" + className="mt-4 w-full justify-center bg-red-600 hover:bg-red-700" > {t('oauthAuthorize.switchAccountCta')} </Button> - </div> + </> ) : ( <> <h2 className="mt-3 text-xl font-semibold text-white"> @@ -760,11 +752,14 @@ const OAuthAuthorizePage: React.FC = () => { </> )} - <div className="mt-3 rounded-xl border border-white/10 bg-white/[0.03] p-3"> - <p className="text-sm font-medium text-white">{t('oauthAuthorize.securityTitle')}</p> - <p className="mt-1 text-xs leading-4 text-gray-400"> - {t('oauthAuthorize.securityDescription')} - </p> + <div className="mt-6 flex items-start gap-2.5 border-t border-white/10 pt-4"> + <ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300/80" /> + <div className="min-w-0"> + <p className="text-sm font-medium text-white">{t('oauthAuthorize.securityTitle')}</p> + <p className="mt-1 text-xs leading-4 text-gray-400"> + {t('oauthAuthorize.securityDescription')} + </p> + </div> </div> </aside> </motion.div> diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 59699b5..10bac68 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1641,21 +1641,16 @@ const Profile: React.FC = () => { }; const handleLogout = () => { - localStorage.removeItem('auth'); - localStorage.removeItem('discord_auth'); - localStorage.removeItem('discord_user'); - localStorage.removeItem('discord_token'); - localStorage.removeItem('google_auth'); - localStorage.removeItem('google_user'); - localStorage.removeItem('google_token'); - localStorage.removeItem('bip39_auth'); - localStorage.removeItem('auth_method'); - localStorage.removeItem('resolved_user_type'); - localStorage.removeItem('resolved_user_id'); - localStorage.removeItem('user_id'); - localStorage.removeItem('session_id'); - localStorage.removeItem('auth_token'); - localStorage.removeItem('selected_profile_id'); + // Use the centralized helpers so the auth-key list stays in sync with + // every other logout path (ProfileMenu, 401 interceptor, OAuth flows). + // The previous hand-rolled list drifted: it missed `requires_username_change` + // (in AUTH_KEYS) and removed `selected_profile_id` (intentionally NOT in + // AUTH_KEYS per the accountAuth.ts comment — preserved across logout so + // the user lands on the same profile after re-auth on mirror domains). + try { + clearStoredAuthSession(); + broadcastAuthChange(); + } catch { /* noop */ } window.location.href = '/'; }; @@ -2664,6 +2659,7 @@ const Profile: React.FC = () => { className="bg-gray-800/70 text-white px-4 py-2 rounded-xl w-full md:w-auto focus:ring-2 focus:ring-red-500 outline-none transition-colors border border-gray-700" autoFocus placeholder={t('profilePage.header.usernamePlaceholder')} + maxLength={32} /> <div className="flex gap-2 mt-3 sm:mt-0 w-full sm:w-auto"> <button diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 7532882..fd98fdb 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -7,7 +7,7 @@ import { ArrowLeft, Settings, Shield, Monitor, Smartphone, Tablet, Copy, X, Snowflake, Activity, Trash2, Crown, Volume2, Database, Key, Lock, Palette, Eye, Download, Upload, Globe, AlertTriangle, History, CalendarClock, FlaskConical, Link2, MessageCircle, BellOff, Sparkles, - Zap, RefreshCw, ChevronDown, ListOrdered, Gauge + Zap, RefreshCw, ChevronDown, ListOrdered, Gauge, Megaphone } from 'lucide-react'; import axios from 'axios'; import { useAuth } from '../context/AuthContext'; @@ -55,6 +55,18 @@ import { setRememberLastPlayer, subscribeToLastPlayerChanges, } from '../utils/lastPlayerPref'; +import { + getAdPopupMode, + setAdPopupMode, + subscribeToAdPopupModeChanges, + type AdPopupMode, +} from '../utils/adPopupMode'; +import { + isAdultAdsEnabled, + setAdultAdsEnabled, + subscribeToAdultAdsChanges, +} from '../utils/adAdultMode'; +import { isLowLatencyEnabled, setLowLatencyEnabled, type LowLatencyScope } from '../utils/lowLatencyPref'; import { BgColorPickerPanel } from '../components/Settings/BgColorPickerPanel'; import { useLightMode } from '../context/LightModeContext'; import { @@ -155,6 +167,7 @@ const SECTIONS = [ { id: 'accounts', labelKey: 'settings.sections.accounts', icon: Link2 }, { id: 'privacy', labelKey: 'settings.sections.privacy', icon: Shield }, { id: 'source-priority', labelKey: 'settings.sections.sourcePriority', icon: ListOrdered }, + { id: 'intermission', labelKey: 'settings.sections.adPopup', icon: Megaphone }, { id: 'extractions', labelKey: 'settings.sections.extractions', icon: Zap }, { id: 'data', labelKey: 'settings.sections.data', icon: Database }, ] as const; @@ -395,6 +408,23 @@ const SettingsPage: React.FC = () => { setRememberLastPlayer(next); }; + // Mode popup pub (normal / auto / click-anywhere). + const [adPopupMode, setAdPopupModeState] = useState<AdPopupMode>(() => getAdPopupMode()); + useEffect(() => subscribeToAdPopupModeChanges(setAdPopupModeState), []); + const handleAdPopupModeChange = (mode: AdPopupMode) => { + setAdPopupModeState(mode); + setAdPopupMode(mode); + }; + + // Toggle publicités +18. + const [adultAds, setAdultAdsState] = useState<boolean>(() => isAdultAdsEnabled()); + useEffect(() => subscribeToAdultAdsChanges(setAdultAdsState), []); + const handleAdultAdsToggle = () => { + const next = !adultAds; + setAdultAdsState(next); + setAdultAdsEnabled(next); + }; + const [bgMode, setBgMode] = useState<'combined' | 'static' | 'animated'>(() => { return (localStorage.getItem('settings_bg_mode') as 'combined' | 'static' | 'animated') || 'combined'; }); @@ -824,6 +854,13 @@ const SettingsPage: React.FC = () => { canManageLinks: response.data.account?.canManageLinks !== false, manageWithProvider: isLinkProvider(response.data.account?.manageWithProvider) ? response.data.account.manageWithProvider : null, }); + + // Si le serveur signale que le pseudo viole la policy → déclenche la + // modale bloquante (qui écoute `auth-changed`). + if (response.data.requiresUsernameChange) { + localStorage.setItem('requires_username_change', '1'); + window.dispatchEvent(new Event('auth-changed')); + } } } catch (error) { console.error('Error loading account links:', error); @@ -1067,6 +1104,17 @@ const SettingsPage: React.FC = () => { window.dispatchEvent(new CustomEvent('hero_visibility_changed')); }; + // Streaming basse latence (LL-HLS) — opt-in par lecteur. S'applique à la + // prochaine lecture (lu au montage du lecteur). Voir utils/lowLatencyPref.ts. + const [lowLatencyMovies, setLowLatencyMovies] = useState<boolean>(() => isLowLatencyEnabled('movies')); + const [lowLatencyLiveTv, setLowLatencyLiveTv] = useState<boolean>(() => isLowLatencyEnabled('livetv')); + const handleLowLatencyToggle = (scope: LowLatencyScope) => { + const next = !(scope === 'movies' ? lowLatencyMovies : lowLatencyLiveTv); + setLowLatencyEnabled(scope, next); + if (scope === 'movies') setLowLatencyMovies(next); + else setLowLatencyLiveTv(next); + }; + const handleRecommendationsToggle = () => { const newValue = !recommendationsDisabled; setRecommendationsDisabled(newValue); @@ -2213,6 +2261,40 @@ const SettingsPage: React.FC = () => { {renderToggle(heroHidden, handleHeroToggle)} </motion.div> + {/* Streaming basse latence (LL-HLS) — opt-in, un toggle par lecteur. */} + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.075 }} + className="p-4 bg-gray-800/30 rounded-xl border border-gray-700/40 mb-3" + > + <div className="flex items-center gap-2 mb-0.5"> + <Zap className="w-3.5 h-3.5 text-emerald-400" /> + <h4 className="font-medium text-white text-sm">{t('settings.lowLatency')}</h4> + </div> + <p className="text-xs text-gray-500 leading-relaxed"> + {t('settings.lowLatencyDesc')} + </p> + + <div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-500/20 bg-amber-500/10 p-2.5"> + <AlertTriangle className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" /> + <p className="text-[11px] text-amber-100/80 leading-relaxed"> + {t('settings.lowLatencyWarning')} + </p> + </div> + + <div className="mt-3 space-y-2"> + <div className="flex items-center justify-between p-3 bg-gray-800/30 rounded-lg border border-gray-700/40"> + <span className="font-medium text-white text-xs">{t('settings.lowLatencyMovies')}</span> + {renderToggle(lowLatencyMovies, () => handleLowLatencyToggle('movies'), 'green')} + </div> + <div className="flex items-center justify-between p-3 bg-gray-800/30 rounded-lg border border-gray-700/40"> + <span className="font-medium text-white text-xs">{t('settings.lowLatencyLiveTv')}</span> + {renderToggle(lowLatencyLiveTv, () => handleLowLatencyToggle('livetv'), 'green')} + </div> + </div> + </motion.div> + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} @@ -2992,6 +3074,72 @@ const SettingsPage: React.FC = () => { </div> </section> + {/* ════════════════════════════════════════════════════════ */} + {/* SECTION: Popup pub */} + {/* ════════════════════════════════════════════════════════ */} + <section id="intermission" className="scroll-mt-24"> + <div className="flex items-center gap-3 mb-6"> + <div className="p-2 rounded-xl bg-gradient-to-br from-amber-600/20 to-orange-600/20 border border-amber-500/20"> + <Megaphone className="w-5 h-5 text-amber-400" /> + </div> + <div> + <h2 className="text-xl font-semibold text-white">{t('settings.adPopup.title')}</h2> + <p className="text-sm text-gray-500">{t('settings.adPopup.description')}</p> + </div> + </div> + + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + className="p-4 bg-gray-800/30 rounded-xl border border-gray-700/40" + > + <div className="mb-3"> + <h4 className="font-medium text-white mb-0.5 text-sm">{t('settings.adPopup.modeTitle')}</h4> + <p className="text-xs text-gray-500 leading-relaxed">{t('settings.adPopup.modeDesc')}</p> + </div> + <div className="flex gap-2 flex-wrap"> + {([ + { id: 'normal', labelKey: 'settings.adPopup.modes.normal.label', descKey: 'settings.adPopup.modes.normal.desc' }, + { id: 'auto', labelKey: 'settings.adPopup.modes.auto.label', descKey: 'settings.adPopup.modes.auto.desc' }, + { id: 'click-anywhere', labelKey: 'settings.adPopup.modes.clickAnywhere.label', descKey: 'settings.adPopup.modes.clickAnywhere.desc' }, + ] as const).map((opt) => { + const active = adPopupMode === opt.id; + return ( + <button + key={opt.id} + type="button" + onClick={() => handleAdPopupModeChange(opt.id)} + className={`flex-1 min-w-[140px] p-3 rounded-xl text-left transition-colors border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/60 ${ + active + ? 'bg-amber-600/10 border-amber-500/40 text-white' + : 'bg-gray-700/20 border-gray-700/40 text-gray-400 hover:bg-gray-700/40 hover:text-white' + }`} + > + <div className="text-xs font-semibold">{t(opt.labelKey)}</div> + <div className="text-[10px] text-gray-500 mt-0.5">{t(opt.descKey)}</div> + </button> + ); + })} + </div> + </motion.div> + + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: 0.05 }} + className="flex items-center justify-between p-4 mt-4 bg-gray-800/30 rounded-xl border border-gray-700/40 hover:border-gray-600/50 transition-colors group" + > + <div className="flex-1 mr-4"> + <div className="flex items-center gap-2 mb-0.5"> + <AlertTriangle className="w-3.5 h-3.5 text-rose-400" /> + <h4 className="font-medium text-white text-sm">{t('settings.adPopup.adultTitle')}</h4> + </div> + <p className="text-xs text-gray-500 leading-relaxed">{t('settings.adPopup.adultDesc')}</p> + </div> + {renderToggle(adultAds, handleAdultAdsToggle, 'red')} + </motion.div> + </section> + {/* ════════════════════════════════════════════════════════ */} {/* SECTION: Extractions */} {/* ════════════════════════════════════════════════════════ */} diff --git a/src/pages/TVDetails.tsx b/src/pages/TVDetails.tsx index fe8561c..0372f64 100644 --- a/src/pages/TVDetails.tsx +++ b/src/pages/TVDetails.tsx @@ -56,6 +56,13 @@ interface TVShow { genres: { id: number; name: string }[]; number_of_seasons?: number; number_of_episodes?: number; + seasons?: Array<{ + season_number: number; + name: string; + poster_path: string | null; + air_date: string | null; + episode_count: number; + }>; status?: string; type?: string; episode_run_time?: number[]; @@ -922,6 +929,28 @@ const groupCrewMembers = (crew: CrewMember[]): GroupedCrewMember[] => { return Array.from(groupedMap.values()); }; +// Sur certains réseaux (proxy FAI / cache CloudFront), la réponse fr-FR d'une +// saison arrive corrompue : le corps n'est ni du JSON ni du gzip valide, donc +// `episodes` est absent (symptôme : "Pas d'épisodes" sur PC, OK sur mobile). +// On bascule alors sur l'objet en-US — clé de cache CDN différente, donc +// généralement saine. Numéros d'épisode et dates sont indépendants de la langue. +const fetchSeasonDetails = async ( + showId: string | number | null | undefined, + season: number +): Promise<any | null> => { + const base = `https://api.themoviedb.org/3/tv/${showId}/season/${season}?api_key=${TMDB_API_KEY}`; + const langs = [...new Set([getTmdbLanguage(), 'en-US'])]; + for (const lang of langs) { + try { + const { data } = await axios.get(`${base}&language=${lang}`); + if (data && Array.isArray(data.episodes)) return data; + } catch { + // erreur réseau / décodage : on tente la langue suivante + } + } + return null; +}; + const checkEpisodeAvailability = async (showId: string, seasonNumber: number, episodeNumber: number) => { let customLinks: string[] = []; let isFrembedAvailable = false; @@ -2747,21 +2776,19 @@ const TVDetails: React.FC = () => { seasonPromises.push( (async () => { try { - // Fetch season details - const seasonResponse = await axios.get( - `https://api.themoviedb.org/3/tv/${id}/season/${season}?api_key=${TMDB_API_KEY}&language=${getTmdbLanguage()}` - ); - const seasonData = seasonResponse.data; - newSeasonsDetails[season] = seasonData; - const episodesData = seasonData.episodes; - for (const episodeData of episodesData) { - const airDate = episodeData.air_date ? new Date(episodeData.air_date) : null; - if (airDate) airDate.setHours(0, 0, 0, 0); - if (!airDate || airDate <= today) { - allEpisodes.push({ - sa: season, - epi: episodeData.episode_number - }); + // Fetch season details (bascule sur en-US si la réponse fr-FR est corrompue) + const seasonData = await fetchSeasonDetails(id, season); + if (seasonData) { + newSeasonsDetails[season] = seasonData; + for (const episodeData of seasonData.episodes) { + const airDate = episodeData.air_date ? new Date(episodeData.air_date) : null; + if (airDate) airDate.setHours(0, 0, 0, 0); + if (!airDate || airDate <= today) { + allEpisodes.push({ + sa: season, + epi: episodeData.episode_number + }); + } } } @@ -5099,8 +5126,12 @@ const TVDetails: React.FC = () => { }) ) : ( /* Affichage standard des saisons pour les séries non-anime */ - availableSeasons.map((season) => { - const details = seasonsDetails[season]; + [...(tvShow.seasons ?? [])] + .filter((sm) => !(String(id) === '71446' && sm.season_number === 1)) + .sort((a, b) => a.season_number - b.season_number) + .map((seasonMeta) => { + const season = seasonMeta.season_number; + const details = seasonsDetails[season] || seasonMeta; const imageUrl = details?.poster_path ? `https://image.tmdb.org/t/p/original${details.poster_path}` : getSeasonFallbackSvg(t('details.season').toUpperCase()); const imageKey = `season-${season}-poster`; const hasFailed = failedImages[imageKey]; @@ -5146,7 +5177,7 @@ const TVDetails: React.FC = () => { </h3> <div className="flex items-center gap-2 mt-1"> <span className="text-xs text-gray-300">{details?.air_date ? new Date(details.air_date).getFullYear() : ''}</span> - <span className="text-xs text-gray-400 ml-auto">{details?.episodes?.length || 0} {t('details.episodes')}</span> + <span className="text-xs text-gray-400 ml-auto">{details?.episodes?.length ?? details?.episode_count ?? 0} {t('details.episodes')}</span> </div> </div> {selectedSeason === season && ( diff --git a/src/pages/Top10Page.tsx b/src/pages/Top10Page.tsx index d5cb556..cee278e 100644 --- a/src/pages/Top10Page.tsx +++ b/src/pages/Top10Page.tsx @@ -504,7 +504,7 @@ const Top10Page: React.FC = () => { alt={entry.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" decoding="async" - fetchPriority={entry.rank <= 3 ? 'high' : 'low'} + {...{ fetchpriority: entry.rank <= 3 ? 'high' : 'low' }} loading="lazy" /> ) : ( diff --git a/src/pages/Watch/WatchMovie.tsx b/src/pages/Watch/WatchMovie.tsx index 1b1e5f8..2bc208c 100644 --- a/src/pages/Watch/WatchMovie.tsx +++ b/src/pages/Watch/WatchMovie.tsx @@ -167,6 +167,21 @@ interface WiflixMovieResponse { cache_timestamp: string; } +// Interface pour la source 1jour1film (J1F) — meme forme que Wiflix +interface J1fMovieResponse { + success: boolean; + tmdb_id: string; + title: string; + original_title: string; + source: '1jour1film'; + j1f_url: string; + players: { + vf: Array<{ name: string; url: string; type: string; label?: string; source?: string }>; + vostfr: Array<{ name: string; url: string; type: string; label?: string; source?: string }>; + }; + cache_timestamp: string; +} + // Interface pour la source Viper (Cpasmal) interface ViperMovieResponse { title: string; @@ -193,7 +208,7 @@ interface NightflixSource { label?: string; } -type PlayerSourceType = 'primary' | 'vostfr' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'multi' | 'omega' | 'darkino' | 'mp4' | 'coflix' | 'frembed' | 'custom' | 'nexus_hls' | 'nexus_file' | 'fstream' | 'wiflix' | 'viper' | 'vidmoly' | 'dropload' | 'adfree' | 'rivestream_hls' | 'rivestream' | 'bravo' | number; +type PlayerSourceType = 'primary' | 'vostfr' | 'videasy' | 'vidsrccc' | 'vidsrcsu' | 'vidsrcwtf1' | 'vidsrcwtf5' | 'multi' | 'omega' | 'darkino' | 'mp4' | 'coflix' | 'frembed' | 'custom' | 'nexus_hls' | 'nexus_file' | 'fstream' | 'wiflix' | 'j1f' | 'viper' | 'vidmoly' | 'dropload' | 'adfree' | 'rivestream_hls' | 'rivestream' | 'bravo' | number; function formatPremidSourceDetail(...parts: Array<string | null | undefined>) { const normalizedParts = parts @@ -289,114 +304,14 @@ const checkMovieAvailability = async (movieId: string) => { }; const checkDarkinoAvailability = async ( - movieTitle: string, + _movieTitle: string, _releaseDate: string, - movieId: string, - updateRetryMessage?: (message: string) => void, - retryCount = 0 + _movieId: string, + _updateRetryMessage?: (message: string) => void, + _retryCount = 0 ): Promise<DarkinoResult | false> => { - const retryMessages = [ - 'Finalisation de la recherche...', - 'Preparation de la source Nightflix...', - 'Verification des acces...', - 'Optimisation de la connexion...' - ]; - const normalizeTitle = (value?: string | null) => - (value || '') - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, ' ') - .trim(); - - try { - // Special case for TMDB ID 11 (La Guerre des étoiles) - directly use Darkino ID 96071 - if (movieId === '11') { - const downloadResponse = await axios.get(`${MAIN_API}/api/films/download/96071`); - const sources: NightflixSource[] = Array.isArray(downloadResponse.data?.sources) - ? downloadResponse.data.sources.filter((source: NightflixSource) => typeof source?.m3u8 === 'string' && source.m3u8.trim() !== '') - : []; - return sources.length > 0 - ? { available: true, sources, darkinoId: '96071' } - : false; - } - - const tmdbResponse = await axios.get(`https://api.themoviedb.org/3/movie/${movieId}`, { - params: { api_key: TMDB_API_KEY, language: getTmdbLanguage() } - }); - const tmdbTitle = tmdbResponse.data?.title || movieTitle; - const tmdbOriginalTitle = tmdbResponse.data?.original_title || ''; - const targetYear = Number.parseInt(String((tmdbResponse.data?.release_date || _releaseDate || '')).slice(0, 4), 10); - const searchQueries = [...new Set([movieTitle, tmdbTitle, tmdbOriginalTitle].filter(Boolean))]; - - for (const query of searchQueries) { - const searchResponse = await axios.get(`${MAIN_API}/api/search`, { - params: { title: query } - }); - const results = Array.isArray(searchResponse.data?.results) ? searchResponse.data.results : []; - if (results.length === 0) { - continue; - } - - let matchingMovie = results.find((result: any) => - (result.have_streaming === 1 || result.have_streaming === 0) && - result.type !== 'series' && - result.tmdb_id && - String(result.tmdb_id) === String(movieId) - ); - - if (!matchingMovie) { - const normalizedTmdbTitle = normalizeTitle(tmdbTitle); - const normalizedOriginalTitle = normalizeTitle(tmdbOriginalTitle); - matchingMovie = results.find((result: any) => { - if (!(result.have_streaming === 1 || result.have_streaming === 0) || result.type === 'series') { - return false; - } - - const resultTitle = normalizeTitle(result.name); - const resultOriginalTitle = normalizeTitle(result.original_title); - const resultYear = Number.parseInt(String(result.release_date || '').slice(0, 4), 10); - const titleMatches = - (normalizedTmdbTitle && (resultTitle === normalizedTmdbTitle || resultOriginalTitle === normalizedTmdbTitle)) || - (normalizedOriginalTitle && (resultTitle === normalizedOriginalTitle || resultOriginalTitle === normalizedOriginalTitle)); - const yearMatches = Number.isNaN(targetYear) || Number.isNaN(resultYear) || resultYear === targetYear; - - return titleMatches && yearMatches; - }); - } - - if (!matchingMovie) { - continue; - } - - const downloadResponse = await axios.get(`${MAIN_API}/api/films/download/${matchingMovie.id}`); - const sources: NightflixSource[] = Array.isArray(downloadResponse.data?.sources) - ? downloadResponse.data.sources.filter((source: NightflixSource) => typeof source?.m3u8 === 'string' && source.m3u8.trim() !== '') - : []; - if (sources.length > 0) { - return { available: true, sources, darkinoId: String(matchingMovie.id) }; - } - } - - return false; - } catch (error: any) { - console.error('Error checking Darkino:', error); - - // Don't retry if error is 500 (server error) - if (error.response && error.response.status === 500) { - console.error('Darkino server error (500), not retrying:', error); - return false; - } - - if (retryCount < 3) { - if (updateRetryMessage) { - updateRetryMessage(retryMessages[retryCount % retryMessages.length]); - } - await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))); - return checkDarkinoAvailability(movieTitle, _releaseDate, movieId, updateRetryMessage, retryCount + 1); - } - return false; - } + // Source Darkino/Nightflix retiree : endpoints api.movix.chat/api/search + /api/films/download desactives. + return false; }; // Helper to pick Supervideo from Omega @@ -484,6 +399,11 @@ const WatchMovie: React.FC = () => { const [selectedWiflixSource, setSelectedWiflixSource] = useState<number>(0); + // J1F (1jour1film) source states + const [, setJ1fData] = useState<J1fMovieResponse | null>(null); + const [j1fSources, setJ1fSources] = useState<{ url: string; label: string; category: string }[]>([]); + const [selectedJ1fSource, setSelectedJ1fSource] = useState<number>(0); + // Viper source states const [, setViperData] = useState<ViperMovieResponse | null>(null); const [viperSources, setViperSources] = useState<{ url: string; label: string; quality: string; language: string }[]>([]); @@ -497,6 +417,7 @@ const WatchMovie: React.FC = () => { const [loadingFstream, setLoadingFstream] = useState(true); const [loadingWiflix, setLoadingWiflix] = useState(true); + const [loadingJ1f, setLoadingJ1f] = useState(true); const [loadingViper, setLoadingViper] = useState(true); const [loadingExtractions, setLoadingExtractions] = useState(true); // Nouvel état pour les extractions const [, setVipRetryMessage] = useState<string | null>(null); @@ -550,6 +471,19 @@ const WatchMovie: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [wiflixSources]); + const sortedJ1f = useMemo(() => { + const prefs = getSourcePriorityPrefs(); + const annotated = j1fSources.map((s) => ({ + ...s, + type: detectHoster(s.url, { + patternOverrides: prefs.patternOverrides, + customHosters: prefs.customHosters, + }) ?? 'unknown', + })); + return sortHostersByPriority(annotated, { category: 'moviesTv', topLevel: 'j1f' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [j1fSources]); + // Omega / Coflix : les listes viennent de champs dérivés (`omegaData` / `coflixData`). // On annote via detectHoster sur l'URL (`.link` pour omega, URL préférée pour coflix) // puis on trie. @@ -691,11 +625,11 @@ const WatchMovie: React.FC = () => { loadingViper }); - if (!loadingDarkino && !loadingCoflix && !loadingOmega && !loadingFrembed && !loadingFstream && !loadingWiflix && !loadingViper && !loadingExtractions) { + if (!loadingDarkino && !loadingCoflix && !loadingOmega && !loadingFrembed && !loadingFstream && !loadingWiflix && !loadingJ1f && !loadingViper && !loadingExtractions) { setVipRetryMessage(null); setIsLoading(false); } - }, [loadingDarkino, loadingCoflix, loadingOmega, loadingFrembed, loadingFstream, loadingWiflix, loadingViper, loadingExtractions]); + }, [loadingDarkino, loadingCoflix, loadingOmega, loadingFrembed, loadingFstream, loadingWiflix, loadingJ1f, loadingViper, loadingExtractions]); // Fetch video sources const fetchVideoSources = async () => { @@ -878,6 +812,14 @@ const WatchMovie: React.FC = () => { return null; }).finally(() => setLoadingWiflix(false)); + // =========== CHECK J1F (1JOUR1FILM) SOURCE =========== + const j1fPromise: Promise<J1fMovieResponse | null> = axios.get(`${MAIN_API}/api/j1f/movie/${id}`) + .then(response => response.data as J1fMovieResponse) + .catch(error => { + console.error('Error fetching 1jour1film source:', error); + return null; + }).finally(() => setLoadingJ1f(false)); + // =========== CHECK VIPER (CPASMAL) SOURCE =========== const viperPromise: Promise<ViperMovieResponse | null> = axios.get(`${MAIN_API}/api/cpasmal/movie/${id}`) .then(response => response.data as ViperMovieResponse) @@ -895,7 +837,8 @@ const WatchMovie: React.FC = () => { purstreamResult, fstreamResult, wiflixResult, - viperResult + viperResult, + j1fResult ] = await Promise.all([ darkinoPromise, availabilityPromise, @@ -904,7 +847,8 @@ const WatchMovie: React.FC = () => { purstreamPromise, fstreamPromise, wiflixPromise, - viperPromise + viperPromise, + j1fPromise ]); // =========== DÉBUT DES EXTRACTIONS (APRÈS LES REQUÊTES PRINCIPALES) =========== @@ -1774,6 +1718,39 @@ const WatchMovie: React.FC = () => { setWiflixSources(wiflixProcessedSources); console.log('🎯 [WatchMovie] Wiflix/Lynx sources set:', wiflixProcessedSources.length, wiflixProcessedSources); + // =========== TRAITEMENT DES RÉSULTATS J1F (1JOUR1FILM) =========== + let j1fProcessedSources: { url: string; label: string; category: string }[] = []; + + if (j1fResult && j1fResult.success && j1fResult.players) { + console.log('🎬 Processing 1jour1film result:', j1fResult); + setJ1fData(j1fResult); + + const categories = ['vf', 'vostfr']; + const vfSources: { url: string; label: string; category: string }[] = []; + const vostfrSources: { url: string; label: string; category: string }[] = []; + + categories.forEach(category => { + const categoryPlayers = j1fResult.players[category as keyof typeof j1fResult.players] || []; + categoryPlayers.forEach((player: any) => { + const source = { + url: player.url, + label: `1J1F ${category.toUpperCase()} - ${player.name}`, + category: category.toUpperCase(), + }; + if (category === 'vf') vfSources.push(source); + else vostfrSources.push(source); + }); + }); + + j1fProcessedSources = [...vfSources, ...vostfrSources]; + console.log('✅ 1jour1film sources processed:', j1fProcessedSources.length); + } else { + setJ1fData(null); + console.log('❌ No 1jour1film sources available'); + } + + setJ1fSources(j1fProcessedSources); + // =========== TRAITEMENT DES RÉSULTATS VIPER =========== const viperProcessedSources: { url: string; label: string; quality: string; language: string }[] = []; @@ -2229,6 +2206,27 @@ const WatchMovie: React.FC = () => { setOnlyVostfrAvailable(false); return true; } + case 'j1f': { + console.log('✅ Selecting 1JOUR1FILM as source'); + const prefsJ1f = getSourcePriorityPrefs(); + const sortedJ1fLocal = sortHostersByPriority( + j1fProcessedSources.map((s) => ({ + ...s, + type: detectHoster(s.url, { + patternOverrides: prefsJ1f.patternOverrides, + customHosters: prefsJ1f.customHosters, + }) ?? 'unknown', + })), + { category: 'moviesTv', topLevel: 'j1f' }, + ); + setSelectedSource('j1f'); + setSelectedJ1fSource(0); + setEmbedUrl(sortedJ1fLocal[0].url); + setEmbedType('j1f'); + currentSourceRef.current = 'j1f'; + setOnlyVostfrAvailable(false); + return true; + } case 'coflix': { // Coflix n'est dispo comme auto-select que si un lecteur "multi" existe // (ancien comportement via getMultiFromCoflix). @@ -2353,6 +2351,7 @@ const WatchMovie: React.FC = () => { { id: 'fstream', hasData: fstreamProcessedSources.length > 0 }, { id: 'omega', hasData: !!(omegaResult && getSupervideoFromOmega(omegaResult)) }, { id: 'wiflix', hasData: wiflixProcessedSources.length > 0 }, + { id: 'j1f', hasData: j1fProcessedSources.length > 0 }, { id: 'coflix', hasData: !!(coflixResult && getMultiFromCoflix(coflixResult)) }, { id: 'viper', hasData: viperProcessedSources.length > 0 }, { id: 'custom', hasData: customLinks.length > 0 }, @@ -2773,7 +2772,7 @@ const WatchMovie: React.FC = () => { } } // Handle Embed source selections - else if (['frembed', 'custom', 'vostfr', 'omega', 'coflix', 'fstream', 'wiflix', 'viper'].includes(type)) { + else if (['frembed', 'custom', 'vostfr', 'omega', 'coflix', 'fstream', 'wiflix', 'j1f', 'viper'].includes(type)) { let finalEmbedUrl = url; if (type === 'fstream') { finalEmbedUrl = getProxyUrl(url); @@ -2804,6 +2803,16 @@ const WatchMovie: React.FC = () => { setEmbedUrl(sortedWiflix[0].url); } } + // Handle J1F source selection + else if (type === 'j1f') { + const index = sortedJ1f.findIndex(s => s.url === url); + if (index !== -1) { + setSelectedJ1fSource(index); + } else if (sortedJ1f.length > 0) { + setSelectedJ1fSource(0); + setEmbedUrl(sortedJ1f[0].url); + } + } // Handle Viper source selection else if (type === 'viper') { const index = viperSources.findIndex(s => s.url === url); @@ -2832,7 +2841,7 @@ const WatchMovie: React.FC = () => { }; }, [ // State values used in logic - darkinoSources, mp4Sources, darkinoAvailable, nexusHlsSources, nexusFileSources, fstreamSources, wiflixSources, sortedFstream, sortedWiflix, viperSources, rivestreamSources, rivestreamLoaded, loadingRivestream, + darkinoSources, mp4Sources, darkinoAvailable, nexusHlsSources, nexusFileSources, fstreamSources, wiflixSources, j1fSources, sortedFstream, sortedWiflix, sortedJ1f, viperSources, rivestreamSources, rivestreamLoaded, loadingRivestream, // State setters setOnlyVostfrAvailable, setShowEmbedQuality, setEmbedUrl, setEmbedType, setSelectedSource, setSelectedDarkinoSource, setSelectedMp4Source, setSelectedNexusHlsSource, setSelectedNexusFileSource, setSelectedFstreamSource, setSelectedWiflixSource, setSelectedViperSource, setSelectedRivestreamSource, setVideoSource, @@ -2893,6 +2902,9 @@ const WatchMovie: React.FC = () => { (wiflixSources.length > 0 && wiflixSources.some(source => source.category === 'VF' || source.category === 'VFQ' )) || + (j1fSources.length > 0 && j1fSources.some(source => + source.category === 'VF' || source.category === 'VFQ' + )) || (viperSources.length > 0 && viperSources.some(source => source.language === 'VF' )); @@ -2927,6 +2939,9 @@ const WatchMovie: React.FC = () => { case 'wiflix': playerType = 'wiflix'; break; + case 'j1f': + playerType = 'j1f'; + break; case 'viper': playerType = 'viper'; break; @@ -2959,7 +2974,7 @@ const WatchMovie: React.FC = () => { } else { console.log('⏳ Chargements en cours - popup ads en attente'); } - }, [loadingDarkino, loadingCoflix, loadingOmega, loadingFrembed, loadingFstream, loadingWiflix, loadingExtractions, selectedSource, darkinoSources, mp4Sources, omegaData, coflixData, fstreamSources, wiflixSources, embedUrl, adPopupTriggered, adPopupBypass, showPopupForPlayer]); + }, [loadingDarkino, loadingCoflix, loadingOmega, loadingFrembed, loadingFstream, loadingWiflix, loadingExtractions, selectedSource, darkinoSources, mp4Sources, omegaData, coflixData, fstreamSources, wiflixSources, j1fSources, embedUrl, adPopupTriggered, adPopupBypass, showPopupForPlayer]); // Si on ferme le popup (croix) ET qu'on n'a PAS cliqué sur la pub, on bloque l'accès au lecteur useEffect(() => { @@ -3058,6 +3073,12 @@ const WatchMovie: React.FC = () => { sortedWiflix[selectedWiflixSource]; return formatPremidSourceDetail(source?.label, source?.category); } + case 'j1f': { + const source = + sortedJ1f.find(entry => entry.url === embedUrl) || + sortedJ1f[selectedJ1fSource]; + return formatPremidSourceDetail(source?.label, source?.category); + } case 'viper': { const source = viperSources.find(entry => entry.url === embedUrl) || @@ -3154,8 +3175,6 @@ const WatchMovie: React.FC = () => { </div> <div className="text-white text-xl font-medium mt-6">{loadingText}</div> </div> - ) : showAdFreePopup && adPopupTriggered && !adPopupBypass ? ( - <AdFreePlayerAds onClose={handlePopupClose} onAccept={handlePopupAccept} adType={adType} onAdClick={() => setHasClickedAd(true)} /> ) : adPopupBypass ? ( <div className="flex flex-col items-center justify-center h-full bg-black"> <div className="text-white text-2xl font-bold mb-4">{t('watch.mustWatchAd')}</div> @@ -3238,6 +3257,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3342,6 +3362,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3441,6 +3462,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -3488,6 +3510,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3546,6 +3569,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -3587,6 +3611,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3645,6 +3670,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -3692,6 +3718,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3750,6 +3777,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -3797,6 +3825,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3869,6 +3898,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -3912,6 +3942,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -3945,6 +3976,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -4004,6 +4036,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} title={movieTitle} @@ -4045,6 +4078,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -4167,6 +4201,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -4268,6 +4303,7 @@ const WatchMovie: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} autoPlay={false} @@ -4285,6 +4321,13 @@ const WatchMovie: React.FC = () => { </div> )} + {/* Ad popup rendered as an overlay (not in the player/loading branch) so + click-anywhere mode keeps the preloaded player visible behind the + transparent catcher. Normal mode's dialog still covers the gated + (black) loading state below it. */} + {showAdFreePopup && adPopupTriggered && !adPopupBypass && ( + <AdFreePlayerAds onClose={handlePopupClose} onAccept={handlePopupAccept} adType={adType} onAdClick={() => setHasClickedAd(true)} /> + )} </div> ); }; diff --git a/src/pages/Watch/WatchTv.tsx b/src/pages/Watch/WatchTv.tsx index 9b10c2c..65f2523 100644 --- a/src/pages/Watch/WatchTv.tsx +++ b/src/pages/Watch/WatchTv.tsx @@ -6,6 +6,7 @@ import HLSPlayer from '../../components/HLSPlayer'; import { motion, AnimatePresence } from 'framer-motion'; import { useAdFreePopup } from '../../context/AdFreePopupContext'; import AdFreePlayerAds from '../../components/AdFreePlayerAds'; +import { getAdPopupMode } from '../../utils/adPopupMode'; import { extractM3u8FromEmbed, extractVoeM3u8, extractUqloadFile, extractDarkiboxSources, extractVidzyM3u8, extractFsvidM3u8, extractOneUploadSources, isOneUploadEmbed, isVoeEmbed, extractVidmolyM3u8, extractDoodStreamFile, extractSeekStreamingM3u8, isDoodStreamEmbed, isSeekStreamingEmbed, type M3u8Result } from '../../utils/extractM3u8'; import { pickAutoSelectedSource, sortHostersByPriority, type SourceAvailability } from '../../utils/sourceAutoSelect'; import { getSourcePriorityPrefs, buildDefaults, subscribeToPriorityChanges } from '../../utils/sourcePriorityPrefs'; @@ -225,7 +226,7 @@ interface EpisodeInfo { vote_average?: number; } -type PlayerSourceType = 'darkino' | 'mp4' | 'm3u8' | 'frembed' | 'custom' | 'vostfr' | 'omega' | 'coflix' | 'fstream' | 'wiflix' | 'viper' | 'vox' | string | number; // Allow string for embed types +type PlayerSourceType = 'darkino' | 'mp4' | 'm3u8' | 'frembed' | 'custom' | 'vostfr' | 'omega' | 'coflix' | 'fstream' | 'wiflix' | 'j1f' | 'viper' | 'vox' | string | number; // Allow string for embed types function formatPremidSourceDetail(...parts: Array<string | null | undefined>) { const normalizedParts = parts @@ -343,78 +344,16 @@ const checkOmegaAvailability = (omegaData: any, seasonNumber: number, episodeNum // Check Darkino Availability for TV Episodes (Adapted from TVDetails) const checkDarkinoAvailability = async ( - showTitle: string, - releaseYear: number, - seasonNumber: number, - episodeNumber: number, - showId: string, - updateRetryMessage?: (message: string) => void, - retryCount = 0 -) => { - const retryMessages = [ - "Finalisation de la recherche premium...", - "Préparation de la source Nightflix VIP...", - "Vérification des accès sécurisés...", - "Optimisation de la connexion VIP..." - ]; - - try { - const searchResponse = await axios.get(`${MAIN_API}/api/search`, { - params: { title: showTitle } - }); - - if (!searchResponse.data.results || searchResponse.data.results.length === 0) { - return false; - } - - // Match only by tmdb_id (exact match) - const matchingShow = searchResponse.data.results.find((result: any) => { - return (result.type === 'series' || result.type === 'animes' || result.type === 'doc') && - result.tmdb_id && - String(result.tmdb_id) === String(showId); - }); - - if (!matchingShow) { - return false; - } - - try { - const downloadResponse = await axios.get( - `${MAIN_API}/api/series/download/${matchingShow.id}/season/${seasonNumber}/episode/${episodeNumber}` - ); - - if (downloadResponse.data && downloadResponse.data.sources && downloadResponse.data.sources.length > 0) { - const m3u8Sources = downloadResponse.data.sources.filter((source: any) => source.m3u8); - if (m3u8Sources.length > 0) { - return { available: true, sources: m3u8Sources, darkinoId: matchingShow.id }; - } - } - return false; - } catch (downloadError: any) { - // Don't retry if error is 500 (server error) - if (downloadError.response && downloadError.response.status === 500) { - console.error('[Darkino Download] Server error (500), not retrying:', downloadError.message); - return false; - } - throw downloadError; // Re-throw other errors to be caught by the main catch block - } - - } catch (error: any) { - console.error('[Darkino Check] Error:', error.message); - - // Don't retry if error is 500 (server error) - if (error.response && error.response.status === 500) { - console.error('[Darkino Check] Server error (500), not retrying:', error.message); - return false; - } - - if (retryCount < 3) { - if (updateRetryMessage) updateRetryMessage(retryMessages[retryCount % retryMessages.length]); - await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1))); - return checkDarkinoAvailability(showTitle, releaseYear, seasonNumber, episodeNumber, showId, updateRetryMessage, retryCount + 1); - } - return false; - } + _showTitle: string, + _releaseYear: number, + _seasonNumber: number, + _episodeNumber: number, + _showId: string, + _updateRetryMessage?: (message: string) => void, + _retryCount = 0 +): Promise<false | { available: boolean; sources: any[]; darkinoId: string | null }> => { + // Source Darkino/Nightflix retirée : endpoints api.movix.chat/api/search + /api/series/download désactivés. + return false; }; // Check sibnet availability for anime @@ -576,6 +515,12 @@ const WatchTv: React.FC = () => { const [selectedWiflixSource, setSelectedWiflixSource] = useState<number>(0); const [loadingWiflix, setLoadingWiflix] = useState(true); + // J1F (1jour1film) source states + const [, setJ1fData] = useState<any>(null); + const [j1fSources, setJ1fSources] = useState<{ url: string; label: string; category: string }[]>([]); + const [selectedJ1fSource, setSelectedJ1fSource] = useState<number>(0); + const [loadingJ1f, setLoadingJ1f] = useState(true); + // Extracted sources states (General HLS/File bucket) const [nexusHlsSources, setNexusHlsSources] = useState<{ url: string; label: string }[]>([]); const [nexusFileSources, setNexusFileSources] = useState<{ url: string; label: string }[]>([]); @@ -662,6 +607,19 @@ const WatchTv: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [wiflixSources]); + const sortedJ1f = useMemo(() => { + const prefs = getSourcePriorityPrefs(); + const annotated = j1fSources.map((s) => ({ + ...s, + type: detectHoster(s.url, { + patternOverrides: prefs.patternOverrides, + customHosters: prefs.customHosters, + }) ?? 'unknown', + })); + return sortHostersByPriority(annotated, { category: 'moviesTv', topLevel: 'j1f' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [j1fSources]); + // Omega / Coflix : les listes viennent de champs dérivés (`omegaData` / `coflixData`). // On annote via detectHoster sur l'URL (`.link` pour omega, URL préférée pour coflix) // puis on trie. @@ -1065,7 +1023,7 @@ const WatchTv: React.FC = () => { // Determine if all necessary sources have finished loading attempts // Determine if all necessary sources have finished loading attempts - const areSourcesLoading = loadingDarkino || loadingCoflix || loadingOmega || loadingCustom || loadingSibnet || loadingFrembed || loadingFstream || loadingWiflix || loadingViper || loadingExtractions; + const areSourcesLoading = loadingDarkino || loadingCoflix || loadingOmega || loadingCustom || loadingSibnet || loadingFrembed || loadingFstream || loadingWiflix || loadingJ1f || loadingViper || loadingExtractions; if (!areSourcesLoading) { setVipRetryMessage(null); // Clear VIP message once attempts are done @@ -1140,7 +1098,7 @@ const WatchTv: React.FC = () => { setIsLoading(false); // Mark loading as complete } - }, [loadingDarkino, loadingCoflix, loadingOmega, loadingCustom, loadingSibnet, loadingFrembed, loadingFstream, loadingWiflix, loadingExtractions, isLoading, selectedSource, darkinoSources, mp4Sources, sibnetUrl, adFreeM3u8Url, id, seasonNumber, episodeNumber, customSources, frembedAvailable]); + }, [loadingDarkino, loadingCoflix, loadingOmega, loadingCustom, loadingSibnet, loadingFrembed, loadingFstream, loadingWiflix, loadingJ1f, loadingExtractions, isLoading, selectedSource, darkinoSources, mp4Sources, sibnetUrl, adFreeM3u8Url, id, seasonNumber, episodeNumber, customSources, frembedAvailable]); // Add effect to hide Sources button in HLS mode useEffect(() => { @@ -1420,6 +1378,19 @@ const WatchTv: React.FC = () => { } })(); + // ========== CHECK J1F (1JOUR1FILM) SOURCE ========== + const j1fPromise = (async () => { + try { + const j1fResponse = await axios.get(`${MAIN_API}/api/j1f/tv/${id}/season/${seasonNumber}`); + return j1fResponse.data; + } catch (error) { + console.error('Error fetching 1jour1film TV source:', error); + return null; + } finally { + setLoadingJ1f(false); + } + })(); + // ========== CHECK VIPER SOURCE ========== const viperPromise = (async () => { try { @@ -1456,7 +1427,8 @@ const WatchTv: React.FC = () => { fstreamResult, wiflixResult, viperResult, - voxResult + voxResult, + j1fResult ] = await Promise.all([ darkinoPromise, customLinksPromise, @@ -1467,7 +1439,8 @@ const WatchTv: React.FC = () => { fstreamPromise, wiflixPromise, viperPromise, - voxPromise + voxPromise, + j1fPromise ]); // ========== PROCESS OMEGA RESULTS (before setting state) ========== @@ -2104,6 +2077,37 @@ const WatchTv: React.FC = () => { setWiflixSources(wiflixProcessedSources); console.log('?? [WatchTv] Wiflix/Lynx TV sources set:', wiflixProcessedSources.length, wiflixProcessedSources); + // =========== TRAITEMENT DES RÉSULTATS J1F (1JOUR1FILM) =========== + let j1fProcessedSources: { url: string; label: string; category: string }[] = []; + + if (j1fResult && j1fResult.success && j1fResult.episodes) { + setJ1fData(j1fResult); + const episodeData = j1fResult.episodes[episodeNumber.toString()]; + if (episodeData) { + const categories = ['vf', 'vostfr']; + const vfSources: { url: string; label: string; category: string }[] = []; + const vostfrSources: { url: string; label: string; category: string }[] = []; + categories.forEach(category => { + const categoryPlayers = episodeData[category as keyof typeof episodeData] || []; + categoryPlayers.forEach((player: any) => { + const source = { + url: player.url, + label: `1J1F ${category.toUpperCase()} - ${player.name}`, + category: category.toUpperCase(), + }; + if (category === 'vf') vfSources.push(source); + else vostfrSources.push(source); + }); + }); + j1fProcessedSources = [...vfSources, ...vostfrSources]; + } + } else { + setJ1fData(null); + } + + setJ1fSources(j1fProcessedSources); + console.log('?? [WatchTv] 1jour1film TV sources set:', j1fProcessedSources.length); + // =========== EXTRACTION ONEUPLOAD DEPUIS WIFLIX =========== if (wiflixProcessedSources.length > 0) { console.log('?? Checking for OneUpload sources in Wiflix TV results...'); @@ -2890,6 +2894,27 @@ const WatchTv: React.FC = () => { setOnlyVostfrAvailable(false); return true; } + case 'j1f': { + console.log('✅ Selecting 1JOUR1FILM as source'); + const prefsJ1f = getSourcePriorityPrefs(); + const sortedJ1fLocal = sortHostersByPriority( + j1fProcessedSources.map((s) => ({ + ...s, + type: detectHoster(s.url, { + patternOverrides: prefsJ1f.patternOverrides, + customHosters: prefsJ1f.customHosters, + }) ?? 'unknown', + })), + { category: 'moviesTv', topLevel: 'j1f' }, + ); + setSelectedSource('j1f'); + setSelectedJ1fSource(0); + setEmbedUrl(sortedJ1fLocal[0].url); + setEmbedType('j1f'); + currentSourceRef.current = 'j1f'; + setOnlyVostfrAvailable(false); + return true; + } case 'viper': { console.log('✅ Selecting VIPER as source'); // Pré-tri langue + hoster pour respecter la préférence user @@ -3012,6 +3037,7 @@ const WatchTv: React.FC = () => { { id: 'fstream', hasData: fstreamProcessedSources.length > 0 }, { id: 'omega', hasData: !!(isOmegaAvailable && rawOmegaData) }, { id: 'wiflix', hasData: wiflixProcessedSources.length > 0 }, + { id: 'j1f', hasData: j1fProcessedSources.length > 0 }, { id: 'viper', hasData: viperProcessedSources.length > 0 }, { id: 'coflix', hasData: !!(coflixResult && (coflixResult.current_episode?.player_links?.length || coflixResult.player_links?.length)) }, { id: 'custom', hasData: !!(customLinksResult.customLinks && customLinksResult.customLinks.length) }, @@ -3246,7 +3272,7 @@ const WatchTv: React.FC = () => { } } // Handle Embed source selections - else if (type === 'frembed' || type === 'custom' || type === 'vostfr' || type === 'omega' || type === 'coflix' || type === 'fstream' || type === 'wiflix' || type === 'viper' || type === 'adfree' || type === 'vox') { + else if (type === 'frembed' || type === 'custom' || type === 'vostfr' || type === 'omega' || type === 'coflix' || type === 'fstream' || type === 'wiflix' || type === 'j1f' || type === 'viper' || type === 'adfree' || type === 'vox') { setEmbedUrl(type === 'fstream' ? getProxyUrl(url) : url); setEmbedType(type as string); // type is known to be a string here setSelectedSource(type as PlayerSourceType); @@ -3275,6 +3301,16 @@ const WatchTv: React.FC = () => { setEmbedUrl(sortedWiflix[0].url); } } + // Handle J1F source selection + else if (type === 'j1f') { + const index = sortedJ1f.findIndex(s => s.url === url); + if (index !== -1) { + setSelectedJ1fSource(index); + } else if (sortedJ1f.length > 0) { + setSelectedJ1fSource(0); + setEmbedUrl(sortedJ1f[0].url); + } + } // Handle Viper source selection else if (type === 'viper') { const index = viperSources.findIndex(s => s.url === url); @@ -3311,7 +3347,7 @@ const WatchTv: React.FC = () => { window.removeEventListener('sourceChange', handleSourceChangeFromMenu as EventListener); }; }, [ - nexusHlsSources, nexusFileSources, darkinoSources, mp4Sources, adFreeM3u8Url, sibnetUrl, darkinoAvailable, fstreamSources, rivestreamSources, viperSources, voxSources, purstreamSources, sortedFstream, sortedWiflix, canUseBravo, // Data sources + nexusHlsSources, nexusFileSources, darkinoSources, mp4Sources, adFreeM3u8Url, sibnetUrl, darkinoAvailable, fstreamSources, rivestreamSources, viperSources, voxSources, purstreamSources, sortedFstream, sortedWiflix, sortedJ1f, j1fSources, canUseBravo, // Data sources setOnlyVostfrAvailable, setShowEmbedQuality, // State setters for visibility setEmbedUrl, setEmbedType, setSelectedSource, // General source setters setSelectedNexusHlsSource, setSelectedNexusFileSource, setSelectedDarkinoSource, setSelectedMp4Source, setSelectedFstreamSource, setVideoSource, setSelectedViperSource, // HLS specific setters @@ -3879,6 +3915,9 @@ const WatchTv: React.FC = () => { (wiflixSources.length > 0 && wiflixSources.some(source => source.category === 'VF' )) || + (j1fSources.length > 0 && j1fSources.some(source => + source.category === 'VF' + )) || (viperSources.length > 0 && viperSources.some(source => source.language === 'VF' )) || @@ -3921,6 +3960,9 @@ const WatchTv: React.FC = () => { case 'wiflix': playerType = 'wiflix'; break; + case 'j1f': + playerType = 'j1f'; + break; case 'viper': playerType = 'viper'; break; @@ -3985,7 +4027,10 @@ const WatchTv: React.FC = () => { }, []); // Now, after all hooks, you can do conditional returns: - if (showAdFreePopup && adPopupTriggered && !adPopupBypass) { + // Normal/auto replace the whole view with the popup (dialog/auto over the gated + // black screen). Click-anywhere falls through so the player loads behind the + // transparent catcher, which is rendered as an overlay in the main return. + if (showAdFreePopup && adPopupTriggered && !adPopupBypass && getAdPopupMode() !== 'click-anywhere') { return <AdFreePlayerAds onClose={handlePopupClose} onAccept={handlePopupAccept} adType={adType} onAdClick={() => setHasClickedAd(true)} />; } if (adPopupBypass) { @@ -4076,6 +4121,12 @@ const WatchTv: React.FC = () => { sortedWiflix[selectedWiflixSource]; return formatPremidSourceDetail(source?.label, source?.category); } + case 'j1f': { + const source = + sortedJ1f.find(entry => entry.url === embedUrl) || + sortedJ1f[selectedJ1fSource]; + return formatPremidSourceDetail(source?.label, source?.category); + } case 'viper': { const source = viperSources.find(entry => entry.url === embedUrl) || @@ -4255,6 +4306,7 @@ const WatchTv: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} rivestreamSources={isRivestreamAvailable() ? rivestreamSources : []} rivestreamCaptions={isRivestreamAvailable() ? rivestreamCaptions : []} loadingRivestream={isRivestreamAvailable() ? loadingRivestream : false} @@ -4600,6 +4652,7 @@ const WatchTv: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} voxSources={voxSources} @@ -4653,6 +4706,7 @@ const WatchTv: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} voxSources={voxSources} @@ -4716,6 +4770,7 @@ const WatchTv: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} voxSources={voxSources} @@ -4759,6 +4814,7 @@ const WatchTv: React.FC = () => { coflixSources={sortedCoflix} fstreamSources={sortedFstream} wiflixSources={sortedWiflix} + j1fSources={sortedJ1f} viperSources={sortedViper} rivestreamSources={isRivestreamAvailable() ? rivestreamSources : []} @@ -4803,6 +4859,12 @@ const WatchTv: React.FC = () => { </div> )} + {/* Click-anywhere: transparent catcher overlaid on the preloaded player. + Normal/auto already returned the popup above, so this renders only for + click-anywhere. */} + {showAdFreePopup && adPopupTriggered && !adPopupBypass && ( + <AdFreePlayerAds onClose={handlePopupClose} onAccept={handlePopupAccept} adType={adType} onAdClick={() => setHasClickedAd(true)} /> + )} </div> ); }; diff --git a/src/pages/WatchPartyCreate.tsx b/src/pages/WatchPartyCreate.tsx index d1206b4..ad4975e 100644 --- a/src/pages/WatchPartyCreate.tsx +++ b/src/pages/WatchPartyCreate.tsx @@ -403,6 +403,7 @@ const WatchPartyCreate: React.FC = () => { onChange={(e) => setNickname(e.target.value)} className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 text-white placeholder-white/30 transition-all font-medium" placeholder={t('watchParty.nicknamePlaceholder')} + maxLength={32} required /> </div> diff --git a/src/pages/WatchPartyJoin.tsx b/src/pages/WatchPartyJoin.tsx index 1a342ab..c701730 100644 --- a/src/pages/WatchPartyJoin.tsx +++ b/src/pages/WatchPartyJoin.tsx @@ -183,6 +183,7 @@ const WatchPartyJoin: React.FC = () => { onChange={(e) => setNickname(e.target.value)} placeholder={t('watchParty.enterPseudo')} className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 text-white placeholder-white/30 transition-all" + maxLength={32} /> </div> diff --git a/src/pages/WhatIsMovixPage.tsx b/src/pages/WhatIsMovixPage.tsx index 8574fba..4421094 100644 --- a/src/pages/WhatIsMovixPage.tsx +++ b/src/pages/WhatIsMovixPage.tsx @@ -11,6 +11,7 @@ import { Button } from '../components/ui/button'; // Sources supportées const supportedSources = [ { name: 'Wiflix', color: '#f97316' }, + { name: '1jour1film', color: '#eab308' }, { name: 'Coflix', color: '#3b82f6' }, { name: 'Cpasmal', color: '#8b5cf6' }, { name: 'Anime-Sama', color: '#ec4899' }, diff --git a/src/pages/WrappedPage.tsx b/src/pages/WrappedPage.tsx index c2e4121..4f73793 100644 --- a/src/pages/WrappedPage.tsx +++ b/src/pages/WrappedPage.tsx @@ -1,16 +1,17 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { motion, AnimatePresence, PanInfo } from 'framer-motion'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { motion, AnimatePresence, PanInfo, useReducedMotion } from 'framer-motion'; import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; -import { ChevronLeft, ChevronRight, Share2, X, Sparkles, Calendar, Trophy, BarChart3, Clock, Flame, Music, ShieldOff, Settings, ImageIcon, Download, Copy, FileText, Loader2, LogIn, UserPlus } from 'lucide-react'; -import { fetchWrappedData, WrappedData, WrappedProgress, WrappedSlide, WrappedTopContent } from '../services/wrappedService'; +import { ChevronLeft, ChevronRight, Share2, X, Sparkles, Calendar, Trophy, BarChart3, Clock, Flame, Music, ShieldOff, Settings, ImageIcon, Download, Copy, FileText, Loader2, LogIn, UserPlus, TrendingUp, Repeat, Hourglass, MousePointerClick, HelpCircle } from 'lucide-react'; +import { fetchWrappedData, WrappedData, WrappedProgress, WrappedSlide, WrappedTopContent, WrappedResponse } from '../services/wrappedService'; +import { drawRoundedRectPath, wrapCanvasText, loadCanvasImage, ensureShareFonts, drawSeededStickers } from '../utils/wrappedCanvas'; +import { generatePosterShareImage, generateTicketShareImage, WrappedShareCardData } from '../utils/wrappedShareCards'; import { SquareBackground } from '../components/ui/square-background'; import AnimatedBorderCard from '../components/ui/animated-border-card'; import ShinyText from '../components/ui/shiny-text'; import axios from 'axios'; import { getTmdbLanguage } from '../i18n'; -import { buildApiProxyUrl } from '../config/runtime'; import { toast } from 'sonner'; import { areSoundEffectsEnabled, SOUND_EFFECTS_CHANGED_EVENT } from '../utils/soundSettings'; @@ -74,144 +75,6 @@ function formatWrappedTypeLabel( const WRAPPED_SHARE_IMAGE_WIDTH = 1080; const WRAPPED_SHARE_IMAGE_HEIGHT = 1920; -function drawRoundedRectPath( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number -) { - const safeRadius = Math.min(radius, width / 2, height / 2); - ctx.beginPath(); - ctx.moveTo(x + safeRadius, y); - ctx.lineTo(x + width - safeRadius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius); - ctx.lineTo(x + width, y + height - safeRadius); - ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height); - ctx.lineTo(x + safeRadius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius); - ctx.lineTo(x, y + safeRadius); - ctx.quadraticCurveTo(x, y, x + safeRadius, y); - ctx.closePath(); -} - -function drawPopcornSticker( - ctx: CanvasRenderingContext2D, - { - x, - y, - scale = 1, - rotation = 0, - opacity = 0.5, - accent = '#f6c453', // Unused but kept for signature - }: { - x: number; - y: number; - scale?: number; - rotation?: number; - opacity?: number; - accent?: string; - fillLevel?: number; - } -) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(rotation); - // Adjusted scale down drastically because original SVG is 512x512 - ctx.scale(scale * 0.05, scale * 0.05); - // Shift so it rotates somewhat around its center (256, 256) - ctx.translate(-256, -256); - ctx.globalAlpha = opacity; - - // SVG Path for Popcorn - const combinedPath = new Path2D( - "M415.08,147.991c-0.582-0.661-1.27-1.201-2.025-1.618c-2.051-13.744-8.438-27.651-20.957-27.856 c-1.149-2.05-2.159-7.502-2.786-10.891c-0.847-4.574-1.723-9.304-3.418-13.119c-4.147-9.33-11.46-9.194-14.405-8.702 c-4.744,0.791-9.651-2.179-10.522-3.816c0.228-0.314,0.77-0.892,1.831-1.599c5.878-3.918,8.79-8.526,8.654-13.697 c-0.247-9.384-9.945-15.137-22.223-22.421c-1.595-0.946-3.232-1.917-4.884-2.919c-13.683-8.292-24.607-3.13-32.584,0.64 c-3.66,1.729-6.869,3.246-9.73,3.352c-0.035-0.649-0.057-1.374-0.075-1.991c-0.212-7.208-0.568-19.271-14.256-31.474 C274.131-0.211,262.42-0.939,254.984,0.582c-10.465,2.138-19.319,9.884-24.931,21.809c-2.617,5.561-4.708,9.396-6.41,10.034 c-2.405,0.901-8.666-0.019-17.332-1.293l-2.647-0.388c-4.139-0.602-7.984,2.263-8.588,6.402s2.262,7.984,6.402,8.588l2.63,0.386 c12.116,1.78,18.791,2.761,24.852,0.49c7.258-2.721,10.712-9.081,14.8-17.77c3.475-7.385,8.671-12.276,14.255-13.417 c7.32-1.495,14.702,3.398,19.604,7.767c8.819,7.861,9.018,14.634,9.194,20.61c0.149,5.083,0.461,15.658,13.134,16.633 c7.087,0.541,13.038-2.264,18.284-4.742c7.585-3.582,11.841-5.272,18.261-1.382c1.694,1.027,3.372,2.022,5.007,2.992 c4.807,2.852,11.761,6.977,14.196,9.523c-0.315,0.265-0.738,0.588-1.301,0.962c-7.601,5.068-10.446,12.378-7.608,19.555 c3.417,8.64,14.778,14.601,25.421,13.633c0.875,2.2,1.688,6.589,2.21,9.409c1.755,9.477,4.404,23.786,17.302,23.281 c1.706-0.07,4.236,5.117,5.72,11.755h-274.7c-0.115-0.071-0.225-0.146-0.345-0.212c-1.163-0.638-4.7-3.277-5.188-5.688 c-0.075-0.369-0.302-1.491,1.425-3.672c2.923-3.692,5.246-4.908,7.937-6.315c6.07-3.177,10.867-6.586,12.796-18.929 c1.208-7.729,4.202-10.008,11.438-15.514c3.905-2.972,8.767-6.67,14.206-12.111c2.958-2.958,2.958-7.754,0-10.711 c-2.958-2.958-7.754-2.958-10.711,0c-4.716,4.716-8.941,7.932-12.668,10.767c-8.093,6.158-15.082,11.477-17.231,25.23 c-0.903,5.779-1.506,6.095-4.851,7.846c-3.32,1.738-7.869,4.117-12.791,10.335c-5.035,6.361-5.169,12.256-4.395,16.08 c0.205,1.015,0.509,1.972,0.86,2.894h-0.614c-2.177,0-4.249,0.937-5.687,2.571c-1.438,1.635-2.104,3.808-1.826,5.967l7.52,58.553 c0.532,4.148,4.326,7.074,8.477,6.547c4.149-0.533,7.08-4.328,6.548-8.477l-6.423-50.014h17.445l43.185,336.283H154.4 l-27.572-214.71c-0.533-4.149-4.328-7.082-8.477-6.548c-4.149,0.533-7.08,4.328-6.548,8.477l28.422,221.32 c0.485,3.779,3.702,6.61,7.512,6.61h32.688c0.007,0,0.014,0.001,0.021,0.001c0.005,0,0.01-0.001,0.015-0.001h38.037 c0.003,0,0.007,0,0.01,0c0.005,0,0.009,0,0.014,0h81.925c0.005,0,0.009,0,0.014,0c0.003,0,0.007,0,0.01,0h38.037 c0.005,0,0.01,0.001,0.015,0.001c0.007,0,0.014-0.001,0.021-0.001h25.718c3.81,0,7.027-2.831,7.512-6.61l45.13-351.431 C417.182,151.8,416.518,149.626,415.08,147.991z M187.118,496.851l-43.185-336.283h42.594l23.857,336.283H187.118z M251.857,496.851h-26.285l-23.858-336.283h50.143V496.851z M293.397,496.851h-26.393V160.568h50.25L293.397,496.851z M331.851,496.851h-23.267l23.858-336.283h42.594L331.851,496.851z M357.599,496.851h-10.476l43.185-336.283h10.476 L357.599,496.851z " + - "M296.654,97.087c-1.495-3.907-5.874-5.862-9.781-4.367c-10.043,3.844-12.895,2.129-19.785-2.013 c-3.833-2.305-8.605-5.173-15.305-7.342c-21.151-6.856-34.389,9.688-40.75,17.636c-0.555,0.694-1.076,1.346-1.567,1.939 c-1.149,1.389-1.668,1.682-1.659,1.682c-0.363,0.102-1.676,0.084-2.73,0.069c-2.714-0.038-6.431-0.091-11.781,0.853 c-17.312,3.055-22.505,22.439-22.718,23.263c-1.039,4.033,1.379,8.127,5.405,9.193c0.645,0.171,1.293,0.253,1.93,0.253 c3.344,0,6.409-2.241,7.323-5.617c0.029-0.108,3.014-10.818,10.693-12.174c3.937-0.695,6.592-0.655,8.934-0.624 c5.941,0.09,10.601-0.385,16.272-7.24c0.539-0.651,1.114-1.368,1.725-2.132c7.145-8.927,13.902-16.041,24.256-12.691 c5.049,1.636,8.669,3.811,12.169,5.915c7.901,4.748,16.071,9.657,33.002,3.176C296.194,105.373,298.148,100.993,296.654,97.087z" - ); - - ctx.fillStyle = accent; - ctx.fill(combinedPath); - ctx.restore(); -} - -function drawClapperSticker( - ctx: CanvasRenderingContext2D, - { - x, - y, - scale = 1, - rotation = 0, - opacity = 0.5, - accent = '#4ecdc4', - }: { - x: number; - y: number; - scale?: number; - rotation?: number; - opacity?: number; - accent?: string; - } -) { - ctx.save(); - ctx.translate(x, y); - ctx.rotate(rotation); - ctx.scale(scale, scale); - ctx.translate(-12, -12); // Center a 24x24 viewBox - ctx.globalAlpha = opacity; - - // SVG Path for Clapperboard - const combinedPath = new Path2D( - "M4 11H16C17.8856 11 18.8284 11 19.4142 11.5858C20 12.1716 20 13.1144 20 15V16C20 18.8284 20 20.2426 19.1213 21.1213C18.2426 22 16.8284 22 14 22H10C7.17157 22 5.75736 22 4.87868 21.1213C4 20.2426 4 18.8284 4 16V11Z " + - "M4.00128 10.9997C3.51749 9.19412 3.27559 8.29135 3.48364 7.51489C3.61994 7.00622 3.88773 6.5424 4.2601 6.17003C4.82851 5.60162 5.73128 5.35973 7.53682 4.87593L14.5398 2.99949C15.213 2.8191 15.5496 2.72891 15.8445 2.70958C17.0553 2.63022 18.1946 3.28804 18.7313 4.37629C18.862 4.64129 18.9522 4.97791 19.1326 5.65114C19.1927 5.87556 19.2228 5.98776 19.2292 6.08604C19.2557 6.48964 19.0364 6.86943 18.6736 7.04832C18.5853 7.09188 18.4731 7.12195 18.2487 7.18208L4.00128 10.9997Z " + - "M14.7004 2.94135L14.0627 8.28861 " + - "M8.42209 4.62396L7.78433 9.97123" - ); - - ctx.strokeStyle = accent; - ctx.lineWidth = 1.5; - ctx.stroke(combinedPath); - ctx.restore(); -} - -function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { - if (!text) return ['']; - - const words = text.split(/\s+/).filter(Boolean); - const lines: string[] = []; - let currentLine = ''; - - words.forEach((word) => { - const candidate = currentLine ? `${currentLine} ${word}` : word; - if (ctx.measureText(candidate).width <= maxWidth) { - currentLine = candidate; - } else { - if (currentLine) { - lines.push(currentLine); - } - currentLine = word; - } - }); - - if (currentLine) { - lines.push(currentLine); - } - - return lines.length > 0 ? lines : [text]; -} - -async function loadCanvasImage(src: string): Promise<HTMLImageElement | null> { - return new Promise((resolve) => { - const image = new Image(); - const resolvedSrc = src.startsWith('http') ? buildApiProxyUrl(src) : src; - if (!resolvedSrc.startsWith(window.location.origin)) { - image.crossOrigin = 'anonymous'; - } - image.onload = () => resolve(image); - image.onerror = () => resolve(null); - image.src = resolvedSrc; - }); -} function downloadBlob(blob: Blob, filename: string) { const blobUrl = URL.createObjectURL(blob); @@ -1073,7 +936,7 @@ const SlideTopGenres: React.FC<{ slide: WrappedSlide; topGenres?: WrappedData['t // ========================================== // SLIDE: LISTENING CLOCK // ========================================== -const SlideListeningClock: React.FC<{ slide: WrappedSlide; listeningClock?: WrappedData['listeningClock']; peakHour?: number }> = ({ slide, listeningClock, peakHour }) => { +const SlideListeningClock: React.FC<{ slide: WrappedSlide; listeningClock?: WrappedData['listeningClock']; peakHour?: number; weekday?: WrappedData['weekday'] }> = ({ slide, listeningClock, peakHour, weekday }) => { const maxMinutes = listeningClock ? Math.max(...listeningClock.map(h => h.minutes)) : 1; const hourMarkers = [0, 6, 12, 18, 23]; @@ -1149,6 +1012,32 @@ const SlideListeningClock: React.FC<{ slide: WrappedSlide; listeningClock?: Wrap <span key={hour}>{hour}{i18n.t('wrapped.hoursShort')}</span> ))} </div> + {weekday && weekday.some(d => d.minutes > 0) && ( + <div className="mt-4"> + <div className="flex items-end justify-center gap-1.5 h-12"> + {/* dow MySQL : 1=dimanche … 7=samedi → on affiche Lun→Dim */} + {[2, 3, 4, 5, 6, 7, 1].map((dow) => { + const entry = weekday.find(d => d.dow === dow); + const minutes = entry?.minutes || 0; + const maxW = Math.max(1, ...weekday.map(d => d.minutes)); + const isTop = minutes === maxW && minutes > 0; + return ( + <div key={dow} className="flex flex-col items-center gap-1 flex-1 h-full justify-end"> + <motion.div + initial={{ height: 0 }} + animate={{ height: minutes > 0 ? `${Math.max(12, (minutes / maxW) * 100)}%` : '2px' }} + transition={{ duration: 0.5, delay: 1.4 }} + className={`w-full rounded-t-sm ${isTop ? 'bg-sky-400' : minutes > 0 ? 'bg-sky-500/40' : 'bg-white/5'}`} + /> + <span className="text-[9px] text-white/40 font-mono"> + {new Intl.DateTimeFormat(i18n.language, { weekday: 'narrow' }).format(new Date(2026, 0, 3 + dow))} + </span> + </div> + ); + })} + </div> + </div> + )} </motion.div> )} @@ -1277,22 +1166,65 @@ const SlideFunFact: React.FC<{ slide: WrappedSlide }> = ({ slide }) => ( </WrappedCenteredSlide> ); +const CreditsRoll: React.FC<{ topContent: WrappedData['topContent']; topGenres?: WrappedData['topGenres']; stats: WrappedData['stats'] }> = ({ topContent, topGenres, stats }) => { + const { t } = useTranslation(); + const prefersReducedMotion = useReducedMotion(); + const lines: { label: string; value: string }[] = [ + { label: t('wrapped.creditsDirectedBy'), value: t('wrapped.creditsYou') }, + ...(topContent.length ? [{ label: t('wrapped.creditsStarring'), value: topContent.slice(0, 5).map(c => c.title).join(' · ') }] : []), + ...(topGenres?.length ? [{ label: t('wrapped.creditsGenre'), value: topGenres[0].name }] : []), + { label: t('wrapped.creditsRuntime'), value: formatCompactDuration(stats.totalMinutes, t) }, + { label: t('wrapped.creditsProducedBy'), value: 'MOVIX' }, + { label: '©', value: 'movix.date' }, + ]; + + return ( + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1 }} className="mt-6 h-36 w-full max-w-md overflow-hidden relative" style={{ maskImage: 'linear-gradient(to bottom, transparent, black 18%, black 82%, transparent)' }}> + <motion.div + initial={{ y: prefersReducedMotion ? 0 : 144 }} + animate={prefersReducedMotion ? { y: 0 } : { y: -lines.length * 44 }} + transition={prefersReducedMotion ? undefined : { duration: lines.length * 2.2, ease: 'linear', repeat: Infinity, repeatDelay: 1.2 }} + className="flex flex-col items-center gap-3" + > + {lines.map((line) => ( + <div key={line.label + line.value} className="text-center"> + <p className="text-[10px] uppercase tracking-[0.3em] text-white/40">{line.label}</p> + <p className="text-sm md:text-base font-bold text-white/90 max-w-sm">{line.value}</p> + </div> + ))} + </motion.div> + </motion.div> + ); +}; + const SlideClosing: React.FC<{ slide: WrappedSlide; stats: WrappedData['stats']; + topContent: WrappedData['topContent']; + topGenres?: WrappedData['topGenres']; onShareImage: () => void; onDownloadImage: () => void; onShareText: () => void; onCopyText: () => void; isPreparingImage: boolean; + shareFormat: 'story' | 'poster' | 'ticket'; + onFormatChange: (f: 'story' | 'poster' | 'ticket') => void; + previewUrl: string | null; + isGeneratingPreview: boolean; }> = ({ slide, stats, + topContent, + topGenres, onShareImage, onDownloadImage, onShareText, onCopyText, - isPreparingImage + isPreparingImage, + shareFormat, + onFormatChange, + previewUrl, + isGeneratingPreview }) => ( <WrappedCenteredSlide contentClassName="max-w-4xl"> <motion.div @@ -1355,6 +1287,9 @@ const SlideClosing: React.FC<{ </AnimatedBorderCard> </motion.div> + {/* Générique de fin */} + <CreditsRoll topContent={topContent} topGenres={topGenres} stats={stats} /> + {/* Stats summary */} <motion.div initial={{ opacity: 0 }} @@ -1403,6 +1338,50 @@ const SlideClosing: React.FC<{ {i18n.t('wrapped.shareOptionsDesc')} </p> + <div className="mb-3 flex items-center justify-center gap-2"> + {([['story', i18n.t('wrapped.shareFormatStory')], ['poster', i18n.t('wrapped.shareFormatPoster')], ['ticket', i18n.t('wrapped.shareFormatTicket')]] as const).map(([fmt, label]) => ( + <button + key={fmt} + onClick={() => onFormatChange(fmt)} + className={`rounded-full px-4 py-1.5 text-xs font-semibold transition-colors border ${ + shareFormat === fmt + ? 'border-fuchsia-400/60 bg-fuchsia-500/25 text-white' + : 'border-white/10 bg-white/[0.04] text-white/60 hover:text-white' + }`} + > + {label} + </button> + ))} + </div> + + {/* Préviz du format sélectionné (cliquer = ouvrir en plein écran dans un nouvel onglet) */} + <div className="mb-4 flex justify-center"> + <button + type="button" + onClick={() => { if (previewUrl) window.open(previewUrl, '_blank', 'noopener'); }} + disabled={!previewUrl} + className={`relative h-48 overflow-hidden rounded-xl border border-white/10 bg-black/40 transition-transform hover:scale-[1.02] sm:h-56 ${shareFormat === 'poster' ? 'aspect-[2/3]' : 'aspect-[9/16]'}`} + > + {previewUrl ? ( + <img + src={previewUrl} + alt={i18n.t('wrapped.shareFormatsTitle')} + className="h-full w-full object-cover" + draggable={false} + /> + ) : ( + <span className="flex h-full w-full items-center justify-center"> + <Loader2 className="h-5 w-5 animate-spin text-white/40" /> + </span> + )} + {isGeneratingPreview && previewUrl && ( + <span className="absolute inset-0 flex items-center justify-center bg-black/50"> + <Loader2 className="h-5 w-5 animate-spin text-white/70" /> + </span> + )} + </button> + </div> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <motion.button whileHover={{ scale: 1.02 }} @@ -1860,6 +1839,338 @@ const SlideWatchBookends: React.FC<{ ); }; +// ========================================== +// SLIDE: TIMELINE (12 mois) +// ========================================== +const SlideTimeline: React.FC<{ slide: WrappedSlide; monthlyGraph?: WrappedData['monthlyGraph']; peakMonth: WrappedData['peakMonth'] }> = ({ slide, monthlyGraph, peakMonth }) => { + const maxMinutes = monthlyGraph && monthlyGraph.length ? Math.max(1, ...monthlyGraph.map(m => m.minutes)) : 1; + const monthShort = (m: number) => new Intl.DateTimeFormat(i18n.language, { month: 'narrow' }).format(new Date(2026, m - 1, 1)); + + return ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', duration: 0.8 }} className="mb-6"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(99, 102, 241, 0.55) 0%, transparent 70%)' }} /> + <div className="relative w-28 h-28 md:w-36 md:h-36 rounded-3xl bg-gradient-to-br from-blue-500 via-indigo-500 to-violet-600 flex items-center justify-center shadow-2xl rotate-3"> + <TrendingUp className="w-14 h-14 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> + <h2 className="text-3xl md:text-5xl font-black mb-2"> + <ShinyText text={slide.title} speed={2} color="#818cf8" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-lg text-indigo-300 mb-6 font-medium"> + {slide.subtitle} + </motion.p> + + {monthlyGraph && ( + <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} className="w-full max-w-md"> + <div className="flex items-end justify-center gap-1.5 h-32 mb-2"> + {monthlyGraph.map((m) => { + const isPeak = m.month === peakMonth.month && m.minutes > 0; + const heightPercent = m.minutes > 0 ? Math.max(8, (m.minutes / maxMinutes) * 100) : 0; + return ( + <div key={m.month} className="flex flex-col items-center gap-1 flex-1 h-full justify-end"> + <motion.div + initial={{ height: 0 }} + animate={{ height: m.minutes > 0 ? `${heightPercent}%` : '2px' }} + transition={{ duration: 0.6, delay: 0.6 + m.month * 0.04, type: 'spring', bounce: 0.2 }} + className={`w-full rounded-t-md ${isPeak ? 'bg-indigo-400 shadow-[0_0_12px_rgba(129,140,248,0.6)]' : m.minutes > 0 ? 'bg-indigo-500/60' : 'bg-white/5'}`} + /> + <span className={`text-[10px] font-mono ${isPeak ? 'text-indigo-300' : 'text-white/40'}`}>{monthShort(m.month)}</span> + </div> + ); + })} + </div> + </motion.div> + )} + + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.1 }} className="mt-4"> + <AnimatedBorderCard highlightColor="129 140 248" backgroundColor="0 0 0" className="p-4 max-w-md backdrop-blur-md"> + <p className="text-sm md:text-base text-white/85">{slide.text}</p> + {slide.subtext && <p className="mt-2 text-indigo-400/80 italic text-sm">{slide.subtext}</p>} + </AnimatedBorderCard> + </motion.div> + </WrappedCenteredSlide> + ); +}; + +// ========================================== +// SLIDE: RECORD DAY +// ========================================== +const SlideRecordDay: React.FC<{ slide: WrappedSlide }> = ({ slide }) => ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0, rotate: -12 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', duration: 0.8 }} className="mb-8"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(245, 158, 11, 0.5) 0%, transparent 70%)' }} /> + <div className="relative w-28 h-28 md:w-36 md:h-36 rounded-3xl bg-gradient-to-br from-amber-500 via-orange-500 to-red-500 flex items-center justify-center shadow-2xl -rotate-3"> + <Trophy className="w-14 h-14 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> + <h2 className="text-4xl md:text-6xl font-black mb-3"> + <ShinyText text={slide.title} speed={2} color="#f59e0b" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-xl md:text-2xl text-amber-300 mb-6 font-semibold"> + {slide.subtitle} + </motion.p> + + {slide.highlight && ( + <motion.p initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.5, type: 'spring' }} className="text-5xl md:text-7xl font-black text-white mb-8"> + {slide.highlight} + </motion.p> + )} + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.7 }}> + <AnimatedBorderCard highlightColor="245 158 11" backgroundColor="0 0 0" className="p-8 max-w-xl backdrop-blur-md"> + <p className="text-lg md:text-xl text-white/90 leading-relaxed">{slide.text}</p> + {slide.subtext && <p className="mt-4 text-amber-400/80 italic">{slide.subtext}</p>} + </AnimatedBorderCard> + </motion.div> + </WrappedCenteredSlide> +); + +// ========================================== +// SLIDE: REWATCH +// ========================================== +const SlideRewatch: React.FC<{ slide: WrappedSlide }> = ({ slide }) => ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0, rotate: 180 }} animate={{ scale: 1, rotate: 0 }} transition={{ type: 'spring', duration: 0.9 }} className="mb-8"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(16, 185, 129, 0.5) 0%, transparent 70%)' }} /> + <div className="relative w-28 h-28 md:w-36 md:h-36 rounded-3xl bg-gradient-to-br from-emerald-500 via-teal-500 to-cyan-600 flex items-center justify-center shadow-2xl rotate-3"> + <Repeat className="w-14 h-14 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> + <h2 className="text-4xl md:text-6xl font-black mb-3"> + <ShinyText text={slide.title} speed={2} color="#10b981" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-xl md:text-2xl text-emerald-300 mb-6 font-semibold"> + {slide.subtitle} + </motion.p> + + {slide.highlight && ( + <motion.p initial={{ opacity: 0, scale: 0.6 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.5, type: 'spring' }} className="text-5xl md:text-7xl font-black text-white mb-8"> + {slide.highlight} + </motion.p> + )} + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.7 }}> + <AnimatedBorderCard highlightColor="16 185 129" backgroundColor="0 0 0" className="p-8 max-w-xl backdrop-blur-md"> + <p className="text-lg md:text-xl text-white/90 leading-relaxed">{slide.text}</p> + {slide.subtext && <p className="mt-4 text-emerald-400/80 italic">{slide.subtext}</p>} + </AnimatedBorderCard> + </motion.div> + </WrappedCenteredSlide> +); + +// ========================================== +// SLIDE: WATCH AGE (âge ciné) +// ========================================== +const SlideWatchAge: React.FC<{ slide: WrappedSlide }> = ({ slide }) => ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', duration: 0.8 }} className="mb-8"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(168, 85, 247, 0.5) 0%, transparent 70%)' }} /> + <div className="relative w-28 h-28 md:w-36 md:h-36 rounded-3xl bg-gradient-to-br from-violet-500 via-purple-500 to-fuchsia-600 flex items-center justify-center shadow-2xl rotate-3"> + <Hourglass className="w-14 h-14 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + {slide.highlight && ( + <motion.p initial={{ opacity: 0, scale: 0.5 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.3, type: 'spring' }} className="text-7xl md:text-9xl font-black text-white mb-4 tracking-tight"> + {slide.highlight} + </motion.p> + )} + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}> + <h2 className="text-3xl md:text-5xl font-black mb-3"> + <ShinyText text={slide.title} speed={2} color="#a855f7" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.6 }} className="text-xl md:text-2xl text-purple-300 mb-8 font-semibold"> + {slide.subtitle} + </motion.p> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.8 }}> + <AnimatedBorderCard highlightColor="168 85 247" backgroundColor="0 0 0" className="p-8 max-w-xl backdrop-blur-md"> + <p className="text-lg md:text-xl text-white/90 leading-relaxed">{slide.text}</p> + {slide.subtext && <p className="mt-4 text-purple-400/80 italic">{slide.subtext}</p>} + </AnimatedBorderCard> + </motion.div> + </WrappedCenteredSlide> +); + +// ========================================== +// SLIDE: PAGES TIME (temps de navigation) +// ========================================== +const SlidePagesTime: React.FC<{ slide: WrappedSlide; topPages: WrappedData['topPages'] }> = ({ slide, topPages }) => { + const { t } = useTranslation(); + const pages = (topPages || []).filter(p => p.page !== 'live-tv').slice(0, 5); + const maxMinutes = pages.length ? Math.max(1, ...pages.map(p => p.minutes)) : 1; + const pageLabel = (page: string) => { + const key = `wrapped.pageNames.${page}`; + const label = t(key); + return label === key ? page : label; + }; + + return ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', duration: 0.8 }} className="mb-6"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(34, 211, 238, 0.45) 0%, transparent 70%)' }} /> + <div className="relative w-28 h-28 md:w-36 md:h-36 rounded-3xl bg-gradient-to-br from-cyan-500 via-sky-500 to-blue-600 flex items-center justify-center shadow-2xl -rotate-3"> + <MousePointerClick className="w-14 h-14 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> + <h2 className="text-3xl md:text-5xl font-black mb-2"> + <ShinyText text={slide.title} speed={2} color="#22d3ee" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-lg text-cyan-300 mb-6 font-medium"> + {slide.subtitle} + </motion.p> + + {pages.length > 0 && ( + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }} className="w-full max-w-md space-y-3 mb-6"> + {pages.map((p, i) => ( + <motion.div key={p.page} initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.6 + i * 0.1 }} className="flex items-center gap-3"> + <span className="text-sm text-white/60 w-16 text-right font-mono">{formatDurationShort(p.minutes)}</span> + <div className="flex-1 h-8 bg-white/5 rounded-lg overflow-hidden relative"> + <motion.div + initial={{ width: 0 }} + animate={{ width: `${Math.max(10, (p.minutes / maxMinutes) * 100)}%` }} + transition={{ duration: 1, delay: 0.7 + i * 0.1 }} + className="h-full bg-gradient-to-r from-cyan-500 to-sky-400 rounded-lg" + /> + <span className="absolute left-3 top-1/2 -translate-y-1/2 text-white text-sm font-medium capitalize">{pageLabel(p.page)}</span> + </div> + </motion.div> + ))} + </motion.div> + )} + + <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 1.2 }}> + <AnimatedBorderCard highlightColor="34 211 238" backgroundColor="0 0 0" className="p-4 max-w-md backdrop-blur-md"> + <p className="text-sm md:text-base text-white/85">{slide.text}</p> + {slide.subtext && <p className="mt-2 text-cyan-400/80 italic text-sm">{slide.subtext}</p>} + </AnimatedBorderCard> + </motion.div> + </WrappedCenteredSlide> + ); +}; + +// ========================================== +// SLIDE: QUIZ — "Devine ton top 1" +// ========================================== +const SlideQuiz: React.FC<{ slide: WrappedSlide; topContent: WrappedData['topContent']; onContinue: () => void }> = ({ slide, topContent, onContinue }) => { + const { t } = useTranslation(); + const [picked, setPicked] = useState<number | null>(null); + // Ordre d'affichage déterministe : top 3 réordonné par (tmdbId % 3) + const options = useMemo(() => { + const top3 = topContent.slice(0, 3); + const offset = (top3[0]?.tmdbId || 0) % 3; + return top3.map((_, i) => top3[(i + offset) % 3]); + }, [topContent]); + const revealed = picked !== null; + const isCorrect = revealed && options[picked!]?.rank === 1; + + return ( + <WrappedCenteredSlide contentClassName="max-w-4xl"> + <motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ type: 'spring', duration: 0.8 }} className="mb-6"> + <div className="relative"> + <div className="absolute inset-0 rounded-full scale-150 pointer-events-none" style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.5) 0%, transparent 70%)' }} /> + <div className="relative w-24 h-24 md:w-32 md:h-32 rounded-3xl bg-gradient-to-br from-amber-400 via-orange-500 to-rose-500 flex items-center justify-center shadow-2xl rotate-3"> + <HelpCircle className="w-12 h-12 md:w-16 md:h-16 text-white" /> + </div> + </div> + </motion.div> + + <motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> + <h2 className="text-3xl md:text-5xl font-black mb-2"> + <ShinyText text={revealed ? (isCorrect ? t('wrapped.quizCorrect') : t('wrapped.quizWrong')) : slide.title} speed={2} color="#fbbf24" shineColor="#ffffff" className="" /> + </h2> + </motion.div> + + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }} className="text-lg md:text-xl text-amber-300 mb-8 font-medium"> + {revealed ? t('wrapped.quizRevealSubtitle') : slide.subtitle} + </motion.p> + + <div className="flex items-center justify-center gap-3 md:gap-6 mb-8"> + {options.map((item, i) => { + const isTop1 = item.rank === 1; + const isPicked = picked === i; + return ( + <motion.button + key={item.rank} + initial={{ opacity: 0, y: 30 }} + animate={{ opacity: 1, y: 0, scale: revealed && isTop1 ? 1.08 : revealed && isPicked && !isTop1 ? 0.94 : 1 }} + transition={{ delay: 0.5 + i * 0.15, type: 'spring' }} + whileHover={!revealed ? { scale: 1.05, rotate: i === 1 ? 0 : i === 0 ? -2 : 2 } : undefined} + onClick={() => { if (!revealed) setPicked(i); }} + disabled={revealed} + className={`relative w-24 md:w-40 aspect-[2/3] rounded-2xl overflow-hidden border-2 transition-colors ${ + revealed && isTop1 ? 'border-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.5)]' + : revealed && isPicked ? 'border-red-400/70' + : 'border-white/15' + }`} + > + {item.poster_path ? ( + <img + src={`${TMDB_IMAGE_BASE}${item.poster_path}`} + alt="" + className={`w-full h-full object-cover transition-all duration-700 ${revealed ? 'blur-0 scale-100' : 'blur-xl scale-110'}`} + draggable={false} + /> + ) : ( + <div className="w-full h-full bg-gradient-to-br from-white/10 to-white/5 flex items-center justify-center text-3xl">🎬</div> + )} + {revealed && ( + <div className="absolute inset-x-0 bottom-0 bg-black/80 px-2 py-1.5"> + <p className="text-[10px] md:text-xs text-white font-semibold truncate">#{item.rank} · {item.title}</p> + </div> + )} + </motion.button> + ); + })} + </div> + + {!revealed ? ( + <motion.button initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 1.2 }} onClick={onContinue} className="text-sm text-white/50 underline underline-offset-4 hover:text-white/80 transition-colors"> + {t('wrapped.quizSkip')} + </motion.button> + ) : ( + <motion.button + initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.6 }} + onClick={onContinue} + className="rounded-2xl border border-amber-400/30 bg-gradient-to-r from-amber-500/20 to-orange-500/20 px-6 py-3 text-sm font-semibold text-white" + > + {t('wrapped.quizContinue')} + </motion.button> + )} + </WrappedCenteredSlide> + ); +}; + // ========================================== // SLIDE BACKGROUNDS // ========================================== @@ -1879,6 +2190,12 @@ const slideBackgrounds: Record<string, { color: string; gradient: string }> = { 'watch-bookends': { color: 'rgba(52, 211, 153, 0.15)', gradient: 'from-emerald-500/20 via-transparent to-transparent' }, closing: { color: 'rgba(232, 121, 249, 0.15)', gradient: 'from-fuchsia-500/20 via-transparent to-transparent' }, 'detailed-stats': { color: 'rgba(34, 211, 238, 0.15)', gradient: 'from-cyan-500/20 via-transparent to-transparent' }, + timeline: { color: 'rgba(129, 140, 248, 0.15)', gradient: 'from-indigo-500/20 via-transparent to-transparent' }, + 'record-day': { color: 'rgba(245, 158, 11, 0.15)', gradient: 'from-amber-500/20 via-transparent to-transparent' }, + rewatch: { color: 'rgba(16, 185, 129, 0.15)', gradient: 'from-emerald-500/20 via-transparent to-transparent' }, + 'watch-age': { color: 'rgba(168, 85, 247, 0.15)', gradient: 'from-purple-500/20 via-transparent to-transparent' }, + 'pages-time': { color: 'rgba(34, 211, 238, 0.15)', gradient: 'from-cyan-500/20 via-transparent to-transparent' }, + quiz: { color: 'rgba(251, 191, 36, 0.15)', gradient: 'from-amber-400/20 via-transparent to-transparent' }, }; // ========================================== @@ -1896,6 +2213,7 @@ const WrappedPage: React.FC = () => { const [noData, setNoData] = useState(false); const [wrappedProgress, setWrappedProgress] = useState<WrappedProgress | null>(null); const [isPreparingShareImage, setIsPreparingShareImage] = useState(false); + const [shareFormat, setShareFormat] = useState<'story' | 'poster' | 'ticket'>('story'); const [isPodiumTrailerLoaded, setIsPodiumTrailerLoaded] = useState(false); const bgMode = (localStorage.getItem('settings_bg_mode') as 'combined' | 'static' | 'animated') || 'combined'; const hasWrappedAccount = Boolean(localStorage.getItem('auth_token')); @@ -2084,70 +2402,47 @@ const WrappedPage: React.FC = () => { ); } - // Fetch TMDB data for top content - const fetchTMDBData = useCallback(async (topContent: WrappedTopContent[]) => { - const newTmdbData = new Map<number, TMDBData>(); - - const fetchPromises = topContent.map(async (item, index) => { + // Le payload backend contient déjà poster/backdrop/year/vote — zéro re-fetch TMDB. + const buildTmdbDataFromPayload = useCallback((topContent: WrappedTopContent[]) => { + const map = new Map<number, TMDBData>(); + topContent.forEach((item) => { if (!item.tmdbId) return; - - try { - const mediaType = item.type === 'tv' || item.type === 'anime' ? 'tv' : 'movie'; - const response = await axios.get( - `https://api.themoviedb.org/3/${mediaType}/${item.tmdbId}`, - { params: { api_key: TMDB_API_KEY, language: getTmdbLanguage() } } - ); - const data: TMDBData = response.data; - - // Fetch trailer for the podium items so the background video can follow top 1, 2 and 3. - if (index < 3) { - try { - const videosRes = await axios.get( - `https://api.themoviedb.org/3/${mediaType}/${item.tmdbId}/videos`, - { params: { api_key: TMDB_API_KEY, language: getTmdbLanguage() } } - ); - let trailer = videosRes.data.results?.find( - (v: any) => v.site === 'YouTube' && v.type === 'Trailer' - ); - // Fallback: try English trailers - if (!trailer) { - const videosResEN = await axios.get( - `https://api.themoviedb.org/3/${mediaType}/${item.tmdbId}/videos`, - { params: { api_key: TMDB_API_KEY, language: 'en-US' } } - ); - trailer = videosResEN.data.results?.find( - (v: any) => v.site === 'YouTube' && v.type === 'Trailer' - ); - // Last fallback: any YouTube video (teaser, clip, etc.) - if (!trailer) { - trailer = videosResEN.data.results?.find( - (v: any) => v.site === 'YouTube' - ); - } - } - data.trailerKey = trailer?.key || null; - } catch { - data.trailerKey = null; - } - } - - newTmdbData.set(item.tmdbId, data); - } catch (error) { - console.error(`[Wrapped] Error fetching TMDB data for ${item.tmdbId}:`, error); - } + map.set(item.tmdbId, { + id: item.tmdbId, + title: item.title, + name: item.title, + poster_path: item.poster_path ?? null, + backdrop_path: item.backdrop_path ?? null, + vote_average: item.vote_average ?? undefined, + release_date: item.year ? `${item.year}-01-01` : undefined, + genres: (item.genres || []).map((name, i) => ({ id: i, name })), + // trailerKey: undefined = pas encore fetché (lazy au slide podium) + }); }); - - await Promise.all(fetchPromises); - setTmdbData(newTmdbData); + setTmdbData(map); }, []); useEffect(() => { const loadWrapped = async () => { setLoading(true); - const response = await fetchWrappedData(year); + const profileId = localStorage.getItem('selected_profile_id') || 'default'; + const sessionKey = `wrapped:${year}:${profileId}`; + let response: WrappedResponse | null = null; - if (response.success && response.wrapped) { + const cachedRaw = sessionStorage.getItem(sessionKey); + if (cachedRaw) { + try { response = JSON.parse(cachedRaw) as WrappedResponse; } catch { /* refetch */ } + } + + if (!response) { + response = await fetchWrappedData(year); + if (response.success && response.wrapped) { + try { sessionStorage.setItem(sessionKey, JSON.stringify(response)); } catch { /* quota — tant pis */ } + } + } + + if (response?.success && response.wrapped) { // Hotfix: Ensure detailed-stats slide exists if backend doesn't send it yet const hasStats = response.wrapped.slides.some((s: WrappedSlide) => s.type === 'detailed-stats'); if (!hasStats) { @@ -2205,6 +2500,22 @@ const WrappedPage: React.FC = () => { response.wrapped.slides.splice(top1Index, 0, ...podiumSlides); } + // Quiz "devine ton top 1" — avant le podium (3 titres avec posters requis) + const hasQuiz = response.wrapped.slides.some((s: WrappedSlide) => s.type === 'quiz'); + const quizEligible = !hasQuiz && response.wrapped.topContent.slice(0, 3).filter((c: WrappedTopContent) => c.poster_path).length === 3; + if (quizEligible) { + const firstPodiumIndex = response.wrapped.slides.findIndex((s: WrappedSlide) => + s.type === 'top3-focus' || s.type === 'top2-focus' || s.type === 'top1'); + if (firstPodiumIndex !== -1) { + response.wrapped.slides.splice(firstPodiumIndex, 0, { + type: 'quiz', + title: t('wrapped.quizTitle'), + subtitle: t('wrapped.quizSubtitle'), + text: '' + }); + } + } + const bonusSlides: WrappedSlide[] = []; const hasSessionSummary = response.wrapped.slides.some((s: WrappedSlide) => s.type === 'session-summary'); const hasWatchBookends = response.wrapped.slides.some((s: WrappedSlide) => s.type === 'watch-bookends'); @@ -2234,9 +2545,12 @@ const WrappedPage: React.FC = () => { } if (bonusSlides.length > 0) { + const recordDayIndex = response.wrapped.slides.findIndex((s: WrappedSlide) => s.type === 'record-day'); + const rewatchIndex = response.wrapped.slides.findIndex((s: WrappedSlide) => s.type === 'rewatch'); const top5Index = response.wrapped.slides.findIndex((s: WrappedSlide) => s.type === 'top5'); - const insertIndex = top5Index !== -1 - ? top5Index + 1 + const anchorIndex = recordDayIndex !== -1 ? recordDayIndex : rewatchIndex !== -1 ? rewatchIndex : top5Index; + const insertIndex = anchorIndex !== -1 + ? anchorIndex + 1 : response.wrapped.slides.findIndex((s: WrappedSlide) => s.type === 'persona'); if (insertIndex !== -1) { @@ -2249,12 +2563,12 @@ const WrappedPage: React.FC = () => { setWrappedData(response.wrapped); setWrappedProgress(response.progress ?? null); setNoData(false); - // Fetch TMDB data for posters - fetchTMDBData(response.wrapped.topContent); + // Build TMDB data map from enriched backend payload (zero network) + buildTmdbDataFromPayload(response.wrapped.topContent); } else { // No data available for this user/year setWrappedData(null); - setWrappedProgress(response.progress ?? null); + setWrappedProgress(response?.progress ?? null); setNoData(true); } @@ -2262,7 +2576,7 @@ const WrappedPage: React.FC = () => { }; loadWrapped(); - }, [year, fetchTMDBData]); + }, [year, buildTmdbDataFromPayload, t]); const goToSlide = useCallback((index: number) => { if (!wrappedData) return; @@ -2303,6 +2617,44 @@ const WrappedPage: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, [nextSlide, prevSlide, navigate]); + // Trailers : fetch lazy à l'arrivée sur une slide podium uniquement + useEffect(() => { + if (!wrappedData) return; + const slideType = wrappedData.slides[currentSlide]?.type; + const idx = slideType === 'top1' ? 0 : slideType === 'top2-focus' ? 1 : slideType === 'top3-focus' ? 2 : -1; + if (idx === -1) return; + const item = wrappedData.topContent[idx]; + if (!item?.tmdbId) return; + const existing = tmdbData.get(item.tmdbId); + if (existing && existing.trailerKey !== undefined) return; // déjà fetché + + let cancelled = false; + (async () => { + let trailerKey: string | null = null; + try { + const mediaType = item.type === 'tv' || item.type === 'anime' ? 'tv' : 'movie'; + const res = await axios.get(`https://api.themoviedb.org/3/${mediaType}/${item.tmdbId}/videos`, { params: { api_key: TMDB_API_KEY, language: getTmdbLanguage() } }); + let trailer = res.data.results?.find((v: any) => v.site === 'YouTube' && v.type === 'Trailer'); + if (!trailer) { + const resEN = await axios.get(`https://api.themoviedb.org/3/${mediaType}/${item.tmdbId}/videos`, { params: { api_key: TMDB_API_KEY, language: 'en-US' } }); + trailer = resEN.data.results?.find((v: any) => v.site === 'YouTube' && v.type === 'Trailer') + || resEN.data.results?.find((v: any) => v.site === 'YouTube'); + } + trailerKey = trailer?.key || null; + } catch { + trailerKey = null; + } + if (cancelled) return; + setTmdbData(prev => { + const next = new Map(prev); + const cur = next.get(item.tmdbId!); + if (cur) next.set(item.tmdbId!, { ...cur, trailerKey }); + return next; + }); + })(); + return () => { cancelled = true; }; + }, [currentSlide, wrappedData, tmdbData]); + const handleDragEnd = (_: any, info: PanInfo) => { const threshold = 50; if (info.offset.x < -threshold) nextSlide(); @@ -2311,6 +2663,7 @@ const WrappedPage: React.FC = () => { const generateWrappedShareImage = useCallback(async (): Promise<Blob | null> => { if (!wrappedData) return null; + await ensureShareFonts(); const canvas = document.createElement('canvas'); canvas.width = WRAPPED_SHARE_IMAGE_WIDTH; @@ -2371,7 +2724,7 @@ const WrappedPage: React.FC = () => { textColor, paddingX = 22, height: pillHeight = 42, - font = '800 22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + font = '800 22px Inter, system-ui, sans-serif', stroke, }: { fill: string | CanvasGradient; @@ -2466,7 +2819,7 @@ const WrappedPage: React.FC = () => { ctx.fill(); ctx.fillStyle = '#ffffff'; - ctx.font = `${withMeta ? '900 118px' : '900 68px'} system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`; + ctx.font = `${withMeta ? '900 118px' : '900 68px'} Inter, system-ui, sans-serif`; const letterWidth = ctx.measureText(fallbackLabel).width; ctx.fillText(fallbackLabel, -letterWidth / 2, withMeta ? 34 : 24); } @@ -2475,7 +2828,7 @@ const WrappedPage: React.FC = () => { ctx.fillStyle = chipFill; ctx.fill(); ctx.fillStyle = chipTextColor; - ctx.font = '900 24px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '900 24px Inter, system-ui, sans-serif'; ctx.fillText(rank, -w / 2 + 44, -h / 2 + 46); if (withMeta) { @@ -2487,11 +2840,11 @@ const WrappedPage: React.FC = () => { ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.82)'; - ctx.font = '700 20px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '700 20px Inter, system-ui, sans-serif'; fillTextCenteredInArea(topTypeLabel, -w / 2 + 20, w - 40, h / 2 - 88); ctx.fillStyle = '#ffffff'; - ctx.font = '900 30px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '900 30px Inter, system-ui, sans-serif'; fillTextCenteredInArea(topItem ? formatCompactDuration(topItem.minutes, t) : formattedShareWatchTime, -w / 2 + 20, w - 40, h / 2 - 46); } @@ -2569,32 +2922,10 @@ const WrappedPage: React.FC = () => { ctx.fill(); } - for (let i = 0; i < 40; i++) { - const isPopcorn = Math.random() > 0.5; - const x = Math.random() * 1080; - const y = Math.random() * 1920; - const scale = 1.0 + Math.random() * 1.5; - const rotation = Math.random() * Math.PI * 2; - const opacity = 0.05 + Math.random() * 0.15; - const colors = ['#4ecdc4', '#ff7a59', '#f6c453', '#ff5f56']; - const accent = colors[Math.floor(Math.random() * colors.length)]; - - if (isPopcorn) { - drawPopcornSticker(ctx, { - x, y, scale, rotation, opacity, - fillLevel: 0.3 + Math.random() * 0.7, - accent - }); - } else { - drawClapperSticker(ctx, { - x, y, scale, rotation, opacity, - accent - }); - } - } + drawSeededStickers(ctx, width, height, (topItem?.tmdbId || 0) + year); const brandIconX = 280; - const brandIconY = 86; + const brandIconY = 226; ctx.strokeStyle = '#ff5f56'; ctx.lineWidth = 7; drawRoundedRectPath(ctx, brandIconX, brandIconY, 54, 38, 12); @@ -2607,31 +2938,31 @@ const WrappedPage: React.FC = () => { ctx.stroke(); ctx.fillStyle = '#ffffff'; - ctx.font = '900 54px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.fillText('MOVIX', 356, 122); + ctx.font = '900 54px Inter, system-ui, sans-serif'; + ctx.fillText('MOVIX', 356, 262); ctx.fillStyle = accentGradient; - ctx.fillText('Wrapped', 557, 122); + ctx.fillText('Wrapped', 557, 262); ctx.save(); ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 3; - ctx.font = '900 184px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.strokeText(String(year), (width - ctx.measureText(String(year)).width) / 2, 268); + ctx.font = '900 184px Inter, system-ui, sans-serif'; + ctx.strokeText(String(year), (width - ctx.measureText(String(year)).width) / 2, 396); ctx.restore(); ctx.fillStyle = 'rgba(255,255,255,0.52)'; - ctx.font = '800 22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - fillCenteredText(`MOVIX WRAPPED ${year}`, 188); + ctx.font = '800 22px Inter, system-ui, sans-serif'; + fillCenteredText(`MOVIX WRAPPED ${year}`, 330); ctx.fillStyle = '#ffffff'; - ctx.font = '900 68px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - fillCenteredText(`Ton top ${year}`, 284); + ctx.font = '900 68px Inter, system-ui, sans-serif'; + fillCenteredText(`Ton top ${year}`, 404); ctx.fillStyle = titleGradient; - ctx.font = '900 88px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '900 82px Inter, system-ui, sans-serif'; const mainTitleLines = wrapCanvasText(ctx, topItem?.title || t('wrapped.title'), 860).slice(0, 2); mainTitleLines.forEach((line, index) => { - fillCenteredText(line, 386 + index * 92); + fillCenteredText(line, 488 + index * 86); }); const chips = [ @@ -2640,11 +2971,11 @@ const WrappedPage: React.FC = () => { { text: '#1', fill: 'rgba(246,196,83,0.2)', textColor: '#f6c453', stroke: 'rgba(246,196,83,0.35)' }, ]; - ctx.font = '800 22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '800 22px Inter, system-ui, sans-serif'; const chipWidths = chips.map(({ text }) => Math.ceil(ctx.measureText(text).width + 44)); const totalChipWidth = chipWidths.reduce((sum, item) => sum + item, 0) + (chips.length - 1) * 14; let chipX = (width - totalChipWidth) / 2; - const chipY = 468; + const chipY = 620; chips.forEach((chip, index) => { const usedWidth = drawPill(chipX, chipY, chip.text, { fill: chip.fill, @@ -2654,137 +2985,88 @@ const WrappedPage: React.FC = () => { chipX += usedWidth + (index < chips.length - 1 ? 14 : 0); }); - drawPosterCard({ - x: 212, - y: 694, - w: 224, - h: 334, - rotation: -0.14, - image: exportTopAssets[1]?.posterImage || null, - fallbackLabel: getTypeInitial(exportTopAssets[1]?.item.type), - rank: '#2', - borderColor: 'rgba(246,196,83,0.8)', - chipFill: '#f6c453', - chipTextColor: '#181818', - }); - - drawPosterCard({ - x: 634, - y: 694, - w: 224, - h: 334, - rotation: 0.14, - image: exportTopAssets[2]?.posterImage || null, - fallbackLabel: getTypeInitial(exportTopAssets[2]?.item.type), - rank: '#3', - borderColor: 'rgba(78,205,196,0.78)', - chipFill: '#4ecdc4', - chipTextColor: '#0f1212', - }); - - drawPosterCard({ - x: 334, - y: 556, - w: 412, - h: 612, - rotation: -0.035, - image: posterImage, - fallbackLabel: getTypeInitial(topItem?.type), - rank: '#1', - borderColor: 'rgba(255,122,89,0.96)', - chipFill: '#ff7a59', - chipTextColor: '#190d0b', - withMeta: true, - }); + drawPosterCard({ x: 222, y: 700, w: 210, h: 300, rotation: -0.14, image: exportTopAssets[1]?.posterImage || null, fallbackLabel: getTypeInitial(exportTopAssets[1]?.item.type), rank: '#2', borderColor: 'rgba(246,196,83,0.8)', chipFill: '#f6c453', chipTextColor: '#181818' }); + drawPosterCard({ x: 648, y: 700, w: 210, h: 300, rotation: 0.14, image: exportTopAssets[2]?.posterImage || null, fallbackLabel: getTypeInitial(exportTopAssets[2]?.item.type), rank: '#3', borderColor: 'rgba(78,205,196,0.78)', chipFill: '#4ecdc4', chipTextColor: '#0f1212' }); + drawPosterCard({ x: 345, y: 668, w: 390, h: 560, rotation: -0.035, image: posterImage, fallbackLabel: getTypeInitial(topItem?.type), rank: '#1', borderColor: 'rgba(255,122,89,0.96)', chipFill: '#ff7a59', chipTextColor: '#190d0b', withMeta: true }); ctx.fillStyle = 'rgba(255,255,255,0.74)'; - ctx.font = '700 22px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - fillCenteredText(t('wrapped.top3FocusSubtitle'), 1234); + ctx.font = '700 22px Inter, system-ui, sans-serif'; + fillCenteredText(t('wrapped.shareTop3Label'), 1268); - const statsBoxes = [ - { - label: wrappedData.stats.totalHours > 0 ? t('wrapped.statHours') : t('wrapped.statMinutes'), - value: wrappedData.stats.totalHours > 0 ? wrappedData.stats.totalHours.toLocaleString(i18n.language) : wrappedData.stats.totalMinutes.toLocaleString(i18n.language), - accent: '#ff7a59', - }, - { label: t('wrapped.statTitles'), value: wrappedData.stats.uniqueTitles.toLocaleString(i18n.language), accent: '#f6c453' }, - { label: t('wrapped.statSessions'), value: wrappedData.stats.totalSessions.toLocaleString(i18n.language), accent: '#4ecdc4' } + const streakCount = wrappedData.stats.longestStreak || 0; + const pctl = wrappedData.stats.percentile; + const lastTile = (pctl != null && pctl >= 90) + ? { label: t('wrapped.shareTopPercentLabel').toUpperCase(), value: `TOP ${100 - pctl}%`, accent: '#a78bfa' } + : { label: t('wrapped.shareStreakLabel').toUpperCase(), value: t('wrapped.shareStreakValue', { days: streakCount }), accent: '#a78bfa' }; + + const tiles = [ + { label: (wrappedData.stats.totalHours > 0 ? t('wrapped.statHours') : t('wrapped.statMinutes')).toUpperCase(), value: (wrappedData.stats.totalHours > 0 ? wrappedData.stats.totalHours : wrappedData.stats.totalMinutes).toLocaleString(i18n.language), accent: '#ff7a59' }, + { label: t('wrapped.statTitles').toUpperCase(), value: wrappedData.stats.uniqueTitles.toLocaleString(i18n.language), accent: '#f6c453' }, + { label: t('wrapped.statSessions').toUpperCase(), value: wrappedData.stats.totalSessions.toLocaleString(i18n.language), accent: '#4ecdc4' }, + { label: t('wrapped.sharePersonaLabel').toUpperCase(), value: `${wrappedData.persona.emoji} ${wrappedData.persona.title}`, accent: wrappedData.persona.color || '#ff7a59' }, + { label: t('wrapped.shareGenreLabel').toUpperCase(), value: topWrappedGenre?.name || t('wrapped.shareGenreFallback'), accent: '#f6c453' }, + lastTile, ]; - statsBoxes.forEach((stat, index) => { - const boxWidth = 286; - const gap = 31; - const x = 80 + index * (boxWidth + gap); - const y = 1268; - drawRoundedRectPath(ctx, x, y, boxWidth, 154, 30); - ctx.fillStyle = 'rgba(17, 17, 20, 0.92)'; - ctx.fill(); - ctx.strokeStyle = 'rgba(255,255,255,0.1)'; - ctx.lineWidth = 2; - ctx.stroke(); + const tileW = 296, tileH = 130, tileGap = 16; + tiles.forEach((tile, index) => { + const col = index % 3; + const row = Math.floor(index / 3); + const tileX = 78 + col * (tileW + tileGap); + const tileY = 1310 + row * (tileH + tileGap + 2); - drawRoundedRectPath(ctx, x + 20, y + 18, 60, 8, 4); - ctx.fillStyle = stat.accent; - ctx.fill(); - - ctx.fillStyle = '#ffffff'; - ctx.font = '900 58px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.fillText(stat.value, x + 24, y + 86); - - ctx.fillStyle = 'rgba(255,255,255,0.52)'; - ctx.font = '800 18px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.fillText(stat.label.toUpperCase(), x + 24, y + 124); - }); - - ctx.fillStyle = '#ffffff'; - ctx.font = '800 30px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.fillText(t('wrapped.shareSnapshotTitle'), 78, 1496); - - const snapshotTiles = [ - { label: t('wrapped.sharePersonaLabel'), value: wrappedData.persona.title, accent: '#ff7a59' }, - { label: t('wrapped.shareGenreLabel'), value: topWrappedGenre?.name || t('wrapped.shareGenreFallback'), accent: '#f6c453' }, - { label: t('wrapped.sharePeakMonthLabel'), value: wrappedData.peakMonth.name, accent: '#4ecdc4' }, - { label: t('wrapped.shareWatchTimeLabel'), value: formattedShareWatchTime, accent: '#ffffff' } - ]; - - const snapshotTileStartY = 1528; - const snapshotTileHeight = 136; - const snapshotTileGapY = 28; - const snapshotBottomY = snapshotTileStartY + snapshotTileHeight * 2 + snapshotTileGapY; - const footerDividerY = snapshotBottomY + 32; - const footerTextY = footerDividerY + 42; - - snapshotTiles.forEach((tile, index) => { - const col = index % 2; - const row = Math.floor(index / 2); - const tileX = 78 + col * 462; - const tileY = snapshotTileStartY + row * (snapshotTileHeight + snapshotTileGapY); - const tileWidth = 384; - const tileHeight = snapshotTileHeight; - - drawRoundedRectPath(ctx, tileX, tileY, tileWidth, tileHeight, 28); + drawRoundedRectPath(ctx, tileX, tileY, tileW, tileH, 26); ctx.fillStyle = 'rgba(16,16,18,0.92)'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.1)'; ctx.lineWidth = 2; ctx.stroke(); - drawRoundedRectPath(ctx, tileX + 20, tileY + 20, 70, 8, 4); + drawRoundedRectPath(ctx, tileX + 18, tileY + 16, 54, 7, 4); ctx.fillStyle = tile.accent; ctx.fill(); ctx.fillStyle = 'rgba(255,255,255,0.48)'; - ctx.font = '800 16px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - ctx.fillText(tile.label.toUpperCase(), tileX + 20, tileY + 56); + ctx.font = '800 15px Inter, system-ui, sans-serif'; + ctx.fillText(tile.label, tileX + 18, tileY + 48); ctx.fillStyle = '#ffffff'; - ctx.font = '800 30px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - const tileLines = wrapCanvasText(ctx, tile.value, tileWidth - 40).slice(0, 2); + const isBigNumber = index < 3; + ctx.font = isBigNumber ? '900 44px Inter, system-ui, sans-serif' : '800 23px Inter, system-ui, sans-serif'; + const tileLines = wrapCanvasText(ctx, tile.value, tileW - 36).slice(0, 2); tileLines.forEach((line, lineIndex) => { - ctx.fillText(line, tileX + 20, tileY + 96 + lineIndex * 34); + ctx.fillText(line, tileX + 18, tileY + (isBigNumber ? 102 : 82) + lineIndex * 27); }); }); + // Barre des genres (top 3 empilés) + if (wrappedData.topGenres && wrappedData.topGenres.length > 0) { + const segs = wrappedData.topGenres.slice(0, 3); + const segTotal = segs.reduce((s, g) => s + g.percent, 0) || 1; + const segColors = ['#ff7a59', '#f6c453', '#4ecdc4']; + const barX = 78, barW = width - 156, barY = 1638, barH = 34; + + ctx.save(); + drawRoundedRectPath(ctx, barX, barY, barW, barH, 17); + ctx.clip(); + let segX = barX; + segs.forEach((g, i) => { + const w = barW * (g.percent / segTotal); + ctx.fillStyle = segColors[i]; + ctx.fillRect(segX, barY, w, barH); + if (w > 150) { + ctx.fillStyle = 'rgba(10,10,12,0.85)'; + ctx.font = '800 17px Inter, system-ui, sans-serif'; + ctx.fillText(`${g.name} ${g.percent}%`, segX + 14, barY + 23); + } + segX += w; + }); + ctx.restore(); + } + + const footerDividerY = 1706; + const footerTextY = 1748; + ctx.strokeStyle = 'rgba(255,255,255,0.08)'; ctx.lineWidth = 1.5; ctx.beginPath(); @@ -2793,12 +3075,12 @@ const WrappedPage: React.FC = () => { ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.76)'; - ctx.font = '800 26px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + ctx.font = '800 26px Inter, system-ui, sans-serif'; ctx.fillText(t('wrapped.shareFooterTag'), 78, footerTextY); ctx.fillStyle = 'rgba(255,255,255,0.36)'; - ctx.font = '600 26px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; - const domainLabel = 'movix.tax'; + ctx.font = '600 26px Inter, system-ui, sans-serif'; + const domainLabel = 'movix.date'; ctx.fillText(domainLabel, width - 78 - ctx.measureText(domainLabel).width, footerTextY); return new Promise((resolve) => { @@ -2806,18 +3088,91 @@ const WrappedPage: React.FC = () => { }); }, [formattedShareWatchTime, t, tmdbData, topWrappedGenre?.name, topWrappedItem, wrappedData, year]); + const buildShareCardData = useCallback((): WrappedShareCardData | null => { + if (!wrappedData) return null; + const top3 = wrappedData.topContent.slice(0, 3); + const top1 = top3[0]; + const tmdb = top1?.tmdbId ? tmdbData.get(top1.tmdbId) : null; + return { + year, + totalHours: wrappedData.stats.totalHours, + totalMinutes: wrappedData.stats.totalMinutes, + uniqueTitles: wrappedData.stats.uniqueTitles, + totalSessions: wrappedData.stats.totalSessions, + longestStreak: wrappedData.stats.longestStreak || 0, + peakHour: wrappedData.peakHour ?? null, + peakMonthName: wrappedData.peakMonth.name, + peakMonthIndex: wrappedData.peakMonth.month, + personaTitle: wrappedData.persona.title, + personaEmoji: wrappedData.persona.emoji, + personaColor: wrappedData.persona.color, + topTitles: wrappedData.topContent.slice(0, 5).map(c => c.title), + topGenreName: topWrappedGenre?.name || null, + watchTimeLabel: formattedShareWatchTime, + topPosterUrl: top1?.poster_path ? `${TMDB_IMAGE_BASE}${top1.poster_path}` : null, + topBackdropUrl: (tmdb?.backdrop_path || top1?.backdrop_path) ? `https://image.tmdb.org/t/p/w1280${tmdb?.backdrop_path || top1?.backdrop_path}` : null, + posterUrls: top3.map(c => (c.poster_path ? `${TMDB_IMAGE_BASE}${c.poster_path}` : null)), + seed: (top1?.tmdbId || 0) + year, + }; + }, [wrappedData, tmdbData, topWrappedGenre?.name, formattedShareWatchTime, year]); + + const generateShareBlob = useCallback(async (): Promise<Blob | null> => { + if (shareFormat === 'story') return generateWrappedShareImage(); + const data = buildShareCardData(); + if (!data) return null; + return shareFormat === 'poster' ? generatePosterShareImage(data) : generateTicketShareImage(data); + }, [shareFormat, generateWrappedShareImage, buildShareCardData]); + + // Cache des blobs par format : partagé entre la préviz, le téléchargement et le partage + // (une seule génération canvas par format et par session de slide). + const shareBlobCacheRef = useRef<Partial<Record<'story' | 'poster' | 'ticket', Blob>>>({}); + const [sharePreviewUrl, setSharePreviewUrl] = useState<string | null>(null); + const [isGeneratingPreview, setIsGeneratingPreview] = useState(false); + + const getOrGenerateShareBlob = useCallback(async (): Promise<Blob | null> => { + const cached = shareBlobCacheRef.current[shareFormat]; + if (cached) return cached; + const blob = await generateShareBlob(); + if (blob) shareBlobCacheRef.current[shareFormat] = blob; + return blob; + }, [shareFormat, generateShareBlob]); + + // Préviz : générée à l'arrivée sur la slide finale et à chaque changement de format. + const isClosingSlideActive = wrappedData?.slides[currentSlide]?.type === 'closing'; + useEffect(() => { + if (!isClosingSlideActive || !wrappedData) return; + let cancelled = false; + (async () => { + setIsGeneratingPreview(true); + try { + const blob = await getOrGenerateShareBlob(); + if (cancelled || !blob) return; + const url = URL.createObjectURL(blob); + setSharePreviewUrl(prev => { + if (prev) URL.revokeObjectURL(prev); + return url; + }); + } catch (error) { + console.error('[Wrapped] Unable to build share preview:', error); + } finally { + if (!cancelled) setIsGeneratingPreview(false); + } + })(); + return () => { cancelled = true; }; + }, [isClosingSlideActive, wrappedData, getOrGenerateShareBlob]); + const handleDownloadShareImage = useCallback(async () => { if (!wrappedData) return; setIsPreparingShareImage(true); try { - const blob = await generateWrappedShareImage(); + const blob = await getOrGenerateShareBlob(); if (!blob) { toast.error(t('wrapped.shareError')); return; } - downloadBlob(blob, `movix-wrapped-${year}.png`); + downloadBlob(blob, `movix-wrapped-${year}-${shareFormat}.png`); toast.success(t('wrapped.imageDownloaded')); } catch (error) { console.error('[Wrapped] Unable to download share image:', error); @@ -2825,20 +3180,20 @@ const WrappedPage: React.FC = () => { } finally { setIsPreparingShareImage(false); } - }, [generateWrappedShareImage, t, wrappedData, year]); + }, [getOrGenerateShareBlob, shareFormat, t, wrappedData, year]); const handleShareImage = useCallback(async () => { if (!wrappedData) return; setIsPreparingShareImage(true); try { - const blob = await generateWrappedShareImage(); + const blob = await getOrGenerateShareBlob(); if (!blob) { toast.error(t('wrapped.shareError')); return; } - const file = new File([blob], `movix-wrapped-${year}.png`, { type: 'image/png' }); + const file = new File([blob], `movix-wrapped-${year}-${shareFormat}.png`, { type: 'image/png' }); if (navigator.share && typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) { await navigator.share({ title: t('wrapped.shareTitle', { year }), @@ -2848,7 +3203,7 @@ const WrappedPage: React.FC = () => { return; } - downloadBlob(blob, `movix-wrapped-${year}.png`); + downloadBlob(blob, `movix-wrapped-${year}-${shareFormat}.png`); toast.success(t('wrapped.imageShareFallback')); } catch (error) { if (!isShareAbortError(error)) { @@ -2858,7 +3213,7 @@ const WrappedPage: React.FC = () => { } finally { setIsPreparingShareImage(false); } - }, [generateWrappedShareImage, t, wrappedData, wrappedShareText, year]); + }, [getOrGenerateShareBlob, shareFormat, t, wrappedData, wrappedShareText, year]); const handleCopyShareText = useCallback(async () => { try { @@ -2904,7 +3259,7 @@ const WrappedPage: React.FC = () => { case 'persona': return <SlidePersona slide={slide} persona={wrappedData.persona} />; case 'peak-month': return <SlidePeakMonth slide={slide} peakMonth={wrappedData.peakMonth} />; case 'top-genres': return <SlideTopGenres slide={slide} topGenres={wrappedData.topGenres} />; - case 'listening-clock': return <SlideListeningClock slide={slide} listeningClock={wrappedData.listeningClock} peakHour={wrappedData.peakHour} />; + case 'listening-clock': return <SlideListeningClock slide={slide} listeningClock={wrappedData.listeningClock} peakHour={wrappedData.peakHour} weekday={wrappedData.weekday} />; case 'streak': return <SlideStreak slide={slide} stats={wrappedData.stats} />; case 'fun-fact': return <SlideFunFact slide={slide} />; case 'session-summary': return <SlideSessionSummary slide={slide} stats={wrappedData.stats} />; @@ -2913,14 +3268,26 @@ const WrappedPage: React.FC = () => { <SlideClosing slide={slide} stats={wrappedData.stats} + topContent={wrappedData.topContent} + topGenres={wrappedData.topGenres} onShareImage={handleShareImage} onDownloadImage={handleDownloadShareImage} onShareText={handleShareText} onCopyText={handleCopyShareText} isPreparingImage={isPreparingShareImage} + shareFormat={shareFormat} + onFormatChange={setShareFormat} + previewUrl={sharePreviewUrl} + isGeneratingPreview={isGeneratingPreview} /> ); case 'detailed-stats': return <SlideDetailedStats slide={slide} data={wrappedData} tmdbData={tmdbData} />; + case 'timeline': return <SlideTimeline slide={slide} monthlyGraph={wrappedData.monthlyGraph} peakMonth={wrappedData.peakMonth} />; + case 'record-day': return <SlideRecordDay slide={slide} />; + case 'rewatch': return <SlideRewatch slide={slide} />; + case 'watch-age': return <SlideWatchAge slide={slide} />; + case 'pages-time': return <SlidePagesTime slide={slide} topPages={wrappedData.topPages} />; + case 'quiz': return <SlideQuiz slide={slide} topContent={wrappedData.topContent} onContinue={nextSlide} />; default: return null; } }; @@ -2994,20 +3361,19 @@ const WrappedPage: React.FC = () => { return ( <SquareBackground mode={bgMode} borderColor="rgba(168, 85, 247, 0.15)" className="fixed inset-0 z-50 bg-black flex items-center justify-center"> <div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(168,85,247,0.2),transparent_50%)]" /> - <div className="flex flex-col items-center justify-center w-full h-full"> - <motion.div - initial={{ opacity: 0, scale: 0.8 }} - animate={{ opacity: 1, scale: 1 }} - className="text-center relative z-10" - > - <motion.div - animate={{ rotate: 360 }} - transition={{ duration: 2, repeat: Infinity, ease: 'linear' }} - className="w-20 h-20 mx-auto mb-6 rounded-full border-4 border-purple-500/30 border-t-purple-400" - /> - <h2 className="text-2xl font-bold text-white mb-2">{t('wrapped.preparingWrapped')}</h2> - <p className="text-purple-400">{t('wrapped.analyzingYear')}</p> - </motion.div> + <div className="relative z-10 mx-auto flex w-full max-w-md flex-col items-center px-6 sm:px-0"> + {/* Skeleton d'une slide : icône + titre + carte (largeurs relatives pour mobile) */} + <div className="mb-6 h-20 w-20 animate-pulse rounded-3xl bg-white/10 sm:mb-8 sm:h-28 sm:w-28 md:h-36 md:w-36" /> + <div className="mb-3 h-7 w-3/5 max-w-[16rem] animate-pulse rounded-xl bg-white/10 sm:h-9" /> + <div className="mb-6 h-4 w-2/5 max-w-[11rem] animate-pulse rounded-lg bg-white/5 sm:mb-8 sm:h-5" /> + <div className="w-full space-y-3 rounded-[1.5rem] border border-white/10 bg-white/5 p-5 sm:p-6"> + <div className="h-4 w-full animate-pulse rounded bg-white/10" /> + <div className="h-4 w-5/6 animate-pulse rounded bg-white/10" /> + <div className="h-4 w-2/3 animate-pulse rounded bg-white/10" /> + </div> + <motion.p initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} className="mt-8 text-sm font-medium text-purple-300"> + {t('wrapped.analyzingYear')} + </motion.p> </div> </SquareBackground> ); diff --git a/src/pages/help/CaMarchePlusPage.tsx b/src/pages/help/CaMarchePlusPage.tsx index c080fc8..494ebd0 100644 --- a/src/pages/help/CaMarchePlusPage.tsx +++ b/src/pages/help/CaMarchePlusPage.tsx @@ -54,7 +54,7 @@ const CaMarchePlusPage: React.FC = () => { ctas: [ { labelKey: 'help.caMarchePlus.cause3CtaMovixHealth', - href: 'https://movix.health', + href: 'https://movix.online', external: true, }, { diff --git a/src/pages/help/DnsPage.tsx b/src/pages/help/DnsPage.tsx index 37deb23..a6942bb 100644 --- a/src/pages/help/DnsPage.tsx +++ b/src/pages/help/DnsPage.tsx @@ -21,7 +21,7 @@ const EXTERNAL_RESOURCES: Array<{ href: 'https://developers.cloudflare.com/1.1.1.1/setup/', }, { labelKey: 'helpDns.otherChangeTonDns', href: 'https://changetondns.fr/' }, - { labelKey: 'helpDns.otherMirrorsMovixHealth', href: 'https://movix.health' }, + { labelKey: 'helpDns.otherMirrorsMovixHealth', href: 'https://movix.online' }, { labelKey: 'helpDns.otherMirrors', href: 'https://rentry.co/movix' }, { labelKey: 'helpDns.otherTelegram', diff --git a/src/pages/help/MiroirsPage.tsx b/src/pages/help/MiroirsPage.tsx index 6bbcef3..d3ffcbe 100644 --- a/src/pages/help/MiroirsPage.tsx +++ b/src/pages/help/MiroirsPage.tsx @@ -34,7 +34,7 @@ const MiroirsPage: React.FC = () => { components: { 1: ( <a - href="https://movix.health" + href="https://movix.online" target="_blank" rel="noopener noreferrer" className="font-medium text-indigo-400 hover:text-indigo-300 underline underline-offset-2 decoration-indigo-500/40 hover:decoration-indigo-400" diff --git a/src/routing/lazyWithRetry.ts b/src/routing/lazyWithRetry.ts index 7b2295b..cfdae80 100644 --- a/src/routing/lazyWithRetry.ts +++ b/src/routing/lazyWithRetry.ts @@ -1,26 +1,143 @@ /** * Wraps a dynamic-import loader to recover from chunk-load failures - * after a deploy (CDN evicted an old chunk before the client refreshed). + * after a deploy (the production domain serves only the latest deployment's + * hashed assets, so a client still running the previous `index.html` requests + * chunks that now 404 — or hits a brand-new chunk that hasn't propagated to the + * edge node yet). * - * Strategy: if a known chunk-load error is caught, force a full page - * reload — but only once per cooldown (60s) to prevent infinite loops. + * Recovery strategy (in order): + * 1. Retry the import a few times with exponential backoff. Most post-deploy + * 404s are CDN-propagation lag measured in seconds — a couple of retries + * on the *current* page resolve them without a disruptive reload. + * 2. If retries are exhausted, force ONE full page reload to pull a fresh + * `index.html` (and therefore fresh chunk hashes). Guarded by a budget + * (max reloads per window) + minimum spacing so a genuinely broken / + * offline client can never reload-loop. + * 3. If the reload budget is spent, the error bubbles to <ErrorBoundary>, + * which shows a soft "new version available" screen instead of crashing. * - * Also tracks in-flight interactive chunk loads (i.e., loads not initiated - * by background prefetch). When the count transitions 0 → 1 a - * `chunk:load:start` event is dispatched on `window`; when it returns to 0 - * a `chunk:load:end` event fires. Silent loads (prefetch) are not counted. + * Background prefetch loads (`{ silent: true }`) NEVER reload or surface a + * crash — a hover-prefetch on a stale chunk must not yank the page out from + * under the user. They retry quietly and, on failure, reject so the caller's + * `.catch` can swallow them. + * + * On the FIRST successful chunk load the reload budget is cleared, so a future + * deploy gets a fresh set of recovery attempts. + * + * Also tracks in-flight interactive chunk loads. When the count transitions + * 0 → 1 a `chunk:load:start` event is dispatched on `window`; when it returns + * to 0 a `chunk:load:end` event fires. Silent loads (prefetch) are not counted. * Used by `<TopProgressBar />` for the global loading indicator. * * Used by every entry in the route registry: * loader: () => lazyWithRetry(() => import('../pages/MyPage')) */ -const RELOAD_KEY = '__movix_chunk_reload_at'; -const RELOAD_COOLDOWN_MS = 60_000; +const RELOAD_KEY = '__movix_chunk_reload'; +const RELOAD_WINDOW_MS = 120_000; // counter resets after 2 min of no failures +const MAX_RELOADS = 3; // hard cap within the window — loop guard +const MIN_RELOAD_SPACING_MS = 3_000; // never reload twice in quick succession -const isChunkLoadError = (err: unknown): boolean => { +const MAX_IMPORT_RETRIES = 2; // in-place retries before falling back to reload +const RETRY_BASE_DELAY_MS = 350; // 350ms, then 700ms + +export const isChunkLoadError = (err: unknown): boolean => { const msg = String((err as Error)?.message || err || ''); - return /Failed to fetch dynamically imported module|Importing a module script failed|ChunkLoadError|error loading dynamically imported module|Unable to preload CSS for/i.test(msg); + const name = String((err as { name?: string })?.name || ''); + return ( + name === 'ChunkLoadError' || + /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload (?:CSS|module)|Importing binding name .* is not found|is not a valid JavaScript MIME type|expected expression, got '<'|'text\/html' is not a valid/i.test( + msg + ) + ); +}; + +type ReloadState = { n: number; at: number }; + +const readReloadState = (): ReloadState => { + try { + const raw = sessionStorage.getItem(RELOAD_KEY); + if (!raw) return { n: 0, at: 0 }; + const parsed = JSON.parse(raw) as ReloadState; + if (typeof parsed?.n !== 'number' || typeof parsed?.at !== 'number') { + return { n: 0, at: 0 }; + } + // Window elapsed since the last failure → start fresh. + if (Date.now() - parsed.at > RELOAD_WINDOW_MS) return { n: 0, at: 0 }; + return parsed; + } catch { + return { n: 0, at: 0 }; + } +}; + +/** Clears the reload budget. Called after any successful chunk load. */ +export const clearChunkReloadHistory = (): void => { + try { + sessionStorage.removeItem(RELOAD_KEY); + } catch { + // sessionStorage may throw in private/locked-down contexts — ignore + } +}; + +/** + * Schedules a guarded full-page reload to recover from a chunk-load failure. + * Returns `true` if a reload was scheduled, `false` if the budget is spent + * (caller should then surface the error to the ErrorBoundary). + * + * Shared by `lazyWithRetry` (interactive route loads) and the global + * `vite:preloadError` handler in `main.tsx`. + */ +export const reloadForChunkFailure = (): boolean => { + const state = readReloadState(); + if (state.n >= MAX_RELOADS) return false; + + const now = Date.now(); + const wait = Math.max(0, MIN_RELOAD_SPACING_MS - (now - state.at)); + try { + sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ n: state.n + 1, at: now + wait })); + } catch { + // If we can't persist the counter we still reload once, but the loop + // guard is weakened — acceptable vs. leaving the user on a broken page. + } + window.setTimeout(() => window.location.reload(), wait); + return true; +}; + +const sleep = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms)); + +/** + * Synthesises a chunk-load error for a *silently* failed import — one that + * resolved to a nullish module instead of rejecting. Vite's preload helper does + * this when a `vite:preloadError` event is preventDefault()-ed (see main.tsx). + * `name = 'ChunkLoadError'` makes `isChunkLoadError` match it, so it flows + * through the same retry + guarded-reload recovery as a normal rejection + * instead of reaching React.lazy as `undefined` (→ "Cannot read properties of + * undefined (reading 'default')"). + */ +const nullishModuleError = (): Error => { + const e = new Error('Dynamic import resolved to a nullish module (stale or failed chunk)'); + e.name = 'ChunkLoadError'; + return e; +}; + +const importWithRetry = async <T>(loader: () => Promise<T>, attempt = 0): Promise<T> => { + let mod: T; + try { + mod = await loader(); + } catch (err) { + if (!isChunkLoadError(err) || attempt >= MAX_IMPORT_RETRIES) throw err; + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + return importWithRetry(loader, attempt + 1); + } + // A valid `import()` always resolves to a module namespace object; a nullish + // result means the load silently failed (see nullishModuleError). Retry, then + // surface it as a chunk failure so the reload path below recovers it. + if (mod == null) { + if (attempt >= MAX_IMPORT_RETRIES) throw nullishModuleError(); + await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt); + return importWithRetry(loader, attempt + 1); + } + return mod; }; let activeLoads = 0; @@ -38,41 +155,38 @@ export const lazyWithRetry = <T>( } activeLoads++; } - return loader() + + let settled = false; + const settle = () => { + if (silent || settled) return; + settled = true; + activeLoads = Math.max(0, activeLoads - 1); + if (activeLoads === 0) { + window.dispatchEvent(new Event('chunk:load:end')); + } + }; + + return importWithRetry(loader) + .then((mod) => { + // A chunk resolved → the client is on a consistent build again. Reset the + // budget so a future deploy gets a fresh set of recovery attempts. + clearChunkReloadHistory(); + settle(); + return mod; + }) .catch((err) => { + settle(); if (!isChunkLoadError(err)) throw err; - let last = 0; - try { - last = Number(sessionStorage.getItem(RELOAD_KEY) || 0); - } catch { - // sessionStorage may throw in private/SSR contexts — fall through to throw - } - if (Date.now() - last < RELOAD_COOLDOWN_MS) { - throw err; // cooldown not elapsed → bubble to ErrorBoundary - } - try { - sessionStorage.setItem(RELOAD_KEY, String(Date.now())); - } catch { - // sessionStorage may throw — proceed with reload anyway - } - // Manually settle the counter before reload — the never-resolving - // promise below means `.finally()` would otherwise never run. - if (!silent) { - activeLoads--; - if (activeLoads === 0) { - window.dispatchEvent(new Event('chunk:load:end')); - } - } - window.location.reload(); - return new Promise<T>(() => {}); // never resolves; page is reloading - }) - .finally(() => { - if (!silent) { - activeLoads--; - if (activeLoads === 0) { - window.dispatchEvent(new Event('chunk:load:end')); - } + // Background prefetch: never reload or crash. Reject quietly so the + // caller's `.catch` swallows it (PrefetchLink drops it from its set). + if (silent) throw err; + + // Interactive load still failing after retries → try a guarded reload. + if (reloadForChunkFailure()) { + return new Promise<T>(() => {}); // never resolves; page is reloading } + // Reload budget spent → bubble to <ErrorBoundary> (soft recovery screen). + throw err; }); }; diff --git a/src/services/wrappedService.ts b/src/services/wrappedService.ts index 86e63e9..e1e4569 100644 --- a/src/services/wrappedService.ts +++ b/src/services/wrappedService.ts @@ -6,7 +6,9 @@ const MAIN_API = import.meta.env.VITE_MAIN_API; export interface WrappedSlide { - type: 'intro' | 'top1' | 'top5' | 'top2-focus' | 'top3-focus' | 'persona' | 'peak-month' | 'top-genres' | 'listening-clock' | 'streak' | 'fun-fact' | 'closing' | 'detailed-stats' | 'session-summary' | 'watch-bookends'; + // 'peak-month' n'est plus émis par le backend (remplacé par 'timeline') mais reste + // renderable pour les payloads encore en cache Redis (TTL 10 min) — ne pas supprimer. + type: 'intro' | 'top1' | 'top5' | 'top2-focus' | 'top3-focus' | 'persona' | 'peak-month' | 'top-genres' | 'listening-clock' | 'streak' | 'fun-fact' | 'closing' | 'detailed-stats' | 'session-summary' | 'watch-bookends' | 'timeline' | 'record-day' | 'rewatch' | 'watch-age' | 'pages-time' | 'quiz'; title: string; subtitle?: string; text: string; @@ -32,6 +34,9 @@ export interface WrappedTopContent { durationLabel?: string; tmdbId?: number; poster_path?: string; + backdrop_path?: string | null; + year?: number | null; + vote_average?: number | null; genres?: string[]; } @@ -93,6 +98,10 @@ export interface WrappedData { firstWatch?: WrappedFirstLastWatch | null; lastWatch?: WrappedFirstLastWatch | null; topPages: { page: string; minutes: number }[]; + recordDay?: { date: string; minutes: number } | null; + weekday?: { dow: number; minutes: number }[]; + rewatch?: { title: string; type: string; count: number } | null; + watchAgeYear?: number | null; } export interface WrappedProgress { diff --git a/src/types/sourcePriority.ts b/src/types/sourcePriority.ts index 859a9d2..9222e9c 100644 --- a/src/types/sourcePriority.ts +++ b/src/types/sourcePriority.ts @@ -3,7 +3,7 @@ /** Sources top-level disponibles pour Films/Séries. Ordre = default hardcodé actuel. */ export const TOP_LEVEL_SOURCE_IDS = [ 'darkino', 'mp4', 'nexus_hls', 'bravo', - 'fstream', 'wiflix', 'omega', 'coflix', 'frembed', 'vostfr', + 'fstream', 'wiflix', 'j1f', 'omega', 'coflix', 'frembed', 'vostfr', 'viper', 'vox', 'custom', ] as const; export type TopLevelSourceId = typeof TOP_LEVEL_SOURCE_IDS[number]; @@ -17,8 +17,8 @@ export const DEPRECATED_HOSTER_IDS = [] as const; /** Hosters built-in connus. Les custom hosters utilisent des ids prefixés `custom_`. */ export const BUILTIN_HOSTER_IDS = [ - 'voe', 'vidmoly', 'uqload', 'sibnet', 'doodstream', - 'seekstreaming', 'smoothpre', 'minochinos', 'vidzy', 'darkibox', + 'voe', 'vidmoly', 'vidzy', 'uqload', 'sibnet', 'doodstream', + 'seekstreaming', 'smoothpre', 'minochinos', 'darkibox', 'supervideo', 'dropload', 'oneupload', 'fsvid', ] as const; export type BuiltinHosterId = typeof BUILTIN_HOSTER_IDS[number]; diff --git a/src/utils/accountAuth.ts b/src/utils/accountAuth.ts index dd3f85a..2a6cd38 100644 --- a/src/utils/accountAuth.ts +++ b/src/utils/accountAuth.ts @@ -49,6 +49,10 @@ export interface ResolvedAuthResponse { user?: StoredUserProfile | null; account?: ResolvedAccountPayload | null; authData?: AuthDataPayload | null; + // Vrai si le pseudo provider stocké viole la policy (trop long, contient + // caractères de contrôle/zero-width). Le frontend affiche une modale + // bloquante qui force l'user à choisir un nouveau pseudo via POST /api/auth/username. + requiresUsernameChange?: boolean; } interface PersistResolvedSessionOptions { @@ -92,6 +96,7 @@ const AUTH_KEYS = [ 'resolved_user_type', 'resolved_user_id', 'user_id', + 'requires_username_change', ] as const; function isAuthMethod(value: string | null): value is AuthMethod { @@ -455,4 +460,13 @@ export function persistResolvedSession( }; localStorage.setItem('auth', JSON.stringify(authData)); + + // Persiste le flag : si vrai, la modale `RequireUsernameChange` se déclenche + // au prochain render de l'App. Sinon on nettoie pour éviter qu'un flag + // périmé d'une session précédente reste actif. + if (payload.requiresUsernameChange) { + localStorage.setItem('requires_username_change', '1'); + } else { + localStorage.removeItem('requires_username_change'); + } } diff --git a/src/utils/adAdultMode.ts b/src/utils/adAdultMode.ts new file mode 100644 index 0000000..bc45901 --- /dev/null +++ b/src/utils/adAdultMode.ts @@ -0,0 +1,46 @@ +// Toggle that controls whether the ad popup opens the +18 ad creative or the +// SFW one. Default = true (+18) per product decision. Users can opt out via +// settings, which writes 'false' to localStorage. Absent key = default (on). + +export const AD_POPUP_ADULT_KEY = 'settings_ad_popup_adult'; +export const AD_POPUP_ADULT_CHANGED_EVENT = 'ad_popup_adult_changed'; + +// +18: les 2 directlinks s'ouvrent ensemble au clic (1 fenêtre par URL). +export const AD_URLS_ADULT = [ + 'https://ustashewasputtin.com/?tiWc1=1197390', +]; +export const AD_URL_SFW = + 'https://endedstrung.com/c7c9gpr0q?key=6b8520a3e98e4cd1228e9319d751b237'; + +export const isAdultAdsEnabled = (): boolean => { + try { + // Default on: only an explicit 'false' opts out. + return localStorage.getItem(AD_POPUP_ADULT_KEY) !== 'false'; + } catch { + return true; + } +}; + +export const setAdultAdsEnabled = (enabled: boolean): void => { + try { + if (enabled) localStorage.removeItem(AD_POPUP_ADULT_KEY); + else localStorage.setItem(AD_POPUP_ADULT_KEY, 'false'); + window.dispatchEvent(new CustomEvent(AD_POPUP_ADULT_CHANGED_EVENT, { detail: { enabled } })); + } catch { /* noop */ } +}; + +export const subscribeToAdultAdsChanges = (cb: (enabled: boolean) => void): (() => void) => { + const onCustom = () => cb(isAdultAdsEnabled()); + const onStorage = (e: StorageEvent) => { + if (e.key === AD_POPUP_ADULT_KEY) cb(isAdultAdsEnabled()); + }; + window.addEventListener(AD_POPUP_ADULT_CHANGED_EVENT, onCustom); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(AD_POPUP_ADULT_CHANGED_EVENT, onCustom); + window.removeEventListener('storage', onStorage); + }; +}; + +export const getAdTargetUrls = (): string[] => + isAdultAdsEnabled() ? AD_URLS_ADULT : [AD_URL_SFW]; diff --git a/src/utils/adPopupMode.ts b/src/utils/adPopupMode.ts new file mode 100644 index 0000000..2e9b309 --- /dev/null +++ b/src/utils/adPopupMode.ts @@ -0,0 +1,33 @@ +export type AdPopupMode = 'normal' | 'auto' | 'click-anywhere'; + +export const AD_POPUP_MODE_KEY = 'settings_ad_popup_mode'; +export const AD_POPUP_MODE_CHANGED_EVENT = 'ad_popup_mode_changed'; + +export const getAdPopupMode = (): AdPopupMode => { + try { + const v = localStorage.getItem(AD_POPUP_MODE_KEY); + if (v === 'auto' || v === 'click-anywhere') return v; + } catch { /* localStorage may be unavailable (SSR / privacy) */ } + return 'normal'; +}; + +export const setAdPopupMode = (mode: AdPopupMode): void => { + try { + if (mode === 'normal') localStorage.removeItem(AD_POPUP_MODE_KEY); + else localStorage.setItem(AD_POPUP_MODE_KEY, mode); + window.dispatchEvent(new CustomEvent(AD_POPUP_MODE_CHANGED_EVENT, { detail: { mode } })); + } catch { /* noop */ } +}; + +export const subscribeToAdPopupModeChanges = (cb: (mode: AdPopupMode) => void): (() => void) => { + const onCustom = () => cb(getAdPopupMode()); + const onStorage = (e: StorageEvent) => { + if (e.key === AD_POPUP_MODE_KEY) cb(getAdPopupMode()); + }; + window.addEventListener(AD_POPUP_MODE_CHANGED_EVENT, onCustom); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(AD_POPUP_MODE_CHANGED_EVENT, onCustom); + window.removeEventListener('storage', onStorage); + }; +}; diff --git a/src/utils/adScriptMode.ts b/src/utils/adScriptMode.ts index 7001bdf..84e6e68 100644 --- a/src/utils/adScriptMode.ts +++ b/src/utils/adScriptMode.ts @@ -12,7 +12,7 @@ export const SCRIPT_AD_MODE_ENABLED = false; // Ad-network script src, protocol-relative as delivered by the network. -export const AD_SCRIPT_SRC = '//vf.amildarrobomb.com/r7gP5R6D5YTruZr/142815'; +export const AD_SCRIPT_SRC = '//endedstrung.com/92/93/15/92931582a515c4ab9a953e76e3a6cadd.js'; // Marker attribute used to keep injection idempotent. const AD_SCRIPT_MARKER = 'data-movix-ad-script'; diff --git a/src/utils/animeSignals.ts b/src/utils/animeSignals.ts index 42aab20..8bd9346 100644 --- a/src/utils/animeSignals.ts +++ b/src/utils/animeSignals.ts @@ -44,6 +44,9 @@ export function scoreAnimeSignals( const origin = detail.origin_country || []; const lang = detail.original_language || ''; const isJpLike = origin.includes('JP') || lang === 'ja'; + // Donghua = animation chinoise (CN/TW/HK, langue zh). anime-sama en héberge une partie. + const isCnLike = + origin.includes('CN') || origin.includes('TW') || origin.includes('HK') || lang === 'zh'; if (origin.includes('JP')) { score += 40; @@ -84,7 +87,22 @@ export function scoreAnimeSignals( } const genreNames = (detail.genres || []).map((g) => g?.name || ''); - if (genreNames.includes('Animation') || genreNames.includes('Animation & SF')) { + const hasAnimationGenre = + genreNames.includes('Animation') || genreNames.includes('Animation & SF'); + + // Donghua : origine/langue chinoise + contexte animation = signal fort (+40). + // On exige le genre Animation (ou le keyword donghua) pour ne PAS classer les + // dramas live-action chinois (énorme catalogue) en anime. + if (isCnLike && hasAnimationGenre) { + score += 40; + reasons.push('donghua=CN+animation'); + } + if (keywordSet.has('donghua')) { + score += 30; + reasons.push('kw=donghua'); + } + + if (hasAnimationGenre) { score += 10; reasons.push('genre=Animation'); } @@ -93,8 +111,8 @@ export function scoreAnimeSignals( } /** - * Seuil par défaut = 40 : il faut au moins un signal fort (JP, ja, studio JP, kw+JP). - * Le genre "Animation" seul (+10) ne suffit plus. + * Seuil par défaut = 40 : il faut au moins un signal fort (JP, ja, studio JP, kw+JP, + * donghua CN+animation, kw donghua). Le genre "Animation" seul (+10) ne suffit plus. */ export function isLikelyAnime( detail: TmdbTvDetail | null | undefined, diff --git a/src/utils/castUtils.ts b/src/utils/castUtils.ts index 8027849..7e54060 100644 --- a/src/utils/castUtils.ts +++ b/src/utils/castUtils.ts @@ -1,5 +1,4 @@ // Utility functions for Chromecast and AirPlay functionality -import { buildApiProxyUrl } from '../config/runtime'; // AirPlay interfaces export interface AirPlayMediaInfo { @@ -256,16 +255,13 @@ export const prepareCastMediaInfo = ( mediaUrl: string, title: string, poster?: string, - currentTime: number = 0 + currentTime: number = 0, + streamType: string = 'BUFFERED' ): CastMediaInfo => { // Detect media type const mediaType = detectMediaType(mediaUrl); - // Handle darkibox.com URLs with proxy - let castUrl = mediaUrl; - if (mediaUrl.includes('darkibox.com')) { - castUrl = buildApiProxyUrl(mediaUrl); - } + const castUrl = mediaUrl; // Set appropriate content type based on media type let contentType: string; @@ -280,7 +276,7 @@ export const prepareCastMediaInfo = ( const mediaInfo: CastMediaInfo = { contentId: castUrl, contentType: contentType, - streamType: 'BUFFERED', + streamType, metadata: { metadataType: 1, // Movie metadata title: title, @@ -344,16 +340,10 @@ export const prepareVideoForAirPlay = ( // The caller should set video.src directly and destroy HLS.js instance if (useNativePlayback && mediaUrl) { console.log('[AirPlay] Setting up native playback for AirPlay'); - - // Apply proxy if needed - let finalUrl = mediaUrl; - if (mediaUrl.includes('darkibox.com')) { - finalUrl = buildApiProxyUrl(mediaUrl); - } - + // For Safari, we can set src directly for both HLS and MP4 // Safari has native HLS support - video.src = finalUrl; + video.src = mediaUrl; } console.log('[AirPlay] Video element prepared successfully'); @@ -762,9 +752,10 @@ export const loadMediaOnCastWithFallback = async ( autoplay: boolean = true, subtitles: CastSubtitleTrack[] = [], enableSubtitlesInitially: boolean = false, + streamType: string = 'BUFFERED', ): Promise<void> => { const contentTypes = getCastContentTypes(mediaUrl); - const baseMediaInfo = prepareCastMediaInfo(mediaUrl, title, poster, currentTime); + const baseMediaInfo = prepareCastMediaInfo(mediaUrl, title, poster, currentTime, streamType); // Attach external subtitle tracks if any. The Default Media Receiver only // handles WebVTT — SRT URLs will land but won't render. We still send them @@ -1085,11 +1076,7 @@ export const prepareAirPlayMediaInfo = ( // Detect media type const mediaType = detectMediaType(mediaUrl); - // Handle proxy URLs for AirPlay - let airPlayUrl = mediaUrl; - if (mediaUrl.includes('darkibox.com')) { - airPlayUrl = buildApiProxyUrl(mediaUrl); - } + const airPlayUrl = mediaUrl; // For AirPlay, we should avoid modifying M3U8 URLs too much // AirPlay handles HLS streams natively and modifying URLs can cause issues diff --git a/src/utils/extractionPrefs.ts b/src/utils/extractionPrefs.ts index f421c0e..9187d5b 100644 --- a/src/utils/extractionPrefs.ts +++ b/src/utils/extractionPrefs.ts @@ -11,7 +11,7 @@ export const M3U8_EXTRACTOR_KEYS = [ ] as const; export const LIVETV_SOURCE_KEYS = [ - 'linkzy', 'wiflix', 'sosplay', 'livetv', 'matches', + 'linkzy', 'wiflix', 'sosplay', 'livetv', 'matches', 'daddylive', ] as const; export const EXTRACTION_METHOD_KEYS = [ diff --git a/src/utils/lowLatencyPref.ts b/src/utils/lowLatencyPref.ts new file mode 100644 index 0000000..fd7d1e2 --- /dev/null +++ b/src/utils/lowLatencyPref.ts @@ -0,0 +1,47 @@ +// Streaming basse latence (LL-HLS) — opt-in par lecteur. +// +// `lowLatencyMode` de hls.js colle au live edge et réduit le buffer : latence +// plus faible mais aucune marge si la connexion ou l'origine hoquette (le +// lecteur cale en même temps que le serveur). D'où l'opt-in + le warning côté +// Settings. Lu au montage du lecteur quand on construit la config Hls() ; un +// changement s'applique donc à la prochaine lecture, pas à une session en cours. +// +// Deux scopes indépendants : `movies` (films/séries + mini-lecteur) et +// `livetv` (chaînes). Défaut : désactivé partout. +// +// Clé non-allowlistée pour la sync : la qualité de connexion est propre à +// chaque appareil, on ne synchronise donc pas ce réglage entre devices. + +export type LowLatencyScope = 'movies' | 'livetv'; + +const STORAGE_KEY = 'low_latency_streaming'; + +type LowLatencyPrefs = Record<LowLatencyScope, boolean>; + +const DEFAULTS: LowLatencyPrefs = { movies: false, livetv: false }; + +export const LOW_LATENCY_CHANGED_EVENT = 'low_latency_changed'; + +function read(): LowLatencyPrefs { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return { ...DEFAULTS }; + return { ...DEFAULTS, ...JSON.parse(raw) }; + } catch { + return { ...DEFAULTS }; + } +} + +export function isLowLatencyEnabled(scope: LowLatencyScope): boolean { + return read()[scope]; +} + +export function setLowLatencyEnabled(scope: LowLatencyScope, enabled: boolean): void { + const next = { ...read(), [scope]: enabled }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // quota / mode privé — on ignore, le défaut (off) reste sûr. + } + window.dispatchEvent(new CustomEvent(LOW_LATENCY_CHANGED_EVENT)); +} diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts index aabe907..c596000 100644 --- a/src/utils/searchUtils.ts +++ b/src/utils/searchUtils.ts @@ -277,13 +277,15 @@ export function generateSearchVariations(originalTerm: string): string[] { } } - // Remove duplicates, empty strings, and variations considered too generic (single short/common word) + // Remove duplicates, empty strings, and variations considered too generic (single short/common word). + // Le titre original échappe au garde anti-générique : sinon les titres courts d'un seul mot + // ("Baki", "Gantz"…) produisent 0 variation et aucune recherche anime n'est lancée. return variations.filter( (term, index, arr) => term && term.trim() && arr.indexOf(term) === index && - isAcceptableVariation(term), + (term === originalTerm || isAcceptableVariation(term)), ); } diff --git a/src/utils/sourceAutoSelect.ts b/src/utils/sourceAutoSelect.ts index 2cf3886..f23bd62 100644 --- a/src/utils/sourceAutoSelect.ts +++ b/src/utils/sourceAutoSelect.ts @@ -95,10 +95,18 @@ export function sortHostersByPriority< const langOrderEnabled = cat.languageOrder .filter((e) => e.enabled) .map((e) => String(e.id).toLowerCase()); + // Fstream renvoie les catégories françaises sous forme `VFF` (Vraie VF) et + // `VFQ` (VF Québécoise) — variantes de `vf`. Sans normalisation, ces items + // tombent à MAX_SAFE_INTEGER et VOSTFR gagne par défaut. + const normalizeLang = (raw: string) => { + const v = raw.toLowerCase(); + if (v === 'vff' || v === 'vfq') return 'vf'; + return v; + }; const langRank = (item: T) => { const raw = item.language ?? item.category; if (!raw) return Number.MAX_SAFE_INTEGER - 1; // sans langue explicite = juste après toutes les activées - const normalized = String(raw).toLowerCase(); + const normalized = normalizeLang(String(raw)); const idx = langOrderEnabled.indexOf(normalized); return idx === -1 ? Number.MAX_SAFE_INTEGER : idx; }; diff --git a/src/utils/sourcePriorityPrefs.ts b/src/utils/sourcePriorityPrefs.ts index 8391bea..fc54bc8 100644 --- a/src/utils/sourcePriorityPrefs.ts +++ b/src/utils/sourcePriorityPrefs.ts @@ -18,9 +18,22 @@ const CHANGE_EVENT = 'movix-source-priority-changed'; * "append aux built-ins" à une sémantique "replace les built-ins". Le * migrator préfixe les built-ins à la liste utilisateur pour préserver * le comportement effectif des users qui avaient des patterns perso. + * + * **v2 → v3** (2026-05-25) : ordre par défaut des hosters Films/Séries + * passe `vidzy` avant `uqload` (fstream auto-sélection prio vidzy/VF + * au lieu de uqload/VOSTFR). Migration n'écrase l'ordre stocké QUE si + * l'ordre des built-ins matche l'ancien default (= user n'a pas reordré). + * Les `custom_*` sont préservés à leur position relative. */ -const SCHEMA_VERSION = 2 as const; -type SchemaVersion = 1 | 2; +const SCHEMA_VERSION = 3 as const; +type SchemaVersion = 1 | 2 | 3; + +/** Ancien ordre built-in (pré-v3). Sert au comparateur du migrator v2→v3. */ +const V2_DEFAULT_BUILTIN_HOSTER_ORDER: readonly string[] = [ + 'voe', 'vidmoly', 'uqload', 'sibnet', 'doodstream', + 'seekstreaming', 'smoothpre', 'minochinos', 'vidzy', 'darkibox', + 'supervideo', 'dropload', 'oneupload', 'fsvid', +]; /** * Ordre par défaut des sources top-level pour Films/Séries, reflétant la priorité @@ -46,7 +59,7 @@ type SchemaVersion = 1 | 2; */ const DEFAULT_MOVIES_TV_ORDER: readonly TopLevelSourceId[] = [ 'nexus_hls', 'bravo', 'mp4', 'darkino', - 'fstream', 'omega', 'wiflix', 'viper', 'coflix', + 'fstream', 'omega', 'wiflix', 'j1f', 'viper', 'coflix', 'custom', 'frembed', 'vox', 'vostfr', ]; @@ -103,7 +116,7 @@ export const DEFAULT_SOURCE_PRIORITY_PREFS: SourcePriorityPrefs = buildDefaults( function isValidPrefs(obj: unknown): obj is SourcePriorityPrefs & { version: SchemaVersion } { if (!obj || typeof obj !== 'object') return false; const p = obj as Partial<SourcePriorityPrefs>; - const versionOk = p.version === 1 || p.version === 2; + const versionOk = p.version === 1 || p.version === 2 || p.version === 3; return versionOk && !!p.categories && !!p.categories.moviesTv @@ -154,6 +167,41 @@ function migrateV1toV2( }; } +/** + * Migration v2 → v3 : nouvel ordre default pour moviesTv hosters (vidzy avant + * uqload). Si l'utilisateur n'a JAMAIS reordré ses built-ins (sa liste de + * built-ins, dans l'ordre, == V2_DEFAULT_BUILTIN_HOSTER_ORDER), on remplace + * par le nouveau default — les `custom_*` sont préservés et rappendés. Sinon + * on garde son ordre tel quel. + */ +function migrateV2toV3( + parsed: SourcePriorityPrefs & { version: 2 }, +): SourcePriorityPrefs & { version: 3 } { + const userOrder = parsed.categories.moviesTv.hosterOrder; + const builtinsSet = new Set<string>(V2_DEFAULT_BUILTIN_HOSTER_ORDER); + const builtinsInUserOrder = userOrder.filter((id) => builtinsSet.has(id)); + const customsInUserOrder = userOrder.filter((id) => !builtinsSet.has(id)); + + const isUntouched = builtinsInUserOrder.length === V2_DEFAULT_BUILTIN_HOSTER_ORDER.length + && builtinsInUserOrder.every((id, i) => id === V2_DEFAULT_BUILTIN_HOSTER_ORDER[i]); + + if (!isUntouched) { + return { ...parsed, version: 3 }; + } + + return { + ...parsed, + version: 3, + categories: { + ...parsed.categories, + moviesTv: { + ...parsed.categories.moviesTv, + hosterOrder: [...BUILTIN_HOSTER_IDS, ...customsInUserOrder], + }, + }, + }; +} + /** Merge parsed prefs with defaults (forward-compat pour nouveaux ids + purge deprecated). */ function mergeWithDefaults(parsed: SourcePriorityPrefs): SourcePriorityPrefs { const defaults = buildDefaults(); @@ -263,8 +311,14 @@ export function getSourcePriorityPrefs(): SourcePriorityPrefs { const parsed = JSON.parse(raw); if (!isValidPrefs(parsed)) return buildDefaults(); // Migration v1 → v2 (patternOverrides : append → replace) - const migrated = parsed.version === 1 ? migrateV1toV2(parsed as SourcePriorityPrefs & { version: 1 }) : parsed; - return mergeWithDefaults(migrated); + const v2 = parsed.version === 1 + ? migrateV1toV2(parsed as SourcePriorityPrefs & { version: 1 }) + : parsed as SourcePriorityPrefs & { version: 2 | 3 }; + // Migration v2 → v3 (default hoster order : vidzy avant uqload) + const v3 = v2.version === 2 + ? migrateV2toV3(v2 as SourcePriorityPrefs & { version: 2 }) + : v2; + return mergeWithDefaults(v3); } catch { return buildDefaults(); } diff --git a/src/utils/syncStorage.ts b/src/utils/syncStorage.ts index 1c35866..e2a7fed 100644 --- a/src/utils/syncStorage.ts +++ b/src/utils/syncStorage.ts @@ -89,6 +89,14 @@ const BLOCKED_SYNC_KEYS = new Set([ const PROFILE_LOAD_PRESERVED_KEYS = new Set([ SYNC_OUTBOX_STORAGE_KEY, + // VIP credential/entitlement keys are server-authoritative (verified via + // /api/check-vip + refreshVipState), like is_admin. They stay syncable for + // cross-device propagation but MUST NOT be wiped during the profile-load + // wipe-and-restore: the transient removeItem('is_vip') fired a native + // cross-tab `storage` event that flipped VIPs to non-VIP for a tick and + // re-triggered the ad popup on open watch tabs. See AdFreePopupContext. + 'access_code', + 'access_code_expires', 'access_token', 'auth', 'auth_method', @@ -106,6 +114,7 @@ const PROFILE_LOAD_PRESERVED_KEYS = new Set([ 'google_user', 'guest_uuid', 'is_admin', + 'is_vip', 'movix_pending_auth_action', 'resolved_user_id', 'resolved_user_type', diff --git a/src/utils/wrappedCanvas.ts b/src/utils/wrappedCanvas.ts new file mode 100644 index 0000000..4aed7c9 --- /dev/null +++ b/src/utils/wrappedCanvas.ts @@ -0,0 +1,217 @@ +/** + * Shared canvas helpers for Movix Wrapped share cards. + * No React dependencies — pure canvas / DOM utilities. + */ +import inter800Url from '@fontsource/inter/files/inter-latin-800-normal.woff2?url'; +import inter900Url from '@fontsource/inter/files/inter-latin-900-normal.woff2?url'; +import archivoBlackUrl from '@fontsource/archivo-black/files/archivo-black-latin-400-normal.woff2?url'; + +export const FONT_STACK = 'Inter, system-ui, sans-serif'; +export const DISPLAY_FONT = '"Archivo Black", Inter, system-ui, sans-serif'; + +let fontsLoaded = false; +/** Charge les fontes des share-cards (rendu identique sur tous les OS). Fallback silencieux. */ +export async function ensureShareFonts(): Promise<void> { + if (fontsLoaded || typeof FontFace === 'undefined') return; + try { + const faces = [ + new FontFace('Inter', `url(${inter800Url})`, { weight: '800' }), + new FontFace('Inter', `url(${inter900Url})`, { weight: '900' }), + new FontFace('Archivo Black', `url(${archivoBlackUrl})`, { weight: '400' }), + ]; + const loaded = await Promise.all(faces.map(f => f.load())); + loaded.forEach(f => document.fonts.add(f)); + fontsLoaded = true; + } catch { /* system-ui fallback */ } +} + +/** PRNG déterministe (positions stickers reproductibles). */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Charge une image pour canvas via fetch→blob (requête CORS propre, pas de collision de cache <img>). */ +export async function loadCanvasImage(src: string): Promise<HTMLImageElement | null> { + // Clé d'URL dédiée au canvas : le service worker (public/sw.js) sert image.tmdb.org + // en cache-first par URL, et son cache contient des réponses OPAQUES (stockées par les + // <img> no-cors du site) — inutilisables ici (status 0 → throw, et le fallback + // crossOrigin échoue pareil faute d'en-têtes ACAO). Un param `?cors=1` change la clé + // de cache → vraie requête réseau CORS, mise en cache séparément ensuite. + const corsSrc = src.includes('?') ? `${src}&cors=1` : `${src}?cors=1`; + try { + const res = await fetch(corsSrc, { mode: 'cors' }); + if (!res.ok) throw new Error(String(res.status)); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const img = await new Promise<HTMLImageElement | null>((resolve) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = url; + }); + if (img) setTimeout(() => URL.revokeObjectURL(url), 60000); + else URL.revokeObjectURL(url); + return img; + } catch { + // Fallback : ancienne méthode crossOrigin + return new Promise((resolve) => { + const image = new Image(); + if (!corsSrc.startsWith(window.location.origin)) image.crossOrigin = 'anonymous'; + image.onload = () => resolve(image); + image.onerror = () => resolve(null); + image.src = corsSrc; + }); + } +} + +export function drawRoundedRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number +) { + const safeRadius = Math.min(radius, width / 2, height / 2); + ctx.beginPath(); + ctx.moveTo(x + safeRadius, y); + ctx.lineTo(x + width - safeRadius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + safeRadius); + ctx.lineTo(x + width, y + height - safeRadius); + ctx.quadraticCurveTo(x + width, y + height, x + width - safeRadius, y + height); + ctx.lineTo(x + safeRadius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - safeRadius); + ctx.lineTo(x, y + safeRadius); + ctx.quadraticCurveTo(x, y, x + safeRadius, y); + ctx.closePath(); +} + +export function drawPopcornSticker( + ctx: CanvasRenderingContext2D, + { + x, + y, + scale = 1, + rotation = 0, + opacity = 0.5, + accent = '#f6c453', // Unused but kept for signature + }: { + x: number; + y: number; + scale?: number; + rotation?: number; + opacity?: number; + accent?: string; + fillLevel?: number; + } +) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rotation); + // Adjusted scale down drastically because original SVG is 512x512 + ctx.scale(scale * 0.05, scale * 0.05); + // Shift so it rotates somewhat around its center (256, 256) + ctx.translate(-256, -256); + ctx.globalAlpha = opacity; + + // SVG Path for Popcorn + const combinedPath = new Path2D( + "M415.08,147.991c-0.582-0.661-1.27-1.201-2.025-1.618c-2.051-13.744-8.438-27.651-20.957-27.856 c-1.149-2.05-2.159-7.502-2.786-10.891c-0.847-4.574-1.723-9.304-3.418-13.119c-4.147-9.33-11.46-9.194-14.405-8.702 c-4.744,0.791-9.651-2.179-10.522-3.816c0.228-0.314,0.77-0.892,1.831-1.599c5.878-3.918,8.79-8.526,8.654-13.697 c-0.247-9.384-9.945-15.137-22.223-22.421c-1.595-0.946-3.232-1.917-4.884-2.919c-13.683-8.292-24.607-3.13-32.584,0.64 c-3.66,1.729-6.869,3.246-9.73,3.352c-0.035-0.649-0.057-1.374-0.075-1.991c-0.212-7.208-0.568-19.271-14.256-31.474 C274.131-0.211,262.42-0.939,254.984,0.582c-10.465,2.138-19.319,9.884-24.931,21.809c-2.617,5.561-4.708,9.396-6.41,10.034 c-2.405,0.901-8.666-0.019-17.332-1.293l-2.647-0.388c-4.139-0.602-7.984,2.263-8.588,6.402s2.262,7.984,6.402,8.588l2.63,0.386 c12.116,1.78,18.791,2.761,24.852,0.49c7.258-2.721,10.712-9.081,14.8-17.77c3.475-7.385,8.671-12.276,14.255-13.417 c7.32-1.495,14.702,3.398,19.604,7.767c8.819,7.861,9.018,14.634,9.194,20.61c0.149,5.083,0.461,15.658,13.134,16.633 c7.087,0.541,13.038-2.264,18.284-4.742c7.585-3.582,11.841-5.272,18.261-1.382c1.694,1.027,3.372,2.022,5.007,2.992 c4.807,2.852,11.761,6.977,14.196,9.523c-0.315,0.265-0.738,0.588-1.301,0.962c-7.601,5.068-10.446,12.378-7.608,19.555 c3.417,8.64,14.778,14.601,25.421,13.633c0.875,2.2,1.688,6.589,2.21,9.409c1.755,9.477,4.404,23.786,17.302,23.281 c1.706-0.07,4.236,5.117,5.72,11.755h-274.7c-0.115-0.071-0.225-0.146-0.345-0.212c-1.163-0.638-4.7-3.277-5.188-5.688 c-0.075-0.369-0.302-1.491,1.425-3.672c2.923-3.692,5.246-4.908,7.937-6.315c6.07-3.177,10.867-6.586,12.796-18.929 c1.208-7.729,4.202-10.008,11.438-15.514c3.905-2.972,8.767-6.67,14.206-12.111c2.958-2.958,2.958-7.754,0-10.711 c-2.958-2.958-7.754-2.958-10.711,0c-4.716,4.716-8.941,7.932-12.668,10.767c-8.093,6.158-15.082,11.477-17.231,25.23 c-0.903,5.779-1.506,6.095-4.851,7.846c-3.32,1.738-7.869,4.117-12.791,10.335c-5.035,6.361-5.169,12.256-4.395,16.08 c0.205,1.015,0.509,1.972,0.86,2.894h-0.614c-2.177,0-4.249,0.937-5.687,2.571c-1.438,1.635-2.104,3.808-1.826,5.967l7.52,58.553 c0.532,4.148,4.326,7.074,8.477,6.547c4.149-0.533,7.08-4.328,6.548-8.477l-6.423-50.014h17.445l43.185,336.283H154.4 l-27.572-214.71c-0.533-4.149-4.328-7.082-8.477-6.548c-4.149,0.533-7.08,4.328-6.548,8.477l28.422,221.32 c0.485,3.779,3.702,6.61,7.512,6.61h32.688c0.007,0,0.014,0.001,0.021,0.001c0.005,0,0.01-0.001,0.015-0.001h38.037 c0.003,0,0.007,0,0.01,0c0.005,0,0.009,0,0.014,0h81.925c0.005,0,0.009,0,0.014,0c0.003,0,0.007,0,0.01,0h38.037 c0.005,0,0.01,0.001,0.015,0.001c0.007,0,0.014-0.001,0.021-0.001h25.718c3.81,0,7.027-2.831,7.512-6.61l45.13-351.431 C417.182,151.8,416.518,149.626,415.08,147.991z M187.118,496.851l-43.185-336.283h42.594l23.857,336.283H187.118z M251.857,496.851h-26.285l-23.858-336.283h50.143V496.851z M293.397,496.851h-26.393V160.568h50.25L293.397,496.851z M331.851,496.851h-23.267l23.858-336.283h42.594L331.851,496.851z M357.599,496.851h-10.476l43.185-336.283h10.476 L357.599,496.851z " + + "M296.654,97.087c-1.495-3.907-5.874-5.862-9.781-4.367c-10.043,3.844-12.895,2.129-19.785-2.013 c-3.833-2.305-8.605-5.173-15.305-7.342c-21.151-6.856-34.389,9.688-40.75,17.636c-0.555,0.694-1.076,1.346-1.567,1.939 c-1.149,1.389-1.668,1.682-1.659,1.682c-0.363,0.102-1.676,0.084-2.73,0.069c-2.714-0.038-6.431-0.091-11.781,0.853 c-17.312,3.055-22.505,22.439-22.718,23.263c-1.039,4.033,1.379,8.127,5.405,9.193c0.645,0.171,1.293,0.253,1.93,0.253 c3.344,0,6.409-2.241,7.323-5.617c0.029-0.108,3.014-10.818,10.693-12.174c3.937-0.695,6.592-0.655,8.934-0.624 c5.941,0.09,10.601-0.385,16.272-7.24c0.539-0.651,1.114-1.368,1.725-2.132c7.145-8.927,13.902-16.041,24.256-12.691 c5.049,1.636,8.669,3.811,12.169,5.915c7.901,4.748,16.071,9.657,33.002,3.176C296.194,105.373,298.148,100.993,296.654,97.087z" + ); + + ctx.fillStyle = accent; + ctx.fill(combinedPath); + ctx.restore(); +} + +export function drawClapperSticker( + ctx: CanvasRenderingContext2D, + { + x, + y, + scale = 1, + rotation = 0, + opacity = 0.5, + accent = '#4ecdc4', + }: { + x: number; + y: number; + scale?: number; + rotation?: number; + opacity?: number; + accent?: string; + } +) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(rotation); + ctx.scale(scale, scale); + ctx.translate(-12, -12); // Center a 24x24 viewBox + ctx.globalAlpha = opacity; + + // SVG Path for Clapperboard + const combinedPath = new Path2D( + "M4 11H16C17.8856 11 18.8284 11 19.4142 11.5858C20 12.1716 20 13.1144 20 15V16C20 18.8284 20 20.2426 19.1213 21.1213C18.2426 22 16.8284 22 14 22H10C7.17157 22 5.75736 22 4.87868 21.1213C4 20.2426 4 18.8284 4 16V11Z " + + "M4.00128 10.9997C3.51749 9.19412 3.27559 8.29135 3.48364 7.51489C3.61994 7.00622 3.88773 6.5424 4.2601 6.17003C4.82851 5.60162 5.73128 5.35973 7.53682 4.87593L14.5398 2.99949C15.213 2.8191 15.5496 2.72891 15.8445 2.70958C17.0553 2.63022 18.1946 3.28804 18.7313 4.37629C18.862 4.64129 18.9522 4.97791 19.1326 5.65114C19.1927 5.87556 19.2228 5.98776 19.2292 6.08604C19.2557 6.48964 19.0364 6.86943 18.6736 7.04832C18.5853 7.09188 18.4731 7.12195 18.2487 7.18208L4.00128 10.9997Z " + + "M14.7004 2.94135L14.0627 8.28861 " + + "M8.42209 4.62396L7.78433 9.97123" + ); + + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.stroke(combinedPath); + ctx.restore(); +} + +export function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] { + if (!text) return ['']; + + const words = text.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let currentLine = ''; + + words.forEach((word) => { + const candidate = currentLine ? `${currentLine} ${word}` : word; + if (ctx.measureText(candidate).width <= maxWidth) { + currentLine = candidate; + } else { + if (currentLine) { + lines.push(currentLine); + } + currentLine = word; + } + }); + + if (currentLine) { + lines.push(currentLine); + } + + return lines.length > 0 ? lines : [text]; +} + +/** Dessine les stickers popcorn/clap en positions seedées. */ +export function drawSeededStickers(ctx: CanvasRenderingContext2D, width: number, height: number, seed: number, count = 40): void { + const rand = mulberry32(seed); + const colors = ['#4ecdc4', '#ff7a59', '#f6c453', '#ff5f56']; + for (let i = 0; i < count; i++) { + const isPopcorn = rand() > 0.5; + const x = rand() * width; + const y = rand() * height; + const scale = 1.0 + rand() * 1.5; + const rotation = rand() * Math.PI * 2; + const opacity = 0.1 + rand() * 0.2; + const accent = colors[Math.floor(rand() * colors.length)]; + if (isPopcorn) { + drawPopcornSticker(ctx, { x, y, scale, rotation, opacity, fillLevel: 0.3 + rand() * 0.7, accent }); + } else { + drawClapperSticker(ctx, { x, y, scale: scale * 1.4, rotation, opacity, accent }); + } + } +} diff --git a/src/utils/wrappedShareCards.ts b/src/utils/wrappedShareCards.ts new file mode 100644 index 0000000..7ba3b7c --- /dev/null +++ b/src/utils/wrappedShareCards.ts @@ -0,0 +1,311 @@ +/** + * Formats alternatifs de la share-card Wrapped : Affiche de film & Billet de ciné. + * Données primitives en entrée (pas de dépendance aux types de page). + */ +import { drawRoundedRectPath, wrapCanvasText, loadCanvasImage, ensureShareFonts, drawSeededStickers, mulberry32, FONT_STACK, DISPLAY_FONT } from './wrappedCanvas'; + +export interface WrappedShareCardData { + year: number; + totalHours: number; + totalMinutes: number; + uniqueTitles: number; + totalSessions: number; + longestStreak: number; + peakHour: number | null; + peakMonthName: string; + peakMonthIndex: number; + personaTitle: string; + personaEmoji: string; + personaColor: string; + topTitles: string[]; // top 5 + topGenreName: string | null; + watchTimeLabel: string; // ex. "23h 14min" + topPosterUrl: string | null; + topBackdropUrl: string | null; + posterUrls: (string | null)[]; // top 3 + seed: number; +} + +function toBlob(canvas: HTMLCanvasElement): Promise<Blob | null> { + return new Promise((resolve) => canvas.toBlob((b) => resolve(b), 'image/png')); +} + +// ──────────────────────────────────────────────────────────────────────────── +// FORMAT B — AFFICHE DE FILM (1080×1620, ratio 2:3) +// ──────────────────────────────────────────────────────────────────────────── +export async function generatePosterShareImage(data: WrappedShareCardData): Promise<Blob | null> { + await ensureShareFonts(); + const width = 1080, height = 1620; + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + const center = (text: string, y: number) => ctx.fillText(text, (width - ctx.measureText(text).width) / 2, y); + + // Fond : backdrop plein cadre + voile sombre + vignette + ctx.fillStyle = '#0a0a0c'; + ctx.fillRect(0, 0, width, height); + const backdrop = data.topBackdropUrl ? await loadCanvasImage(data.topBackdropUrl) : null; + if (backdrop) { + const scale = Math.max(width / backdrop.width, height / backdrop.height); + const dw = backdrop.width * scale, dh = backdrop.height * scale; + ctx.save(); + ctx.globalAlpha = 0.42; + ctx.drawImage(backdrop, (width - dw) / 2, (height - dh) / 2, dw, dh); + ctx.restore(); + } + const veil = ctx.createLinearGradient(0, 0, 0, height); + veil.addColorStop(0, 'rgba(8,8,10,0.82)'); + veil.addColorStop(0.42, 'rgba(8,8,10,0.45)'); + veil.addColorStop(1, 'rgba(8,8,10,0.94)'); + ctx.fillStyle = veil; + ctx.fillRect(0, 0, width, height); + const vignette = ctx.createRadialGradient(width / 2, height / 2, height * 0.3, width / 2, height / 2, height * 0.75); + vignette.addColorStop(0, 'rgba(0,0,0,0)'); + vignette.addColorStop(1, 'rgba(0,0,0,0.55)'); + ctx.fillStyle = vignette; + ctx.fillRect(0, 0, width, height); + + // Grain léger (déterministe) + const rand = mulberry32(data.seed); + ctx.fillStyle = 'rgba(255,255,255,0.04)'; + for (let i = 0; i < 220; i++) { + ctx.fillRect(rand() * width, rand() * height, 1.4, 1.4); + } + + // Lauriers + sélection officielle + ctx.fillStyle = 'rgba(246,196,83,0.92)'; + ctx.font = `800 26px ${FONT_STACK}`; + center(`🏆 SÉLECTION OFFICIELLE ${data.year} 🏆`, 138); + + // MOVIX PRÉSENTE + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = `800 30px ${FONT_STACK}`; + center('M O V I X P R É S E N T E', 252); + + // Titre principal + ctx.fillStyle = '#ffffff'; + ctx.font = `400 64px ${DISPLAY_FONT}`; + center('UNE ANNÉE DE', 392); + const big = data.totalHours > 0 ? `${data.totalHours} HEURES` : `${data.totalMinutes} MINUTES`; + const grad = ctx.createLinearGradient(140, 0, width - 140, 0); + grad.addColorStop(0, '#ffd7d1'); + grad.addColorStop(0.5, '#ff7a59'); + grad.addColorStop(1, '#f6c453'); + ctx.fillStyle = grad; + ctx.font = `400 116px ${DISPLAY_FONT}`; + center(big, 516); + + // Persona + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.font = `800 34px ${FONT_STACK}`; + center(`${data.personaEmoji} ${data.personaTitle}`, 596); + + // Bandeau posters top 3 + const posters = await Promise.all(data.posterUrls.slice(0, 3).map(u => (u ? loadCanvasImage(u) : Promise.resolve(null)))); + const pw = 218, ph = 326, gap = 36; + const totalW = pw * 3 + gap * 2; + let px = (width - totalW) / 2; + const py = 668; + posters.forEach((img, i) => { + ctx.save(); + drawRoundedRectPath(ctx, px, py, pw, ph, 22); + ctx.fillStyle = 'rgba(20,20,24,0.9)'; + ctx.fill(); + ctx.strokeStyle = i === 0 ? 'rgba(255,122,89,0.9)' : 'rgba(255,255,255,0.18)'; + ctx.lineWidth = 3; + ctx.stroke(); + if (img) { + drawRoundedRectPath(ctx, px + 7, py + 7, pw - 14, ph - 14, 17); + ctx.clip(); + ctx.drawImage(img, px + 7, py + 7, pw - 14, ph - 14); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = `900 64px ${FONT_STACK}`; + ctx.fillText('🎬', px + pw / 2 - 32, py + ph / 2 + 20); + } + ctx.restore(); + // Badge rang + drawRoundedRectPath(ctx, px + 12, py + 12, 56, 34, 17); + ctx.fillStyle = i === 0 ? '#ff7a59' : 'rgba(10,10,12,0.85)'; + ctx.fill(); + ctx.fillStyle = i === 0 ? '#190d0b' : '#ffffff'; + ctx.font = `900 20px ${FONT_STACK}`; + ctx.fillText(`#${i + 1}`, px + 26, py + 36); + px += pw + gap; + }); + + // Bloc crédits façon affiche + let cy = 1108; + const creditLine = (label: string, value: string, valueSize = 26) => { + ctx.fillStyle = 'rgba(255,255,255,0.45)'; + ctx.font = `800 17px ${FONT_STACK}`; + center(label, cy); + cy += 34; + ctx.fillStyle = 'rgba(255,255,255,0.92)'; + ctx.font = `800 ${valueSize}px ${FONT_STACK}`; + const lines = wrapCanvasText(ctx, value, width - 220).slice(0, 2); + lines.forEach((line) => { center(line, cy); cy += valueSize + 8; }); + cy += 18; + }; + + creditLine('AVEC', data.topTitles.slice(0, 5).join(' · ').toUpperCase(), 24); + creditLine('GENRE DE L\'ANNÉE', (data.topGenreName || '—').toUpperCase()); + creditLine('UN FILM DE', 'TOI'); + + // Pied : stats + domaine + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.font = `700 22px ${FONT_STACK}`; + center(`${data.uniqueTitles} TITRES · ${data.totalSessions} SESSIONS · ${data.watchTimeLabel}`, 1492); + ctx.fillStyle = 'rgba(255,255,255,0.38)'; + ctx.font = `600 24px ${FONT_STACK}`; + center('movix.date', 1556); + + return toBlob(canvas); +} + +// ──────────────────────────────────────────────────────────────────────────── +// FORMAT C — BILLET DE CINÉ (1080×1920, ticket centré) +// ──────────────────────────────────────────────────────────────────────────── +export async function generateTicketShareImage(data: WrappedShareCardData): Promise<Blob | null> { + await ensureShareFonts(); + const width = 1080, height = 1920; + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + // Fond sombre + glows + stickers (cohérent avec le format story) + ctx.fillStyle = '#070708'; + ctx.fillRect(0, 0, width, height); + const glow = ctx.createRadialGradient(width / 2, 320, 30, width / 2, 320, 560); + glow.addColorStop(0, 'rgba(255, 95, 86, 0.22)'); + glow.addColorStop(1, 'rgba(255, 95, 86, 0)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, width, height); + const glow2 = ctx.createRadialGradient(width * 0.8, height * 0.85, 30, width * 0.8, height * 0.85, 480); + glow2.addColorStop(0, 'rgba(78, 205, 196, 0.16)'); + glow2.addColorStop(1, 'rgba(78, 205, 196, 0)'); + ctx.fillStyle = glow2; + ctx.fillRect(0, 0, width, height); + drawSeededStickers(ctx, width, height, data.seed, 28); + + // Ticket papier + const tx = 130, ty = 290, tw = width - 260, th = 1340; + ctx.save(); + ctx.shadowColor = 'rgba(0,0,0,0.5)'; + ctx.shadowBlur = 60; + ctx.shadowOffsetY = 26; + drawRoundedRectPath(ctx, tx, ty, tw, th, 36); + ctx.fillStyle = '#f5efe2'; + ctx.fill(); + ctx.restore(); + + // Perforations latérales (cercles couleur fond) + ctx.fillStyle = '#070708'; + for (let y = ty + 60; y < ty + th - 40; y += 64) { + ctx.beginPath(); ctx.arc(tx, y, 11, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(tx + tw, y, 11, 0, Math.PI * 2); ctx.fill(); + } + + const ink = '#1d1a16'; + const inkSoft = 'rgba(29,26,22,0.55)'; + const centerInTicket = (text: string, y: number) => ctx.fillText(text, tx + (tw - ctx.measureText(text).width) / 2, y); + + // En-tête ticket + ctx.fillStyle = ink; + ctx.font = `400 58px ${DISPLAY_FONT}`; + centerInTicket(`MOVIX WRAPPED`, ty + 116); + ctx.font = `400 100px ${DISPLAY_FONT}`; + centerInTicket(String(data.year), ty + 226); + ctx.fillStyle = inkSoft; + ctx.font = `800 24px ${FONT_STACK}`; + centerInTicket('★ A D M I T O N E ★', ty + 286); + + // Ligne déchirure + ctx.strokeStyle = 'rgba(29,26,22,0.35)'; + ctx.lineWidth = 3; + ctx.setLineDash([14, 12]); + ctx.beginPath(); + ctx.moveTo(tx + 40, ty + 330); + ctx.lineTo(tx + tw - 40, ty + 330); + ctx.stroke(); + ctx.setLineDash([]); + + // Champs du billet + let fy = ty + 412; + const field = (label: string, value: string, big = false) => { + ctx.fillStyle = inkSoft; + ctx.font = `800 19px ${FONT_STACK}`; + centerInTicket(label, fy); + fy += big ? 56 : 46; + ctx.fillStyle = ink; + ctx.font = big ? `900 44px ${FONT_STACK}` : `900 34px ${FONT_STACK}`; + const lines = wrapCanvasText(ctx, value, tw - 120).slice(0, 2); + lines.forEach((line) => { centerInTicket(line, fy); fy += big ? 52 : 42; }); + fy += 30; + }; + + field('FILM / SÉRIE DE L\'ANNÉE', data.topTitles[0] ? `« ${data.topTitles[0]} »` : '—', true); + field('DURÉE TOTALE', data.watchTimeLabel); + + // Rangée SALLE / SIÈGE / SÉANCE + const cols = [ + { label: 'SALLE', value: String(data.peakMonthIndex).padStart(2, '0') }, + { label: 'SIÈGE', value: `${data.longestStreak}J` }, + { label: 'SÉANCE', value: data.peakHour != null ? `${data.peakHour}H` : '—' }, + ]; + const colW = (tw - 160) / 3; + cols.forEach((c, i) => { + const cx = tx + 80 + i * colW; + ctx.fillStyle = inkSoft; + ctx.font = `800 19px ${FONT_STACK}`; + ctx.fillText(c.label, cx + (colW - ctx.measureText(c.label).width) / 2, fy); + ctx.fillStyle = ink; + ctx.font = `900 46px ${FONT_STACK}`; + ctx.fillText(c.value, cx + (colW - ctx.measureText(c.value).width) / 2, fy + 58); + }); + fy += 130; + + field('GENRE', (data.topGenreName || '—').toUpperCase()); + + // Rangée TITRES / SESSIONS (comble l'espace mort avant le code-barres) + const cols2 = [ + { label: 'TITRES', value: String(data.uniqueTitles) }, + { label: 'SESSIONS', value: String(data.totalSessions) }, + ]; + const col2W = (tw - 160) / 2; + cols2.forEach((c, i) => { + const cx = tx + 80 + i * col2W; + ctx.fillStyle = inkSoft; + ctx.font = `800 19px ${FONT_STACK}`; + ctx.fillText(c.label, cx + (col2W - ctx.measureText(c.label).width) / 2, fy); + ctx.fillStyle = ink; + ctx.font = `900 46px ${FONT_STACK}`; + ctx.fillText(c.value, cx + (col2W - ctx.measureText(c.value).width) / 2, fy + 58); + }); + fy += 130; + + // Persona + ctx.fillStyle = ink; + ctx.font = `800 30px ${FONT_STACK}`; + centerInTicket(`${data.personaEmoji} ${data.personaTitle}`, fy); + + // Code-barres (seedé — barre garantie à chaque pas, gap borné : pas de gros trous) + const rand = mulberry32(data.seed + 7); + const barAreaW = tw - 240; + let bx = tx + 120; + const barY = ty + th - 196; + ctx.fillStyle = ink; + while (bx < tx + 120 + barAreaW) { + const bw = 2 + Math.floor(rand() * 8); + ctx.fillRect(bx, barY, bw, 86); + bx += bw + 4 + Math.floor(rand() * 6); + } + ctx.fillStyle = inkSoft; + ctx.font = `700 24px ${FONT_STACK}`; + centerInTicket('movix.date', ty + th - 66); + + return toBlob(canvas); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..8687bc3 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,6 @@ /// <reference types="vite/client" /> + +declare module '*.woff2?url' { + const src: string; + export default src; +}