Aller au contenu

Quesaco ?

Infra de MainmainFlix

Comment ça marche ?

Tout ce système repose sur quelques solutions Open Source: Plex, Radarr, Sonarr, un indexer et ton client torrent préféré.

Pour une automatisation complète nous pouvons rajouter des briques (solutions) par-ci par-là afin de répondre à certains besoins.

Documentation importante: https://trash-guides.info

Les principales solutions

Plex est une solution Open Source qui permet de créer des librairies et de pouvoir consommer leur contenu en VOD. Il est possible de donner accès aux différentes librairies à d'autres utilisateurs.

Radarr est une solution Open Source qui permet de manager les demandes de film dans un ou plusieurs path.

Il va être capable de récupérer les metadatas du film mais aussi de pouvoir se connecter via API à un client Torrent pour télécharger directement le média. En rajoutant un indexer, il va être capable d'aller pull sur ce dernier le film qu'il aimerait télécharger (vous voyez déjà venir l'automatisation) et l'envoyer au client Torrent.

Sonarr est exactement la même solution que Radarr mais pour les séries (même features, l'un est un fork de l'autre). Client Torrent (ici qBitTorrent) est un downloader de torrent, celui-ci a pas mal de paramètres et possède une api, ce qui nous intéresse fortement.

Un indexer (Prowlarr) qui va permettre l'aller chercher sur différent site de torrent les medias que tu cherches.

Tout connecter

Directories

Le but va être de créer une structure de répertoire permettant d'organiser le tout correctement:

  • le client Torrent doit avoir un path dédié pour le téléchargement des films, pareil pour les séries
  • Radarr va avoir un path dédié pour les films
  • Sonarr va avoir un path dédié pour les séries
  • Plex doit avoir accès aux paths de Radarr et Sonarr pour consommer ces librairies.

Globalement ça ressemble à ça:

data
├── torrents
│   ├── movies
│   └── tv
├── usenet
│   ├── incomplete
│   └── complete
│       ├── movies
│       └── tv
└── media
    ├── movies
    └── tv

Connexions

  • qBitTorrent va être utilisé par Radarr et Sonarr pour télécharger
  • Prowlarr va être utilisé par Radarr et Sonarr pour chercher les fichiers torrent
  • Plex va être utilisé par le consommateur VOD

Full automation

Solutions

Pour avoir un gestion complètement automatiser nous pouvons rajouter plusieurs solutions:

  • Overseerr qui permet d'avoir un super portail pour les utilisateurs pour leur demande de films
  • Kuma qui va permettre de faire du monitoring de l'infrastructure et de l'alerting en cas de pépins
  • Slack/Discord avec un channel dédié pour l'alerting
  • Tautulli pour analyser ce qui est consommer sur le Plex par les utilisateurs, ça permet d'avoir des metrics supplémentaires
  • FlareSolverr qui permet de passer les capcha Cloudflare

Connexion

  • Tautulli va consommer Plex
  • FlareSolverr va être consommer par Prowlarr
  • Kuma va poller les différents endpoints pour surveiller leur status et utiliser Slack pour envoyer de l'alerting
  • Overseerr va être connecter à Plex ainsi que Radarr et Sonarr
  • Overseerr Focus, frontend of our system
  • Overseerr va être la plateform où les utilisateurs vont faire leurs demandes.

Lors qu'une demande est faite, il va envoyer celle ci directement à Radarr ou Sonarr (au choix par rapport au média demandé) et ces derniers vont s'occuper du reste avec les autres composants.

Schéma:

flowchart TB
    subgraph Users["👥 Utilisateurs"]
        User[Utilisateur]
    end

    subgraph Frontend["🎬 Interface"]
        Plex[("PlexStreaming VOD")]
        Overseerr["OverseerrDemandes"]
    end

    subgraph Management["📥 Gestion des médias"]
        Radarr["RadarrFilms"]
        Sonarr["SonarrSéries"]
        Prowlarr["ProwlarrIndexer"]
    end

    subgraph Download["⬇️ Téléchargement"]
        qBit["qBitTorrentClient Torrent"]
        FlareSolverr["FlareSolverrBypass Captcha"]
    end

    subgraph Storage["💾 Stockage"]
        TorrentDir["/downloadsTéléchargements"]
        MoviesDir["/moviesFilms"]
        SeriesDir["/seriesSéries"]
    end

    subgraph Monitoring["📊 Monitoring"]
        Tautulli["TautulliAnalytics Plex"]
        Kuma["Uptime KumaMonitoring"]
        Alerts["Slack/DiscordAlertes"]
    end

    %% User flows
    User -->|"Regarde"| Plex
    User -->|"Demande"| Overseerr

    %% Overseerr connections
    Overseerr -->|"Film demandé"| Radarr
    Overseerr -->|"Série demandée"| Sonarr
    Overseerr -.->|"Sync utilisateurs"| Plex

    %% Arr stack
    Radarr -->|"Recherche"| Prowlarr
    Sonarr -->|"Recherche"| Prowlarr
    Prowlarr -->|"Bypass CF"| FlareSolverr
    Prowlarr -->|"Télécharge"| qBit

    %% Download flow
    qBit -->|"Sauvegarde"| TorrentDir
    Radarr -->|"Importe"| MoviesDir
    Sonarr -->|"Importe"| SeriesDir

    %% Plex access
    Plex -.->|"Lit"| MoviesDir
    Plex -.->|"Lit"| SeriesDir

    %% Monitoring
    Tautulli -->|"Analyse"| Plex
    Kuma -->|"Poll"| Plex
    Kuma -->|"Poll"| Overseerr
    Kuma -->|"Poll"| Radarr
    Kuma -->|"Poll"| Sonarr
    Kuma -->|"Alerte"| Alerts

Infra de l'IA de ce site

Pour générer des résumés automatiques, j'utilise les WorkerAI de Cloudflare.

Securité

Il fallait déjà sécuriser les appels, pour ça il y a un worker dédié par lequel toutes les requêtes passent.

Il permet de vérifier les tokens d'authentification, rajouter un ratelimit, etc ...

Un petit bout de code:

"""Security gateway worker - handles auth and forwards to API worker."""
import logging
import json
import js
from workers import WorkerEntrypoint, Response


ALLOWED_ORIGINS = [
    "",
    "",
    "",
    "",
]

CORS_HEADERS = {
    "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type, API_KEY_TOKEN",
}

# Rate limit config
RATE_LIMIT_MAX = int
RATE_LIMIT_WINDOW = int


def get_timestamp():
    """Retourne le timestamp actuel en secondes."""
    return int(js.Date.now() / 1000)


def error_response(detail: str, status: int = 400, origin: str = None):
    headers = {"Content-Type": "application/json"}
    if origin and origin in ALLOWED_ORIGINS:
        headers["Access-Control-Allow-Origin"] = origin
    return Response(
        json.dumps({"detail": detail}),
        status=status,
        headers=headers
    )


def add_cors_headers(response: Response, origin: str) -> Response:
    headers = {**dict(response.headers), **CORS_HEADERS}
    if origin in ALLOWED_ORIGINS:
        headers["Access-Control-Allow-Origin"] = origin
    return Response(
        response.body,
        status=response.status,
        headers=headers
    )


class Default(WorkerEntrypoint):
    """Gateway Worker - Auth and forward."""

    async def _check_rate_limit(self, ip: str) -> bool:
        """Retourne True si rate limit dépassé."""
        key = f"ratelimit:{ip}"
        now = get_timestamp()
        window_start = now - RATE_LIMIT_WINDOW

        try:
            data = await self.env.KV.get(key)
            if data:
                record = json.loads(data)
                requests = [ts for ts in record["requests"] if ts > window_start]
            else:
                requests = []

            if len(requests) >= RATE_LIMIT_MAX:
                return True

            requests.append(now)
            await self.env.KV.put(
                key,
                json.dumps({"requests": requests}),
                expiration_ttl=RATE_LIMIT_WINDOW
            )
            return False

        except Exception as e:
            logging.error(f"Rate limit error: {e}")
            return False

    async def fetch(self, request):
        url = request.url
        method = request.method
        path = "/" + url.split("/", 3)[-1]
        origin = request.headers.get("Origin")
        client_ip = request.headers.get("CF-Connecting-IP", "unknown")

        # Handle CORS preflight
        if method == "OPTIONS":
            headers = {**CORS_HEADERS}
            if origin in ALLOWED_ORIGINS:
                headers["Access-Control-Allow-Origin"] = origin
            return Response("", headers=headers)

        # Route PUBLIQUE avec rate limit
        if path.startswith("/le/path/qui/va/bien"):
            if origin not in ALLOWED_ORIGINS:
                return error_response("Forbidden", 403, origin)

            if await self._check_rate_limit(client_ip):
                return error_response("Rate limit exceeded. Try again later.", 429, origin)

            response = await self.env.AI_WORKER.fetch(request)
            return add_cors_headers(response, origin)

        # Health endpoint
        if path.startswith("/le/path/qui/va/bien"):
            auth_error = await self._verify_headers(
                request=request,
                header_name="header spécifique pour le health check",
                secret=self.env.LE_TOKEN_QUI_VA_BIEN,
            )
            if auth_error:
                return auth_error

        # Other endpoints need token
        else:
            auth_error = await self._verify_headers(
                request=request,
                header_name="X-API-Token",
                secret=self.env.API_KEY_TOKEN,
            )
            if auth_error:
                return auth_error

        # Forward to AI worker
        if path.startswith("/le/path/qui/va/bien"):
            response = await self.env.A_FIRST_WORKER.fetch(request)
            return add_cors_headers(response, origin)

        # Forward to API worker
        response = await self.env.ANOTHER_WORKER.fetch(request)
        return add_cors_headers(response, origin)

    async def _verify_headers(
        self,
        request,
        header_name: str,
        secret: any,
    ) -> Response | None:
        try:
            token = request.headers.get(header_name)
            if not token:
                return error_response("Token not valid", 403)

            secret_binding = secret
            if hasattr(secret_binding, 'get') and callable(secret_binding.get):
                secret_value = await secret_binding.get()
            else:
                secret_value = secret_binding

            if token != secret_value:
                return error_response("Token not valid", 403)

            return None
        except Exception as e:
            logging.error(f"Auth error: {e}")
            return error_response("Authentication error", 500)

IA et Summarize

J'ai créer une API à base de plusieurs Worker qui font appel aux models locaux chez Cloudflare.

Voilà le petit obut de code:

from workers import WorkerEntrypoint, Response
import json
import js

class Default(WorkerEntrypoint):
    async def fetch(self, request):
        body = await request.json()
        prompt = body.get("prompt")

        if prompt is None:
            return Response(
                json.dumps({"error": "Bad request"}),
                status=400,
                headers={"Content-Type": "application/json"},
            )

        try:
            result = await self.env.AI.run(
                "@cf/le_bon/model",
                js.JSON.parse(json.dumps({
                    "messages": [
                        {
                            "role": "user",
                            "content": "Le petit prompt qui va bien avec la page qu'on veut summarize."
                        }
                    ],
                    "max_tokens": int
                })),
            )

            # Convertir via JSON.stringify
            result_str = js.JSON.stringify(result)
            result_dict = json.loads(result_str)
            summary = result_dict.get('response', str(result_dict))

            return Response(
                json.dumps({"result": {"summary": summary}}),
                status=200,
                headers={"Content-Type": "application/json"},
            )
        except Exception as e:
            return Response(
                json.dumps({"error": str(e)}),
                status=500,
                headers={"Content-Type": "application/json"},
            )