Plugin Discord pour Oscarr. Vos utilisateurs lient leur compte Discord une fois, cherchent sur TMDB, soumettent des demandes de films et séries, et suivent l'état de leurs demandes depuis Discord. Les notifications d'Oscarr (demande approuvée, média disponible) leur arrivent en DM.
Leonarr n'est pas un bot autonome. C'est un plugin Oscarr packagé qui passe uniquement par le PluginContext v1.1 (ctx.tmdb, ctx.requests, ctx.media, event bus, plugin permissions, frontend isolé). Tout ce que votre UI web fait déjà — folder rules, quality mappings, blacklist, plugin guards, auto-approve — s'applique côté Discord sans rejeu de logique. Voir docs/plugins.md côté Oscarr pour le contrat complet.
/link: envoie un deep link éphémère vers le flow Discord OAuth d'Oscarr. L'utilisateur valide une fois, Oscarr stocke le mapping Discord ↔ Oscarr dansUserProvider./search <query>: autocomplétion TMDB viactx.tmdb.search. La réponse a trois branches selon l'état de la bibliothèque Oscarr : déjà disponible, déjà demandée, ou proposition de soumission. Embed avec poster et boutons de pagination./status: les 10 dernières demandes de l'utilisateur viactx.requests.listForUser, avec progression live des téléchargements en cours viactx.getArrClients./help: liste les commandes.- DM automatique sur notification : Leonarr souscrit à l'event
user.notification.created(émis parsafeUserNotifycôté Oscarr) et envoie un embed avec poster, titre traduit (titleText/messageTextviennent du payload) et un emoji selon le type. Pas de polling. - Annonce de canal optionnelle : si
announceChannelIdest défini, Leonarr poste dans ce canal à chaque eventmedia.available.
Pas d'imports en backdoor, pas d'auto-HTTP, pas de cron, pas de table de mapping séparée. Oscarr garde la main sur les UserProvider.
- Une instance Oscarr
>=0.7.0-0 <1.0.0(testé contre0.7.0, ce qui donne le badge Verified sur la page d'install viaengines.testedAgainst). Les capacités v1.1 duPluginContextsont requises. - Une application Discord avec un bot token. Récupérez-les depuis discord.com/developers/applications.
- Le provider Discord OAuth activé côté Oscarr (Admin → Authentification → Discord). Réutilisez le même
clientId/clientSecretque ceux du bot. AppSettings.siteUrlrenseigné côté Oscarr :/links'en sert comme base canonique pour générer le deep link OAuth. Sans cette valeur, la commande log un warning et n'envoie rien.- Node.js 22+ pour le build local (
target=node22côté esbuild).
Au runtime, Leonarr doit se trouver dans le répertoire scanné par Oscarr — packages/plugins/leonarr/ dans le monorepo, ou tout autre chemin pointé par la variable d'environnement OSCARR_PLUGINS_DIR. Le scan suit les symlinks et ignore les dossiers cachés.
git clone https://github.com/kedaewyn/Leonarr.git /opt/leonarr
cd /opt/leonarr
npm install
npm run buildnpm run build produit trois artefacts dans dist/ :
dist/index.js: bundle backend (platform=node, ESM, target Node 22).discord.jset@oscarr/sharedrestent externes et sont résolus au runtime.dist/frontend/index.js: composant React de l'onglet admin (platform=browser). React,react-dom,react/jsx-runtimeet@oscarr/sdksont externes ; l'importmap d'Oscarr les fournit.dist/frontend/index.css: bundle Tailwind scoped au plugin. Oscarr purge sa propre Tailwind contre son tree à lui, donc les utilitiesndp-*utilisées seulement ici doivent être recompilées localement. Le patch d'isolation CSS de la 0.7.0 injecte automatiquement ce stylesheet.
En dev, un symlink vers packages/plugins/leonarr du monorepo est le plus simple :
ln -s /opt/leonarr /chemin/vers/Oscarr/packages/plugins/leonarrEn prod, deux options :
- Install from URL depuis l'admin Oscarr (Admin → Plugins → Install from URL) : collez l'URL de la tarball publiée par le workflow
release.yml. Le résolveur Oscarr lit la dernière release, repère l'asset dont le nom contient le token arch du container qui tourne (leonarr-x.y.z-linux-amd64.tar.gzouleonarr-x.y.z-linux-arm64.tar.gz) et le télécharge. Vous pouvez aussi pointer directement sur l'asset arch que vous voulez. Oscarr valide le manifest, dépose le contenu danspackages/plugins/leonarr/et hot-load le plugin sans redémarrer le conteneur. - Installation manuelle : décompressez la tarball dans le dossier scanné par Oscarr, ou clonez ce repo et lancez
npm run buildlocalement, puis redémarrez le service une fois pour que le plugin engine découvre le plugin.
Au démarrage, le plugin engine découvre leonarr, charge manifest.json, appelle register() et log [PluginEngine] Loaded "leonarr" v0.1.1. Le client Discord reste inactif tant que les settings ne sont pas remplis.
Ouvrez Oscarr → Admin → Plugins → Leonarr. L'onglet ressemble aux autres tabs admin :
- En tête : titre, description, et pill running / stopped (poll toutes les 10 s tant que l'onglet est visible).
- Barre d'actions : Start, Stop, Restart. Le bouton Start est désactivé tant qu'un setting requis manque.
- Carte Settings : un champ par setting du manifest, bouton Save en bas.
| Paramètre | Requis | Notes |
|---|---|---|
botToken |
oui | Bot token Discord (developer portal → Bot → Token). |
clientId |
oui | Application ID Discord. |
guildId |
non | Enregistre les commandes sur un seul serveur avec propagation instantanée. Vide = global, jusqu'à 1 h de propagation. |
announceChannelId |
non | ID du canal pour les annonces media.available. Vide = DMs uniquement. |
Les boutons et leurs routes sous-jacentes (POST /api/plugins/leonarr/start|stop|restart) sont gardés par la permission leonarr.control, enregistrée au load et accordée aux admins par défaut. Vous pouvez la déléguer à un autre rôle pour avoir un opérateur de bot non-admin.
Restart est l'action à utiliser après un changement de settings : il bounce la gateway Discord et ré-enregistre les slash commands contre la nouvelle config.
Developer portal Discord → OAuth2 → URL Generator. Scopes : bot + applications.commands. Permissions minimum : Send Messages et Embed Links.
Leonarr utilise uniquement Guilds et Direct Messages. L'intent Message Content n'est pas nécessaire ; tout passe par slash commands et boutons.
- L'utilisateur lance
/link(channel ou DM). - Leonarr lit
AppSettings.siteUrl, construit l'URL${siteUrl}/api/auth/discord/authorize?action=linket la renvoie comme bouton dans une réponse éphémère. - L'utilisateur ouvre le lien et valide le consentement Discord OAuth. Le
stateUUID est généré et vérifié côté Oscarr — Leonarr ne signe rien lui-même. - Oscarr échange le code, récupère l'identité Discord et upsert la ligne
UserProvider(même sémantique que l'ajout de provider depuis le profil web). - Au prochain
/searchou/status, Leonarr résout l'utilisateur viactx.findUserByProvider('discord', discordId).
Pas de PIN, pas de polling, pas de table de mapping plugin-side. Si l'utilisateur existe déjà dans Oscarr (par exemple lié via Plex), le provider Discord est ajouté à son compte existant.
/search, sélection d'un résultat, clic sur Demander. Sous le capot, Leonarr appelle ctx.requests.create(...), qui est exactement le même pipeline que POST /api/requests :
validateRequestBodyrunPluginGuard(les autres plugins peuvent bloquer, par exemple un plugin abonnement)isBlacklistedfindOrCreateMedia(fetch TMDB + upsert DB)- check doublon sur les demandes actives du même utilisateur
auto-approvehonoré depuisAppSettingssendToService(Radarr ou Sonarr avec folder rules + quality mapping)
Le bot ne contourne jamais la validation ni les permissions : il est un client de plus du pipeline central.
Côté Oscarr, chaque appel à safeUserNotify émet un event user.notification.created sur le bus interne. Leonarr y souscrit via ctx.events.on(...), résout l'utilisateur Discord cible et envoie un DM avec embed : poster, titre traduit, emoji selon le type de notif (request.approved, media.available, etc.).
Si announceChannelId est aussi configuré, Leonarr s'abonne à media.available et poste un message dans ce canal pour chaque nouvelle dispo. Pratique pour un canal #nouveautes partagé.
Si l'utilisateur a désactivé les DMs du bot, le DM est silencieusement perdu : Discord n'expose pas de fallback fiable. L'event reste loggé côté Oscarr.
leonarr/
├── manifest.json # Métadonnées plugin (settings, capabilities, hooks UI)
├── build.js # esbuild — bundle backend + frontend + CSS Tailwind
├── package.json # discord.js, esbuild, react (dev)
├── tailwind.config.js # Tailwind scoped au plugin
├── frontend/
│ ├── index.tsx # Onglet admin (Start/Stop/Restart + form Settings)
│ ├── index.css # Sources Tailwind du plugin
│ └── oscarr-sdk.d.ts # Types du SDK frontend host
└── src/
├── index.ts # register(ctx) — onInstall/onEnable/onDisable + routes /status, /start, /stop, /restart
├── bot.ts # Lifecycle client Discord (start/stop/isRunning), routing events
├── types.ts # Types miroir du PluginContext v1.1 d'Oscarr
├── commands/
│ ├── link.ts # /link
│ ├── search.ts # /search + pagination + soumission
│ ├── status.ts # /status
│ └── help.ts # /help
├── events/
│ └── notifications.ts # Souscriptions user.notification.created + media.available
├── i18n/
│ ├── en.json
│ ├── fr.json
│ └── index.ts # Helper t(lang, key, vars)
└── lib/
└── sessionStore.js # In-memory TTL store partagé par /search et /status
src/types.ts mirroite @oscarr/shared/pluginContext pour que le repo reste autonome (pas besoin du monorepo Oscarr pour tsc). Quand le PluginContext évolue côté Oscarr, gardez ce fichier en sync : npm run typecheck vous le dira fort.
Les catalogues i18n sont des JSON dans src/i18n/{en,fr}.json, bundlés par esbuild. Ajoutez les nouvelles clés dans les deux fichiers.
Dans manifest.json :
settings:plugin · settings:app · users:read · tmdb:read · requests:read · requests:write · events · permissions
Chaque capacité a une justification d'une ligne dans manifest.capabilityReasons, affichée à l'admin lors de l'install ou de l'activation.
register(ctx) retourne quatre hooks que le plugin engine appelle dans cet ordre :
onInstall(ctx)— une seule fois, à la toute première découverte du plugin (flagué viaPluginState.onInstallRancôté Oscarr). Leonarr l'utilise pour logguer le message « fill the settings, then click Start » dans les logs admin.onEnable(ctx)— à chaque activation du plugin via l'admin. Le client Discord se connecte ici, pas au load.onDisable(ctx)— à la désactivation. Le client Discord est détruit, les souscriptions d'event nettoyées.registerRoutes(app, ctx)— au load et à chaque ré-activation. Y sont déclarésleonarr.control(registerPluginPermission), les trois RBAC rules (registerRoutePermission) et les routes/status,/start,/stop,/restart.
Rien de Discord ne tourne avant onEnable, et onDisable est garanti d'être appelé avant le déchargement. Pas d'action requise côté admin pour récupérer un état propre après un toggle off/on.
npm install
npm run dev # esbuild --watch sur les trois artefacts
npm run build # build minifié one-shot
npm run typecheck # tsc --noEmitbuild.js détecte --watch et bascule esbuild en mode incrémental. Les changements TS / TSX recompilent en quelques ms.
.github/workflows/ci.yml: sur chaque PR versmain, lancenpm ci, le syntax check Node,npm run typecheck,npm run buildet vérifie la présence dedist/index.js+dist/frontend/index.{js,css}..github/workflows/release.yml: sur chaque tagv*(ouworkflow_dispatch), lance Qodana (non bloquant), build deux tarballs par arch (amd64 + arm64) et publie une GitHub Release avec les deux assets + leurs.sha256. Voir la section suivante pour pourquoi deux assets..github/workflows/codeql.yml: CodeQL JS/TS + Actions, push surmain, PR, et planifié hebdomadaire.
Plus de pipeline Docker ni de push GHCR : Leonarr se distribue uniquement comme tarball de plugin, conformément au flow décrit dans docs/plugins.md côté Oscarr.
Le release publie deux tarballs nommés selon le pattern attendu par le résolveur Oscarr (packages/backend/src/plugins/routes.ts → ARCH_TOKENS) :
leonarr-x.y.z-linux-amd64.tar.gz— pour les containers x86_64 (process.arch === 'x64').leonarr-x.y.z-linux-arm64.tar.gz— pour les containers ARM64 (process.arch === 'arm64').
Chaque tarball contient manifest.json, package.json, package-lock.json, README.md, LICENSE, dist/ et node_modules/ prebuildé pour l'arch cible. Pourquoi node_modules dans le tarball : l'image prod d'Oscarr strippe npm/npx/corepack (Dockerfile), donc npm install ne tourne pas après l'extraction. Tout ce que dist/index.js importe au runtime — au premier rang discord.js qui reste externe au bundle esbuild — doit déjà être dans l'asset.
discord.js tire trois optional deps natives : zlib-sync (compression du gateway), bufferutil et utf-8-validate (perf des frames WebSocket). zlib-sync se compile via node-gyp à l'install ; les deux autres ont des prebuilds pour les arches usuelles. Buildées sur le runner amd64, elles ne marchent pas en arm64 et inversement — d'où la matrix.
Côté résolveur Oscarr (resolveInstallUrlFromRepo) : pour chaque install/update, il lit la latest release, filtre les *.tar.gz, et choisit dans cet ordre — (1) asset dont le nom contient un token matchant process.arch du container qui tourne, (2) asset sans aucun token arch (universel), (3) fallback sur le tarball source de HEAD. Notre release ne ship que (1) ; (2) n'existe pas. Si Oscarr tourne sur une arch hors amd64/arm64, l'install retombera sur le tarball source — qui ne contient pas dist/ ni node_modules, donc ne marchera pas. Aujourd'hui Oscarr ne supporte officiellement que ces deux arches, le risque est nul.
Si vous voulez reproduire un asset hors CI :
npm ci --no-audit --no-fund # installe + compile zlib-sync pour votre arch
npm run typecheck
npm run build
rm -rf node_modules
npm ci --omit=dev --no-audit --no-fund # production tree only
ARCH=$(node -e 'console.log(process.arch === "x64" ? "amd64" : process.arch)')
VERSION=$(node -p "require('./package.json').version")
tar -czf "leonarr-${VERSION}-linux-${ARCH}.tar.gz" \
manifest.json package.json package-lock.json README.md LICENSE* dist/ node_modules/Le tarball produit est strictement équivalent à celui de la CI, à condition d'être sur la bonne arch (x86_64 → amd64, aarch64 → arm64).
- Propagation des commandes globales : peut prendre jusqu'à une heure. Utilisez
guildIden dev pour des updates instantanés. - DMs désactivés : si l'utilisateur a coupé les DMs du bot, les notifications partent dans le vide. Discord n'expose rien de fiable pour le détecter en amont.
- Sonarr et nouvelles saisons sur une série existante : demander S4-5 alors que Sonarr a déjà la série ne déclenche qu'un
search missing, pas un ajout de saison. C'est un comportement d'Oscarr (requestscore) plus qu'une limite Leonarr.
Leonarr onEnable — bot token or client id missing
Remplissez les settings du plugin dans l'onglet admin, puis cliquez sur Restart.
Failed to register commands: DiscordAPIError[50001]: Missing Access
Le bot n'est pas dans le serveur référencé par guildId, ou il n'a pas le scope applications.commands. Ré-invitez-le avec ce scope.
Le DM /link n'arrive jamais.
La réponse de /link est éphémère dans le channel où la commande est lancée, pas un DM. Si l'éphémère n'apparaît pas, le bot n'a probablement pas la permission Use Application Commands dans ce salon.
L'onglet admin reste vide ou les styles sont cassés.
Le bundle dist/frontend/index.css n'a pas été buildé ou n'est pas servi. Vérifiez que npm run build a tourné et que le plugin engine est en >=0.7.0 (le patch d'isolation CSS y a été ajouté).
MIT.
