Architecture¶
Comment le serveur est assemblé : les couches hexagonales sous internal/,
les contextes bornés, les événements qui les relient, et les gardes statiques
qui obligent le code à respecter cette structure. Le code se trouve dans
internal/domain, internal/app, internal/adapters/{driving,driven} et est
assemblé dans
cmd/evey/wire*.go ; la disposition est décidée par
ADR-0001, la séparation des dépôts par
ADR-0009 et le bus d'événements par
ADR-0010. Pour construire et lancer le serveur, lisez
DEV.md ; pour savoir quels fichiers toucher pour une modification
donnée, lisez la Carte du code.
Le serveur est en Go ; les clients web, mobile et TV forment un monorepo
TypeScript/React sous frontend/ (React Native / Expo pour le mobile et la TV).
Couches¶
flowchart LR
driving[Adaptateurs entrants<br/>internal/adapters/driving<br/>grpcapi, http, pluginhost]
app[Services applicatifs<br/>internal/app/contexte]
domain[Domaine<br/>internal/domain/contexte<br/>modèles, objets-valeurs, ports]
driven[Adaptateurs sortants<br/>internal/adapters/driven<br/>postgres, ffmpeg, mkvgo, ...]
driving --> app --> domain
driven -. implémentent les ports .-> domain
| Dossier | Contient | Règle |
|---|---|---|
internal/domain/<contexte>/ |
les modèles, les objets-valeurs et les ports (interfaces) | aucun import de framework, aucun import d'un autre contexte |
internal/app/<contexte>/ |
les services applicatifs qui orchestrent les ports | les entrées-sorties passent par un port ; dlna (SSDP / UPnP AVTransport) et loadtest (son propre client HTTP) sont les deux exceptions délibérées qui atteignent le réseau directement |
internal/adapters/driven/ |
les adaptateurs sortants qui implémentent les ports : postgres, meilisearch, ffmpeg, mkvgo, filesystem, prober, opensubtitles, email, webpush, webhooksender, pluginrunner, metadata, introproviders, search |
trois autres satisfont une interface de la couche applicative plutôt qu'un port du domaine : itunes et musicbrainz (app/music.AlbumArtFetcher), ociregistry (app/updatecheck) |
internal/adapters/driving/ |
les adaptateurs entrants : grpcapi (+ HTTP via grpc-gateway), http (statique, middleware d'authentification, SSE, HLS, ressources des iframes de plugins), pluginhost (serveur gRPC sur une socket Unix, authentifié par les identifiants du pair, l'API hôte que rappellent les plugins en sandbox) |
|
internal/pkg/ |
le noyau technique partagé : auth, logger, eventbus, i18n, metrics, tracing, ratelimit, procfork, ssrf, cache, ... | |
internal/config/ |
la configuration du serveur | |
gen/ |
le code proto généré |
Règle de dépendance : adapters -> app -> domain. Le domaine n'importe jamais
la couche applicative ni les adaptateurs. La composition est écrite à la main
(pas google/wire) dans cmd/evey/wire.go et ses voisins wire_infra.go,
wire_services.go, wire_adapters.go, wire_events.go, wire_helpers.go.
Les tests contournent les adaptateurs en injectant des doublures qui
implémentent les mêmes ports du domaine : 20 des 21 paquets du domaine
livrent un sous-paquet fakes/.
Contextes bornés¶
Trente-deux paquets de contexte se trouvent sous internal/domain/ et
internal/app/ : 19 existent dans les deux couches, 2 n'existent que dans le
domaine (ratings, shared), 11 n'existent que dans la couche applicative.
Ils collaborent en partie par des événements de domaine sur un bus partagé ;
la couche applicative lit aussi directement le paquet de domaine d'un
contexte voisin (voir Relations).
| Contexte | Responsabilité |
|---|---|
user |
comptes, authentification, sessions, préférences |
library |
bibliothèques, dossiers, tâches de scan, index des médias, personnes, collections, favoris et votes pouce levé / baissé d'un profil (ces votes n'ont pas de service applicatif : le RPC de notation stocke et lit UserRatingRepository tel quel), trickplay, détection des intros et génériques |
media (app seulement) |
liens de partage et jetons de flux |
mediarequest |
demandes de médias soumises par les utilisateurs |
metadata |
enrichissement TMDB / OMDb, illustrations |
playback |
flux, progression, télécommande, lecture en cours |
transcoding |
tâches ffmpeg, segments HLS |
subtitle |
sous-titres intégrés + externes + opensubtitles |
mkv |
opérations sur le conteneur MKV (via mkvgo) |
music |
lecture et étiquetage propres à l'audio |
photo |
bibliothèques de photos |
favorite (app seulement) |
favoris par profil ; le modèle et FavoriteRepository se trouvent dans domain/library |
playlist |
listes de lecture des utilisateurs, listes intelligentes |
collection (app seulement) |
collections constituées par l'administrateur ; ses ports se trouvent dans domain/library |
ratings (domaine seulement) |
projette les classifications nationales (CSA, FSK, MPAA, BBFC, ACB) sur un seul axe d'âge minimum |
feed |
fil d'activité / découverte |
activity |
chronologie d'activité des utilisateurs |
chromecast |
cycle de vie des sessions de cast |
syncplay |
sessions synchronisées entre appareils |
plugin |
registre des plugins, jetons de sandbox, cycle de vie |
notification |
notifications dans l'application + webpush |
webhook |
webhooks sortants |
backup |
sauvegarde et restauration de la base et de la configuration |
config (app seulement) |
réglages persistants du serveur, écrits par les points d'entrée REST et gRPC |
dlna (app seulement) |
découverte SSDP et pilotage UPnP AVTransport |
loadtest (app seulement) |
simule des spectateurs contre la propre pile HTTP du serveur : en HLS, ou sur le fichier brut quand le verdict d'un client est une lecture directe |
maintenance (app seulement) |
maintenance des médias déclenchée par l'administrateur : reconstruction de l'index de lecture, journal de réparation, annulation, intégrité audio |
presence (app seulement) |
registre en mémoire, non persisté, de qui est connecté en ce moment |
qoe (app seulement) |
ingestion et agrégation de la télémétrie de lecture CMCD (CTA-5004) |
demo (app seulement) |
l'instance de démonstration publique (EVEY_DEMO=1) : alimente ses comptes, ses bibliothèques et ses médias sous licence libre via les services du produit, et se réinitialise |
updatecheck (app seulement) |
compare le build en cours avec ce que publie le registre et prévient l'administrateur ; ne met jamais rien à jour |
shared (domaine seulement) |
ports transverses (EventBus, TransactionalUnit, ServerConfigRepository, ShareLinkRepository), identifiants typés, curseurs, jetons de flux |
Relations¶
| Émetteur | Événements | Consommés par |
|---|---|---|
library |
LibraryCreated / LibraryDeleted, LibraryScanStarted / LibraryScanCompleted, MediaItemAdded, MediaItemRemoved, TrickplayGenerated |
media.item.added : notification, metadata (enrichissement automatique), library lui-même (worker trickplay + détection des intros), feed (invalidation du cache des genres) |
playback |
PlaybackStarted / PlaybackStopped |
aucun abonné dédié aujourd'hui ; seul l'abonnement générique "*" de webhook les reçoit |
user |
UserCreated, user.password.reset_requested |
le premier n'a pas d'abonné ; le second est consommé par l'adaptateur sortant email |
pluginrunner |
plugin.error / plugin.started |
app/plugin pour son tableau de bord sécurité, notification pour l'administrateur |
shared |
ServerConfigChanged |
les services qui appliquent sans redémarrage un réglage enregistré dans le panneau d'administration (SMTP, fuseau horaire, budget CPU, ...) |
- L'indexation de recherche n'est pas pilotée par événement :
library.ScanServiceappelle directement le portlibrary.SearchIndex. - Aucune passerelle ne transmet les événements de domaine aux plugins : les plugins atteignent l'API avec des JWT à portée limitée (ADR-0003). Le bus lui-même est l'ADR-0010.
shared.TransactionalUnitenveloppe les écritures multi-agrégats (ADR-0002).
Aucun paquet sous internal/domain n'importe le paquet de domaine d'un autre
contexte, règle imposée par internal/contractguard/domain_imports_test.go
(voir Gardes d'architecture). La couche applicative
n'y est pas tenue : 15 paquets applicatifs importent aujourd'hui le paquet de
domaine d'un contexte voisin.
Flux de données typique : « un utilisateur lance un média »¶
- Le client mobile ou web appelle
POST /api/v1/playback/start(route grpc-gateway). internal/adapters/driving/httpexécute son middleware d'authentification (auth_middleware.go,httpAuthMiddleware) et extrait la session.internal/adapters/driving/grpcapi(playback_handler.go) valide le proto et appelleapp/playback.CommandService.StartStream(séparation commande/requête, voir ADR-0009).app/playback.CommandServiceappelle les ports du domaine :user.UserRepository,playback.SessionQueryRepo(idempotence + plafond d'appareils),playback.SessionCommandRepo, puisshared.EventBus.Publish(PlaybackStarted).- Les adaptateurs sortants s'exécutent :
postgrespersiste la session dans une portéeshared.TransactionalUnit.Do; le bus en mémoire (internal/pkg/eventbus) distribue de façon asynchrone. PlaybackStartedn'a pas d'abonné dédié aujourd'hui : seul l'abonnement générique"*"dewebhookle reçoit.- La réponse revient en JSON via grpc-gateway ; les mises à jour de progression passent par SSE / flux gRPC.
Ce qui se passe ensuite, quand le lecteur demande le manifeste et les segments, fait l'objet de Pipeline de lecture.
Gardes d'architecture¶
Les règles ci-dessus ne sont pas que de la documentation.
internal/contractguard/ contient 58 tests statiques répartis sur 30
fichiers qui font échouer le build dès que le code s'en écarte.
La règle hexagonale elle-même est TestDomainContextsDoNotImportEachOther
(ADR-0001), dans domain_imports_test.go. Elle parcourt internal/domain et
impose exactement ceci : un contexte peut importer internal/domain/shared et
sa propre arborescence, rien d'autre dans internal/domain, et jamais
internal/app ni internal/adapters. Sa portée s'arrête à
internal/domain : elle ne s'applique pas à la couche applicative.
Les autres protègent des contrats qui franchissent une frontière de langage ou de processus, parmi lesquels :
| Garde | Ce qu'elle refuse |
|---|---|
TestNoClientReadOfAFieldTheServerNeverWrites |
un champ de réponse proto lu par un client que le serveur n'écrit nulle part |
TestRegisteredHandlersGetEveryPortTheyDeclare, TestBootInstallsItsSilentWirings |
un câblage qui échouerait en silence |
TestPostgresRepositoriesBoundTheirQueries |
une requête d'un dépôt Postgres sans délai limite |
TestProcfork_EveryForkGoesThroughCommand |
un lancement de sous-processus qui contourne internal/pkg/procfork |
TestEverySortFieldTheAPIAcceptsHasASQLColumn, ...CanBuildACursorValue |
un champ de tri absent de la table des colonnes SQL ou de l'encodeur de curseur |
TestEveryWebSurfaceReachingAnAdminRPCIsClassified, et son pendant natif pour mobile et TV |
une surface d'interface appelant un RPC réservé à l'administrateur sans décider qui la voit |
TestEveryAllowedSlotIsMountedOnWebAndMobile, TestPluginBridgeParityWebMobile |
un slot d'interface de plugin ou un message du pont présent sur un seul client |
TestImageSubtitleCodecsAgreeAcrossServerAndClients |
une seconde liste de codecs de sous-titres bitmap sur un lecteur |
TestHandWrittenClientTypesMatchTheEndpointTheyCall |
un type de réponse écrit à la main pour mobile/TV qui dérive de son point d'entrée |
TestOnlyThePlanDecidesPlayback, TestNoCallerDerivesDownstreamOfThePlaybackPlan |
une décision de lecture prise hors du résolveur de plan (ADR-0030) |
TestRestartExitCodeAgreesWithTheEntrypoint |
un code de sortie Go et docker/entrypoint.sh en désaccord sur ce que signifie un redémarrage |
TestServerEmittedI18nKeysExistInCatalogue, TestI18nPlaceholdersAgreeAcrossLocales |
une clé émise par le serveur absente du catalogue, des espaces réservés qui diffèrent entre langues |
Diagrammes C4¶
Quatre diagrammes documentent Evey à des niveaux de zoom décroissants selon
le modèle C4. Les noms des nœuds sont les vrais noms
des paquets Go sous internal/. Les noms des conteneurs viennent de
docker-compose.yml pour evey-server, evey-postgres et evey-caddy ;
evey-web, evey-meilisearch, evey-mobile et evey-mailcatcher sont
déclarés dans docker-compose.override.yml et n'existent que dans la
topologie de développement.
Niveau 1 : contexte du système¶
Qui communique avec Evey, et quels tiers Evey contacte.
flowchart LR
user[Utilisateur<br/>client web / mobile / TV]
admin[Administrateur<br/>interface web /admin]
oidc[Fournisseur d'identité OIDC<br/>SSO optionnel]
tmdb[API TMDB<br/>métadonnées films/séries]
omdb[API OMDb<br/>métadonnées et notes de repli]
imdb[IMDbAPI / TVmaze<br/>métadonnées de séries de repli]
art[MusicBrainz / iTunes Search<br/>pochettes d'albums]
introdb[IntroDB / TIDB<br/>horodatages intro + générique]
opensubs[OpenSubtitles<br/>recherche de sous-titres]
webpush[Passerelle Web Push<br/>FCM / APNs / VAPID]
registry[Registre OCI<br/>tags d'image publiés]
subgraph evey[Serveur multimédia Evey]
core((Plateforme multimédia auto-hébergée<br/>bibliothèque, lecture, transcodage,<br/>musique, photo, plugins))
end
user -->|HTTPS<br/>HLS / SSE / gRPC-Web| evey
admin -->|HTTPS<br/>/admin| evey
evey -.->|flux de code OIDC| oidc
evey -.->|récupération de métadonnées HTTPS| tmdb
evey -.->|récupération de métadonnées HTTPS| omdb
evey -.->|récupération de métadonnées HTTPS| imdb
evey -.->|récupération de pochettes HTTPS| art
evey -.->|recherche d'intro HTTPS| introdb
evey -.->|récupération de sous-titres HTTPS| opensubs
evey -.->|envoi de push HTTPS| webpush
evey -.->|vérification de mise à jour HTTPS| registry
Niveau 2 : conteneurs¶
Les unités déployables lancées par docker compose --env-file .env.dev up.
Les flèches pleines sont le trafic à l'exécution ; les flèches pointillées
sont le trafic sortant vers des tiers.
flowchart TB
user([Navigateur / mobile de l'utilisateur])
admin([Navigateur de l'administrateur])
subgraph compose[Réseau Docker Compose]
caddy[evey-caddy<br/>Caddy 2.9<br/>:80 / :443<br/>TLS, reverse proxy]
web[evey-web<br/>Vite en dev / nginx en prod<br/>:5173 en dev<br/>SPA React + RN web]
server[evey-server<br/>binaire Go<br/>:9091 HTTP+gRPC-gateway<br/>:9090 gRPC interne]
pg[(evey-postgres<br/>PostgreSQL 16<br/>:5432)]
meili[(evey-meilisearch<br/>Meilisearch 1.12<br/>:7700)]
mobile[evey-mobile<br/>dev seulement<br/>:8081 Expo / bundler Metro]
mail[evey-mailcatcher<br/>dev seulement<br/>:1025 SMTP / :1080 UI]
end
tmdb[[TMDB / OMDb / OpenSubtitles]]
fs[(volumes /media<br/>montages en lecture seule)]
cache[(volume evey-cache<br/>transcodage + trickplay)]
data[(volume evey-data<br/>clés, logs, sauvegardes,<br/>plugins, avatars)]
user -->|HTTPS| caddy
admin -->|HTTPS /admin| caddy
caddy -->|HTTP /| web
caddy -->|HTTP /v1, /api, SSE, HLS| server
web -->|gRPC-Web via /v1| server
user -.->|bundle Metro de dev| mobile
server -->|SQL/TCP 5432| pg
server -->|HTTP 7700<br/>indexation + recherche| meili
server -.->|SMTP 1025 en dev| mail
server -.->|HTTPS| tmdb
server -->|lecture FS| fs
server -->|lecture/écriture FS| cache
server -->|lecture/écriture FS| data
La production diffère de cette topologie de développement
Le compose d'installation déclare un seul service, evey. Il n'y a pas
de conteneur evey-web séparé (ni le mailcatcher et le bundler Metro,
réservés au dev) : le binaire sert l'interface web construite depuis
/srv, donc un reverse proxy lui transmet tout, interface et API, sur
:9091. Postgres et Meilisearch tournent dans le même conteneur,
supervisés par docker/entrypoint.sh : PostgreSQL 17 avec ses données
sous /data/postgres, et le binaire Meilisearch (Dockerfile, étape
meili-download, figé en 1.12.8) avec son index sous /data/meili. Le
docker-compose.yml du dépôt garde des services postgres et caddy
séparés, décrits dans
Déploiements avancés. Voir
Installation et Reverse proxy.
Niveau 3 : composants (zoom sur evey-server)¶
Les couches hexagonales à l'intérieur du binaire Go. Les adaptateurs entrants appellent les services applicatifs par leurs interfaces ; les services applicatifs ne dépendent que des ports du domaine ; les adaptateurs sortants implémentent ces ports.
flowchart LR
subgraph driving[Adaptateurs entrants]
grpc[grpcapi<br/>internal/adapters/driving/grpcapi<br/>handlers par contexte]
gw[grpc-gateway<br/>JSON/HTTP <-> gRPC<br/>généré depuis les proto]
http[http<br/>internal/adapters/driving/http<br/>statique, SSE, HLS,<br/>auth_middleware.go]
plug[pluginhost<br/>internal/adapters/driving/pluginhost<br/>gRPC sur socket Unix<br/>auth par identifiants du pair]
end
subgraph app[Couche applicative - internal/app]
direction TB
a_lib[library]
a_med[media]
a_meta[metadata]
a_play[playback]
a_trans[transcoding]
a_sub[subtitle]
a_mkv[mkv]
a_music[music]
a_photo[photo]
a_user[user]
a_upd[updatecheck]
a_misc[favorite, playlist, collection,<br/>feed, activity,<br/>chromecast, syncplay,<br/>plugin, notification, webhook,<br/>backup, dlna, config, mediarequest,<br/>maintenance, presence, qoe, loadtest,<br/>demo]
end
subgraph domain[Cœur du domaine - internal/domain]
d_models[Agrégats + objets-valeurs<br/>par contexte borné]
d_ports[Ports sortants<br/>dépôts - séparation commande/requête,<br/>EventBus, TransactionalUnit,<br/>ServerConfigRepository]
end
subgraph driven[Adaptateurs sortants]
pgad[postgres<br/>dépôts + migrations<br/>shared.TransactionalUnit]
meiad[meilisearch<br/>index de recherche]
fsad[filesystem<br/>scanner, parser, watcher]
ffad[ffmpeg<br/>transcodeur, trickplay]
mkvad[mkvgo<br/>opérations MKV]
metaad[metadata<br/>clients TMDB, OMDb,<br/>IMDbAPI, TVmaze]
introad[introproviders<br/>chaîne IntroDB + TIDB]
itunad[itunes<br/>pochettes d'albums]
mbad[musicbrainz<br/>pochettes d'albums]
ociad[ociregistry<br/>tags publiés]
probead[prober<br/>voie rapide mkvgo,<br/>repli ffprobe]
searchad[search<br/>façade résiliente sur meili]
osad[opensubtitles]
emad[email]
wpad[webpush]
whad[webhooksender]
prad[pluginrunner]
end
grpc --> app
gw --> grpc
http --> app
plug --> app
app --> d_ports
app --> d_models
d_ports -.implémente.- pgad
d_ports -.implémente.- meiad
d_ports -.implémente.- fsad
d_ports -.implémente.- ffad
d_ports -.implémente.- mkvad
d_ports -.implémente.- metaad
d_ports -.implémente.- introad
d_ports -.implémente.- probead
a_music -.interface applicative.- itunad
a_music -.interface applicative.- mbad
a_upd -.interface applicative.- ociad
d_ports -.implémente.- searchad
d_ports -.implémente.- osad
d_ports -.implémente.- emad
d_ports -.implémente.- wpad
d_ports -.implémente.- whad
d_ports -.implémente.- prad
pgad --> pg2[(PostgreSQL)]
meiad --> me2[(Meilisearch)]
fsad --> fs2[(montages /media)]
ffad --> cc2[(evey-cache + binaire ffmpeg)]
metaad -.-> ext2[[TMDB / OMDb / IMDbAPI / TVmaze]]
introad -.-> ext3[[IntroDB / TIDB]]
itunad -.-> ext4[[iTunes Search / MusicBrainz]]
mbad -.-> ext4
ociad -.-> ext5[[Registre OCI]]
Notes sur les goroutines :
- Le serveur gRPC (
grpc.Server.Serve) et le serveur HTTP (gateway + statique) tournent chacun dans leur propre goroutine, lancée depuis le câblage principal decmd/evey. - Les tâches de scan de
app/librarytournent sur un pool de workers (une goroutine par scan de bibliothèque actif, voirlibrary.ScanService). - Les tâches ffmpeg sont lancées via
internal/pkg/procforkpar l'adaptateur sortantffmpeg(ffmpeg.Transcoder.Start), avec une goroutine de supervision par tâche qui vide la sortie d'erreur du processus. shared.EventBusdistribue aux abonnés de façon asynchrone : chaque abonné tourne dans sa propre goroutine, pour qu'un consommateur lent ne bloque jamais les émetteurs. Le bus n'ouvre aucune transaction lui-même ; c'est le travail du handler.pluginrunnerlance un sous-processus par plugin ; les plugins en iframe s'exécutent dans le navigateur de l'utilisateur et répondent via des jetons signés émis parapp/plugin.
Niveau 3 (bonus) : zoom sur le pipeline de scan¶
Le chemin critique qui transforme des fichiers sur disque en médias enrichis et
cherchables. Les flèches pleines sont des appels directs ; les flèches
pointillées sont des événements de domaine sur le bus. L'indexation de
recherche est un appel direct de port depuis ScanService, pas un événement.
flowchart LR
user([L'administrateur déclenche<br/>POST /api/v1/libraries/:id/scan])
subgraph driving2[entrant]
h[grpcapi<br/>library_handler.go]
end
subgraph appLayer[internal/app/library]
svc[ScanService<br/>pool de workers,<br/>1 goroutine par bibliothèque]
end
subgraph drivenScan[sortant]
scanner[filesystem.Scanner<br/>parcours + parser]
watcher[filesystem.FSWatcher<br/>fsnotify, incrémental]
end
subgraph domainScan[domain/library]
repo[(ports MediaItemCommandRepo + MediaItemQueryRepo)]
bus[(port EventBus)]
end
subgraph persist[persistance sortante]
pgrepo[postgres.MediaItemRepository<br/>dans un TransactionalUnit]
searchidx[meilisearch.SearchAdapter<br/>implémente library.SearchIndex]
end
subgraph consumers[Consommateurs d'événements - chacun dans sa goroutine]
meta[app/metadata<br/>enrichissement TMDB/OMDb]
trick[app/library<br/>worker trickplay]
intro[app/library<br/>détection des intros]
feed[app/feed<br/>invalidation du cache des genres]
notif[app/notification<br/>alertes de nouveaux médias]
wh[app/webhook<br/>fourre-tout, POST sortant]
end
user --> h --> svc
svc --> scanner
watcher --> svc
scanner --> svc
svc --> repo
repo --> pgrepo
svc --> searchidx
svc --> bus
bus -. MediaItemAdded .-> meta
bus -. MediaItemAdded .-> trick
bus -. MediaItemAdded .-> intro
bus -. MediaItemAdded .-> feed
bus -. MediaItemAdded .-> notif
bus -. MediaItemAdded .-> wh
trick -. TrickplayGenerated .-> intro
meta --> repo
trick --> ffstore[(evey-cache<br/>sprites de trickplay)]
À lire avec la section « Flux de données typique » ci-dessus : la lecture d'un média est le côté lecture des données ; le pipeline de scan est le côté écriture, qui remplit l'index des médias où la lecture va chercher ce qu'elle sert.
Pour aller plus loin¶
- DEV.md : construction, lancement, migrations, génération des proto
- Carte du code : quels fichiers toucher pour les modifications courantes
- Pipeline de lecture : le chemin de lecture vidéo HLS, en détail
- Référence de l'API : référence générée de l'API
- Décisions d'architecture : les ADR