Architecture du backend
Décidé avec Samuel le 30 octobre 2026. Une architecture propre (clean architecture) en couches, avec CQRS par un médiateur, sans orchestrateurs.
Le code est en anglais (classes, méthodes, tables), la doc reste en français.
Projets
La solution Musiqud.slnx vit à la racine de src/api/. Chaque projet porte le préfixe Musiqud. :
| Projet | Rôle | Dépend de |
|---|---|---|
Domain | Les entités, qui deviennent les tables de la base, et leurs enums | rien |
Dtos | Les contrats : les requêtes que l'Api reçoit et les résultats que les handlers renvoient | Domain (ses enums seulement) |
Core | La logique, en tranches par domaine : messages, handlers, validateurs, services, modèles internes | Domain, Dtos |
Infrastructure | Ce qui touche l'extérieur : EF Core et Postgres, client MusicBrainz, caches | Core |
Api | L'exécutable et la racine de composition : endpoints /v1 et /bff, middlewares, configuration | Core, Infrastructure |
Generators | Au build seulement : génère le médiateur et vérifie les règles (analyseur Roslyn) | rien |
Les dépendances pointent toujours vers l'intérieur : jamais de Core vers Infrastructure, jamais de Domain vers quoi que ce soit. Dtos ne prend de Domain que ses enums, jamais une entité : un test d'architecture le vérifie. Generators est référencé comme analyseur par les autres projets, jamais à l'exécution. Un projet HostedServices s'ajoutera quand une tâche de fond en aura besoin (rafraîchir le catalogue, par exemple), avec la même règle : il ne dépend que de Core.
Ce qui sort de Core, c'est toujours un DTO. Une entité ne quitte jamais Core et Infrastructure, et un modèle interne de Core ne quitte jamais Core.
Dossiers
Core se découpe d'abord en tranches verticales, une par domaine (Catalog/, Journal/, Ratings/), puis, dans chaque tranche, en dossiers par concept : Queries/, Commands/, Services/. Ce qui n'appartient à aucun domaine va dans Core/Shared/, rangé par concept de la même façon. Les autres projets n'ont pas de tranches : ils se rangent directement par concept.
Musiqud.Core/
Catalog/
Queries/ SearchCatalog.cs, GetRelease.cs
Services/ CatalogService.cs
Interfaces/ ICatalog.cs, ICatalogData.cs
Projections/
Models/ les modèles de passage de la tranche
Journal/
Commands/ LogListen.cs
Queries/ ListJournalEntries.cs
Services/ JournalService.cs
Shared/
Behaviors/ Mediators/ Results/ Validators/ Helpers/
Interfaces/ IMediator, IData, ICurrentUser
- Une tranche correspond à un domaine, jamais à un écran ou à une fonctionnalité :
Journal/, pasHomeScreen/niRateAlbum/. Les domaines sont ceux de la liste des services. - Hors des tranches de Core, un dossier nommé d'après un domaine ou une fonctionnalité (
Journal/,Playlists/) est une erreur. - Les dossiers de concept ont un nom au pluriel, et une seule sorte d'élément par dossier : les endpoints et leurs réponses ne partagent pas un dossier, les tests et les faux non plus.
- Les namespaces suivent les dossiers :
Musiqud.Core.Catalog.Queries,Musiqud.Api.Endpoints. - Un fichier par type, nommé d'après lui, sauf pour une opération : voir Handlers.
- Une tranche peut utiliser les éléments publics d'une autre (un DTO, une entité), mais un handler n'appelle jamais le service d'une autre tranche : la logique partagée par deux tranches va dans un service de
Shared/ou dans un helper.
Où va quoi :
- Domain :
Entities/pour les entités,Enums/. - Dtos :
Requests/pour ce que l'Api reçoit (le corps d'une requête HTTP),Results/pour ce que les handlers renvoient. Les DTO de/v1, qui forment un contrat public, sont séparés :Requests/V1/etResults/V1/. Les noms finissent par leur sorte :RateReleaseRequest,CatalogPageResult. - Core : dans chaque tranche,
Commands/etQueries/(les opérations),Services/,Interfaces/(les adaptateurs que la tranche demande, commeICatalog, et son accès aux données,ICatalogData),Models/,Projections/,Errors/(les erreurs de la tranche),Constants/. DansShared/:Behaviors/,Mediators/,Results/(Result<T>,Error),Validators/,Helpers/,States/etInterfaces/(médiateur, contrats des handlers et des behaviors,IData, personne connectée). L'horloge estTimeProvider, intégré à .NET : pas d'interface à nous. - Infrastructure :
Databases/(leDbContext),Configurations/(le mapping EF de chaque entité),Migrations/(avec ses extensions deMigrationBuilder), et les adaptateurs par concept (Catalogs/,Caches/,Clients/). - Api :
Endpoints/,Middlewares/,Extensions/(l'enregistrement des services).
Modèles internes de Core
Core peut avoir ses propres modèles, qui ne servent qu'entre ses handlers et ses services, dans <Tranche>/Models/. Ils sont internal : l'Api ne les voit pas, et le compilateur le garantit. Un modèle qu'une interface d'adaptateur utilise (ce que ICatalog renvoie, par exemple) doit être public, puisqu'Infrastructure l'implémente; il reste quand même hors des réponses de l'Api, qui ne renvoie que des DTO.
Couches
Une requête HTTP traverse toujours le même chemin :
endpoint → médiateur → un handler → services → I<Tranche>Data (le DbContext)
↘ I<Tranche>Data
Endpoints
- Chaque endpoint envoie exactement un message au médiateur, par
IMediator.SendAsync: une commande s'il modifie quelque chose, une requête s'il ne fait que lire. Un endpoint qui choisit entre deux handlers selon ses arguments envoie une de deux commandes. - Un endpoint ne prend jamais un handler, un service ni une interface de données. Il construit le message à partir de la requête, l'envoie, et renvoie le DTO que le handler a produit.
/v1et/bffpartagent les handlers./bffrenvoie les résultats des handlers tels quels./v1est un contrat public, avec ses DTO à lui (Results/V1/) que l'endpoint construit à partir de ceux du handler : un changement incompatible demande un/v2./bffpeut changer, mais doit rester compatible avec les versions de l'app mobile encore installées.
Handlers
- Un handler implémente
ICommandHandler<TCommand, TResult>ouIQueryHandler<TQuery, TResult>, jamais les deux, et n'a qu'une méthode publique :HandleAsync. - Un handler n'appelle jamais un autre handler. Une opération en plusieurs étapes reste un seul handler; ce que deux handlers partagent va dans un service. Écrire puis relire, c'est un seul handler qui renvoie le résultat.
- Les variantes sont des champs du message, jamais des méthodes publiques de plus.
- Noms au verbe d'abord :
RateReleaseCommandetRateReleaseCommandHandler,GetReleaseQuery,ListJournalEntriesQuery,SearchCatalogQuery.Get…pour un élément,List…pour plusieurs,Search…pour une recherche. - Une opération, un fichier. Le message, son handler et, s'il en a un, son validateur vivent ensemble dans un fichier nommé d'après l'opération, dans
Commands/ouQueries/de sa tranche. C'est la seule exception à « un fichier par type ». Le message et le validateur viennent avant le handler.
// Core/Catalog/Queries/SearchCatalog.cs
namespace Musiqud.Core.Catalog.Queries;
public sealed record SearchCatalogQuery : IQuery<CatalogPageResult>
{
public required string Text { get; init; }
public int Page { get; init; } = 1;
}
internal sealed class SearchCatalogQueryValidator : AbstractValidator<SearchCatalogQuery>
{
public SearchCatalogQueryValidator()
{
RuleFor(q => q.Text).NotEmpty().MaximumLength(200);
RuleFor(q => q.Page).GreaterThanOrEqualTo(1);
}
}
internal sealed class SearchCatalogQueryHandler(CatalogService catalog) : IQueryHandler<SearchCatalogQuery, CatalogPageResult>
{
private readonly CatalogService _catalog = catalog;
public Task<Result<CatalogPageResult>> HandleAsync(SearchCatalogQuery query, CancellationToken ct)
=> _catalog.SearchAsync(query.Text, query.Page, ct);
}
- Le message est
public, puisque l'Api le construit. Le handler et le validateur sontinternal: seul le médiateur, généré dans Core, les connaît. - Un handler dépend de services de sa tranche, de l'interface de données de sa tranche, de helpers et de states. Jamais d'un adaptateur directement, jamais du médiateur.
- Du code utilisé par un seul handler est une méthode privée de ce handler, sauf s'il touche un adaptateur : il va alors dans un service. Avant d'ajouter une méthode privée, vérifier que la même logique n'existe pas déjà ailleurs; si oui, elle devient une méthode publique d'un service.
Services
- Un service par domaine, nommé d'après ce qu'il possède (
CatalogService,JournalService), jamais d'après une fonctionnalité ou un écran. Un nom de fournisseur seulement quand ce système est le concept lui-même. - Un service garde l'accès aux données et aux adaptateurs, et la logique que deux handlers ou plus partagent. Il n'appelle jamais un autre service ni un handler.
- Au plus 400 lignes et 6 adaptateurs (interfaces de Core autres que l'interface de données, et
TimeProvider). Au-delà, il se divise selon un concept qu'il contient déjà. - La liste des services est dans Services. Un nouveau domaine y ajoute sa ligne dans le même changement. Un service qui n'a plus rien est supprimé.
Médiateur
Le médiateur est le nôtre, généré au build par Musiqud.Generators.
IMediatordansCore/Shared/Interfaces/,MediatordansCore/Shared/Mediators/, une classepartialque le générateur complète. Une méthodeSendAsyncpar marqueur (ICommand<T>,IQuery<T>).- Le code généré construit chaque handler avec
new, ses dépendances tirées du conteneur, à l'intérieur de son pipeline de behaviors. Pas de réflexion à l'exécution. - Le médiateur est scoped : une instance par requête HTTP. Ce qui vit aussi longtemps que l'application (tâches de fond, démarrage) prend
IRootMediator, qui envoie chaque message dans un scope à lui. - Les handlers ne sont jamais enregistrés dans le conteneur. L'état gardé entre deux appels va dans un singleton de
Core/Shared/States/, jamais dans les champs d'un handler. - Le build échoue (diagnostics
MQ0001et suivants) sur :- un handler nommé comme type hors du médiateur;
- un message sans handler, ou avec deux;
- un handler que le médiateur ne sait pas construire;
- un behavior absent du pipeline;
- un validateur en double, avec des dépendances, ou hors du fichier de son opération (voir Validation).
- Les tests de Core construisent le handler eux-mêmes avec ses faux et appellent
HandleAsync: ils voient les typesinternalde Core, et le générateur ne tourne pas sur les projets de tests.
Behaviors
Les besoins transversaux passent par des behaviors du pipeline, IPipelineBehavior<TMessage, TResult> dans Core/Shared/Behaviors/, dans l'ordre que liste l'attribut [Pipeline(...)] du médiateur, le plus extérieur d'abord :
- Journalisation de chaque message et de son issue (voir Erreurs).
- Performance : un avertissement au-delà de 500 ms.
- Validation du message avant le handler.
Un behavior ne touche jamais aux données.
Validation
La validation s'écrit dans le style de FluentValidation, avec notre propre API : pas de paquet.
- Le validateur d'un message vit dans le fichier de son opération, entre le message et le handler (voir Handlers). Il hérite d'
AbstractValidator<TMessage>(Core/Shared/Validators/) et déclare ses règles dans son constructeur :RuleFor(q => q.Text).NotEmpty().MaximumLength(200). - Les règles disponibles sont celles dont on a besoin, ajoutées au fur et à mesure :
NotEmpty,NotNull,MaximumLength,GreaterThanOrEqualTo,InclusiveBetween,Matches, etMustpour une condition à nous.RuleForlit le nom de la propriété avec[CallerArgumentExpression], sans arbre d'expression ni réflexion. Musiqud.Generatorstrouve au build le validateur de chaque message et le passe au behavior de validation de sa route; un message sans validateur passe tout droit. Chaque validateur est construit une seule fois, dans un champ statique du médiateur généré : ses règles ne sont jamais reconstruites à chaque requête.- Un validateur ne vérifie que la forme du message : présence, longueurs, bornes, formats. Il est synchrone et n'a aucune dépendance. Ce qui demande la base ou un service (l'album existe-t-il? la playlist est-elle à cette personne?) se vérifie dans le handler, qui renvoie l'erreur adaptée (voir Erreurs).
- Un échec arrête le pipeline avant le handler : le behavior renvoie un
Resulten échec qui porte toutes les erreurs trouvées, pas seulement la première, chacune de sorteValidation, avec la propriété en cause, un code stable (not_empty,max_length) et un message par défaut en français. L'Api le rend en400au formatValidationProblemDetails. - Le build échoue sur un message qui a deux validateurs, sur un validateur dont le constructeur prend des paramètres, et sur un validateur placé hors du fichier de son opération.
Constantes et helpers
- Une constante partagée par un handler et une autre classe (un endpoint, un test) va dans
Constants/de sa tranche, dans une classe statique par concept (RatingConstants). Jamais sur le handler : le nommer hors du médiateur fait échouer le build. Une constante utilisée par une seule classe reste privée dans celle-ci. - Un helper est statique et sans état, ne prend jamais une interface de données, et a au moins deux appelants. La logique pure partagée (classement, correspondance, calcul de moyenne) est un helper. Un helper qui n'a qu'un appelant est une méthode privée de cet appelant.
Erreurs
Une erreur prévisible fait partie du contrat d'une opération : elle se voit dans la signature, et elle est renvoyée, jamais levée. Les exceptions restent pour l'imprévu (un bug, la base injoignable).
Result
Result<T>etErrorsont les nôtres, dansCore/Shared/Results/, sans paquet. Un handler renvoie toujoursTask<Result<T>>; une commande qui ne renvoie rien renvoieResult<Success>. Les contratsICommandHandleretIQueryHandlerl'imposent : un handler qui ne renvoie pas unResultne compile pas.Errorest un record : une sorte (NotFound,Forbidden,Conflict,Validation,Unavailable), un code stable (catalog.release_not_found), un message par défaut en français, et pour la validation la propriété en cause. UnResulten échec porte une ou plusieurs erreurs.- Des conversions implicites gardent le code court :
return rating;pour un succès,return CatalogErrors.ReleaseNotFound(id);pour un échec. - Pas d'enchaînement
MapouBind: unResultse lit avec une clause de garde, comme le reste du code.
var page = await _catalog.SearchAsync(text, ct);
if (page.IsFailure)
return page.Errors;
- Les erreurs d'une tranche sont des fabriques statiques dans
Errors/de la tranche (CatalogErrors.ReleaseNotFound(id)), pour qu'un code ne s'écrive jamais deux fois. - Les services renvoient aussi un
Resultquand leur échec est prévisible. Les adaptateurs traduisent leurs pannes (réseau, limite de débit de MusicBrainz) enUnavailable. - Un
Resultignoré fait échouer le build : appeler une méthode qui en renvoie un sans le lire, c'est avaler une erreur. Notre analyseur l'interdit.
Règles
- Le privé répond « introuvable ». Demander une écoute, une playlist ou un profil privé de quelqu'un d'autre renvoie
NotFound, exactement comme pour ce qui n'existe pas : unForbiddenrévélerait que la chose existe.Forbiddenne sert que pour ce qu'on voit sans avoir le droit de le modifier. - Échouer avant d'écrire. Un handler renvoie ses échecs avant toute écriture. Dans une transaction explicite, un échec l'annule, jamais il ne la valide.
- Les codes font partie du contrat. Un code publié ne change plus, surtout dans
/v1. L'app mobile gère un code qu'elle ne connaît pas en se rabattant sur la sorte de l'erreur, puisque d'anciennes versions restent installées. La liste des codes est générée dans la doc OpenAPI.
Réponses de l'Api
- Les endpoints n'écrivent jamais de statut eux-mêmes : une seule extension,
ToHttpResult(), traduit unResult. Succès en200(ou201,204selon l'endpoint),NotFounden404,Forbiddenen403,Conflicten409,Validationen400,Unavailableen503avecRetry-Afterquand on le connaît. - Toutes les erreurs sont en Problem Details (
application/problem+json), aveccode,errorspour la validation (ValidationProblemDetails) ettraceIdpour retrouver la requête dans les journaux. 401vient de l'authentification, avant le médiateur.
Exceptions
- Une exception imprévue remonte jusqu'à un middleware de l'Api, qui la journalise en
Erroret renvoie un500sans détails. On n'attrape jamais une exception pour la changer enResult, sauf à la frontière d'un adaptateur ou dansSaveAsync, pour un échec qu'on sait prévisible. throwreste permis pour un argument invalide, qui est un bug de l'appelant.- L'annulation (
OperationCanceledException, le client s'est déconnecté) n'est pas une erreur : elle n'est pas journalisée comme telle, et aucune réponse n'est écrite.
Réessayer sans doublon (US-37)
Décidé avec Samuel le 2 novembre 2026. Une écriture peut arriver au serveur sans que sa réponse revienne. L'app propose alors de réessayer avec la même saisie, et le serveur doit accepter ce second envoi sans rien créer en double.
- Écoute : l'identifiant vient de l'app (voir Journal), et un nouvel essai renvoie l'écoute déjà enregistrée. Si la saisie a changé entre les deux envois, l'app le voit en comparant la réponse à ce qu'elle a envoyé, et envoie la nouvelle saisie par
PATCH. Supprimer une écoute déjà supprimée répond404 journal.listen_not_found, que l'app compte comme une suppression faite. - Modifier une écoute, réglages : l'envoi remplace les valeurs, donc le refaire ne change rien. Deux envois simultanés répondent
409 data.changed_meanwhileau second, comme avant. Relire en silence pourrait écraser un changement fait sur un autre appareil. - Top 5 et rotation : chaque envoi remplace la liste. Deux envois simultanés se heurtent à la clé ou à
xmin, et le second relit et renvoie la liste enregistrée au lieu d'un409. La dernière modification gagne, comme pour deux envois successifs. - Inscription : la refaire avec le même nom renvoie le compte enregistré. Avec un autre nom,
409 users.already_registered. - Première connexion : deux
SignInCommandsimultanés pour le mêmesubse heurtent à l'index unique, et le second relit la personne créée par le premier. - App : une écriture sans réponse (réseau coupé, ou rien après 20 s) devient un
BffErrorde codenetwork(src/app/src/lib/bff.ts). Le composantSaveErroraffiche l'erreur sous le formulaire, et le bouton Réessayer quand réessayer peut suffire (réseau, erreur5xx), pas pour une saisie refusée. La saisie reste dans le formulaire, l'app désactive Enregistrer pendant l'envoi et ne réessaie jamais seule. Garder un brouillon après un rechargement demandera un autre item.
Journalisation
Le behavior de journalisation note l'issue de chaque message : un échec prévisible en Information, avec son code (ce n'est pas une panne, donc pas d'alerte); seules les exceptions imprévues sont en Error.
Données
EF Core avec le fournisseur Npgsql, sur PostgreSQL 19. Pas de repositories : EF en est déjà un. Core travaille avec le DbContext, mais à travers des interfaces qu'il déclare lui-même, une par tranche.
Accès
- Chaque tranche déclare dans
Interfaces/ce qu'elle voit de la base :IJournalData,ICatalogData. L'interface ne liste que lesDbSetde la tranche, plusSaveAsync.MusiqudDbContext, dansInfrastructure/Databases/, les implémente toutes. - Un handler ou un service ne prend que l'interface de sa tranche. Il ne peut donc ni lire ni écrire les tables d'une autre tranche. Ce que deux tranches doivent lire toutes les deux va dans les deux interfaces.
- Core référence
Microsoft.EntityFrameworkCore(pourDbSetet LINQ), jamais le fournisseur Npgsql.Domainne référence pas EF du tout, et ses entités ne portent aucun attribut EF. - Le
DbContextest scoped, un par requête HTTP : c'est lui le unit of work.SaveAsyncest déjà transactionnel; une transaction explicite ne sert que si un handler doit écrire en plusieurs fois, et elle ne reste jamais ouverte pendant un appel réseau (MusicBrainz, Keycloak).
// Core/Journal/Interfaces/IJournalData.cs
public interface IJournalData
{
DbSet<JournalEntry> JournalEntries { get; }
DbSet<Rating> Ratings { get; }
Task<Result<Success>> SaveAsync(CancellationToken ct);
}
SaveAsyncremplaceSaveChangesAsync, que les handlers n'appellent jamais.MusiqudDbContexty attrape les deux échecs prévisibles de la base et les renvoie enConflict: un conflit de version (xmin,DbUpdateConcurrencyException) et un doublon (contrainte unique, code Postgres23505). Core n'a donc jamais besoin de connaître Npgsql pour les reconnaître.
Lire et écrire
- Lire : les requêtes sont sans suivi par défaut (
QueryTrackingBehavior.NoTrackingdans la config du contexte), et une lecture projette directement vers leResultavecSelect. Une seule requête SQL, seulement les colonnes utiles, sans charger d'entité. Une projection utilisée par deux handlers ou plus devient uneExpression<Func<TEntity, TResult>>statique dansProjections/de la tranche. - Écrire : on charge les entités suivies (
AsTracking()), on les modifie, puisSaveAsyncdansHandleAsync. Une opération sur plusieurs lignes sans les charger passe parExecuteUpdateAsyncouExecuteDeleteAsync. - Supprimer, c'est effacer. Pas de marquage
deleted_atni de filtre global : quand une personne supprime une écoute, une playlist ou son compte, les lignes disparaissent. Un marquage ne s'ajoute que pour un besoin précis, décidé et noté ici. - Pas de lazy loading. Les données liées arrivent par projection ou par
Includeexplicite; plusieurs collections incluses demandentAsSplitQuery. - Le SQL brut (
FromSql) sert seulement à ce qu'EF ne sait pas exprimer, comme la recherche plein texte du catalogue, et vit dans un service, jamais dans un handler. - Aucun
IQueryablene sort d'une méthode publique : un service renvoie des résultats matérialisés.
Modèle
- Les entités sont des classes
sealedavec{ get; set; }et sans logique, jamais des records : EF suit chaque objet chargé pour détecter ses modifications, ce qu'un record eninitet son égalité par valeur empêchent. Les DTO, les messages et les modèles de Core restent en records. - Le mapping de chaque entité est une classe
IEntityTypeConfiguration<T>dansInfrastructure/Configurations/, chargée parApplyConfigurationsFromAssembly. Chaque colonne y déclare son type, sa longueur et si elle est obligatoire, et les index y sont déclarés. - Tables et colonnes en snake_case (
journal_entries.release_id), par le paquetEFCore.NamingConventions: le SQL écrit à la main n'a pas besoin de guillemets. - Identifiants internes en
Guidversion 7 (Guid.CreateVersion7()), générés par l'application (ValueGeneratedNever), triables par date de création. Les identifiants du catalogue sont des chaînes opaques préfixées par leur fournisseur (mb:…). - Concurrence optimiste par la colonne système
xminde Postgres, déclarée comme propriété fantôme dans la configuration : rien n'apparaît dansDomain. - Les dates sont en
DateTimeOffsetUTC (timestamptz). created_atetupdated_atappartiennent à la base, jamais au code :created_ata la valeur par défautnow();updated_ataussi, et un triggerBEFORE UPDATEla remet ànow()à chaque modification. Une seule fonction,set_updated_at(), créée par la première migration, sert à toutes les tables;- le trigger couvre aussi
ExecuteUpdateAsyncet le SQL brut, qu'un intercepteur deSaveChangesne verrait pas, et n'ajoute aucun travail côté application; - dans la configuration EF, une extension (
HasTimestamps()) déclare les deux colonnes générées par la base (ValueGeneratedOnAdd,ValueGeneratedOnAddOrUpdate). EF ne les écrit jamais et relit leurs valeurs dans leRETURNINGde sonINSERTou de sonUPDATE; - la migration qui crée une table lui ajoute son trigger avec une extension de
MigrationBuilder(AddUpdatedAtTrigger("journal_entries")), pour ne jamais l'oublier. Un test d'architecture vérifie que chaque table qui aupdated_ata son trigger.
now()donne l'heure du début de la transaction : toutes les lignes écrites par un mêmeSaveAsyncont la même date. Les autres dates métier (la date d'une écoute, par exemple) viennent deTimeProvider, que les tests remplacent parFakeTimeProvider.- Les enums sont stockés en texte (
HasConversion<string>()), lisibles en SQL et faciles à faire évoluer, plutôt qu'en enums natifs de Postgres.
Migrations
- Elles vivent dans
Infrastructure/Migrations/, générées pardotnet ef migrations add. Le SQL produit (dotnet ef migrations script) se relit avant le commit. - Une migration déjà appliquée ne se modifie jamais. Au plus une migration par PR.
- L'image de l'API les applique avec une commande à elle (
Musiqud.Api migrate), lancée par un initContainer avant que le nouveau pod serve. - Une migration reste compatible avec la version précédente de l'API, qui tourne encore pendant le déploiement. Un changement destructif (renommer, retirer une colonne) se fait en deux versions : ajouter et copier, puis retirer.
Journalisation
EnableSensitiveDataLogging en développement seulement. Une requête SQL de plus de 200 ms est journalisée en avertissement.
Fournisseurs interchangeables
Quand plusieurs produits peuvent rendre le même service (le catalogue aujourd'hui avec MusicBrainz, d'autres plus tard), le reste du code n'en nomme aucun. Ajouter un fournisseur, c'est une implémentation et une ligne d'enregistrement, rien d'autre.
- Une interface Core par capacité, nommée d'après le concept (
ICatalog, jamaisIMusicBrainzCatalog). Ses méthodes ne parlent que nos modèles de Core; aucun format de fournisseur ne la traverse. - Le fournisseur se décrit par un record descripteur dans
Models/de la tranche (identifiant stable, nom affiché, rang par défaut, limite de débit ou non), exposé en propriété de l'interface. Rien ne teste quel fournisseur on a. - Les identifiants appartiennent au fournisseur : il les préfixe et dit lesquels sont à lui (
Owns). Le reste du code demande, sans jamais analyser l'identifiant. - La disponibilité est un état, pas une exception : une méthode qui ne lève jamais renvoie un enum partagé. Les lectures, elles, renvoient une erreur
Unavailablequi dit quoi faire : une panne réseau du fournisseur ne remonte jamais en exception. - Les détails restent chez le fournisseur : ses clients, mappers et records portent son nom (
MusicBrainzReleaseGroupMapper) et ne servent qu'à lui. - Un test d'architecture échoue quand du code hors des fichiers du fournisseur et de la racine de composition le nomme.
Catalogue
Décidé avec Samuel le 30 octobre 2026, à partir de DEC-05 et DEC-06. Source : MusicBrainz, derrière ICatalog. Contrainte principale : son API accepte 1 requête par seconde pour toute la plateforme. Tout ici sert à l'appeler le moins possible.
Copie permanente et cache temporaire
- Ce qui est utilisé est copié chez nous, pour de bon. Dès qu'une personne note, journalise ou classe une œuvre, elle entre dans nos tables (
releases,recordings,artists) avec notre identifiant v7. Ce sont nos données : les écoutes et les notes pointent dessus. Les fiches, le journal et les profils ne lisent que ces tables. - Les identifiants du fournisseur vivent dans une table à part,
external_ids(entité, fournisseur, identifiant). Changer de fournisseur ajoute des lignes, sans rien réécrire. - Ce qui n'est pas encore utilisé (résultats de recherche, fiches consultées sans action) passe par
HybridCacheen mémoire, qui fusionne aussi les demandes identiques simultanées : cinq personnes sur la même fiche, un seul appel. 24 h pour une fiche, 1 h pour une recherche. Ce cache se perd à chaque déploiement, sans conséquence; un deuxième niveau partagé ne viendra qu'avec plusieurs instances de l'API.
Appeler MusicBrainz
- Un seul limiteur de débit dans l'adaptateur, à une requête toutes les 1,1 seconde : à une seconde pile, MusicBrainz refuse déjà la deuxième. Une demande attend au plus 5 secondes dans la file, sinon l'adaptateur renvoie
Unavailable. Un refus de l'API publique (503 ou 429) a droit à deux nouvelles tentatives, après 2 puis 4 secondes (ou le délai qu'elle indique), qui repassent par le limiteur. Une panne réseau n'est jamais réessayée. - Une recherche fait au plus deux requêtes : les albums, EP et singles (release groups), puis les chansons (recordings). Si l'une échoue, l'autre s'affiche quand même; la recherche n'est
Unavailableque si les deux échouent. - Pertinence : la phrase exacte pèse le plus, puis les mots, puis l'artiste. MusicBrainz n'a pas de popularité : à score égal, l'œuvre qui a le plus de sorties (ou d'éditions) passe devant. On demande 100 résultats, dans la même requête, pour n'en garder que les 25 meilleurs. Ce tri ne sert plus qu'à l'intérieur de sa réponse : le classement final est le nôtre (voir Popularité). La sortie affichée pour une chanson est son album studio de préférence, avant une compilation ou un live.
- Un
User-Agentqui nous identifie avec une adresse de contact, comme MusicBrainz l'exige. - La recherche commence dans nos tables : les œuvres copiées (plein texte avec
pg_trgm, tolérant aux fautes de frappe) et la table de popularité. Elle ne demande rien à MusicBrainz quand elles donnent déjà 3 titres exacts pour chaque côté cherché (sorties, chansons). Plus Musiqud sert, moins il appelle MusicBrainz. Côté app, la recherche part sur Entrée, sur le bouton, ou pendant la frappe quand le catalogue le permet (Pendant la frappe).
Recherche asynchrone
Une recherche est une tâche : nos résultats arrivent tout de suite, ceux de MusicBrainz quand la file les atteint. Choix de Samuel, préféré à deux requêtes séparées ou à un flux : la file permet aussi d'annuler une recherche devenue inutile avant qu'elle coûte une requête à MusicBrainz.
POST /bff/catalog/searches(StartCatalogSearchCommand) répond202avec l'identifiant de la recherche, son adresse dansLocation, et déjà nos résultats locaux, classés. Quand ils suffisent (3 titres exacts par côté), la recherche est terminée : identifiant vide, étatcompleted, rien dans la file.GET /bff/catalog/searches/{id}?wait=5(GetCatalogSearchQuery) renvoie l'état (pending,completed,unavailable) et toute la liste, classée : nos résultats et, dès qu'il a répondu, ceux de MusicBrainz, chaque œuvre une fois (comparées parexternal_ids). L'app remplace sa liste par celle-ci. Avant TECH-14, le suivi ne renvoyait que les résultats de MusicBrainz et l'app les ajoutait à la fin : une app mobile de cette époque afficherait des doublons, aucune n'est publiée. Avecwait, le serveur garde la requête ouverte jusqu'à 5 secondes en attendant le résultat (long polling) : une recherche coûte d'habitude deux requêtes, pas un appel toutes les 500 ms.DELETE /bff/catalog/searches/{id}: l'app l'envoie quand on relance une recherche avant la fin de la précédente. Une recherche pas encore partie sort de la file.- La file est un
Channelen mémoire, vidée par une tâche de fond (HostedServices) qui passe parIRootMediatoret le limiteur de débit. Les recherches passent avant le rafraîchissement du catalogue. - Deux recherches du même texte normalisé partagent la même tâche. La réponse de MusicBrainz reste en cache 1 h (
HybridCache) : la même recherche, relancée, se termine sans nouvelle requête. - L'état d'une recherche vit en mémoire 5 minutes. Après un redémarrage, le
GETrépond404et l'app relance la recherche. Un stockage partagé ne deviendra nécessaire qu'avec plusieurs instances de l'API. - Au-delà de 20 recherches en attente, le
POSTrenvoie quand même nos résultats locaux, avec l'étatunavailablepour MusicBrainz. - Ouverte sans compte (US-02), la recherche est limitée à 60 lancements par minute et par adresse IP (
429au-delà), assez pour chercher pendant la frappe. L'adresse vient deX-Forwarded-Forderrière Traefik. - Le suivi attend volontairement : son message porte le marqueur
IWaits, et le behavior de performance ne compte pas cette attente comme de la lenteur. - Les résultats de MusicBrainz ne sont pas copiés chez nous : seule une vraie utilisation copie une œuvre. La recherche en mémoire garde sa réponse telle quelle; chaque suivi refait la recherche locale (quelques millisecondes) et classe le tout.
Popularité (TECH-14)
Décidé avec Samuel le 2 novembre 2026. MusicBrainz ne connaît pas la popularité, et son tri ne suffit pas pour un titre que beaucoup ont repris : pour « wish you were here », les 100 enregistrements qu'il renvoie sont tous à égalité et la version de Pink Floyd n'en fait pas partie.
- Source : les écoutes de ListenBrainz (MetaBrainz, CC0), environ 3 milliards, publiées le 1er et le 15 de chaque mois, avec les mêmes MBID que notre miroir. Le dump canonique de MetaBrainz ramène chaque enregistrement à sa version de référence et chaque sortie à son groupe. Les titres, les types et les années viennent de la base du miroir, en lecture seule. Le nombre de sorties dans MusicBrainz reste le repli pour une œuvre sans écoutes.
- Mesure : le nombre de personnes distinctes qui ont écouté l'œuvre au moins une fois (environ 98 000 personnes dans le dump d'octobre 2026). Pas les écoutes : une seule personne qui écoute en boucle ne fait pas une œuvre connue.
- Table
popular_works: les œuvres d'au moins 10 auditeurs (environ 5 millions de chansons et 1,35 million d'albums, EP et singles en novembre 2026), avec l'identifiant du fournisseur (mb:recording:…), le titre, l'artiste, le type, l'année et, pour une chanson, la sortie qui donne son titre et sa pochette. Deux colonnes calculées par la base servent la recherche :search_title(fonctionmusiqud_normalize: minuscules, sans accents ni ponctuation) etsearch_vector(les mots du titre et de l'artiste). Pas decreated_at,updated_atnixmin: rien n'y est jamais modifié. - Écriture : seul le job mensuel écrit cette table, une exception à la règle qui veut que tout passe par l'API. Il en est propriétaire, l'API ne fait que la lire. Il la remplace en une transaction (
DELETEpuisCOPY) : les lectures voient l'ancienne table jusqu'au commit. Il refuse de la remplacer par moins de la moitié des lignes, signe d'un dump ou d'un miroir abîmé. Le code vit danssrc/popularity/(Python, Polars). La CronJobpopularity(deploy/k8s/dev/popularity.yaml) tourne le 8 du mois à 4 h, heure de Toronto, et dure environ 2 h 30, presque tout pour télécharger 250 Go en 4 connexions. - Classement : une seule liste, tous types mélangés. D'abord la correspondance du titre : exact (seul, ou avec l'artiste avant ou après), puis tous les mots dans le titre ou l'artiste, puis le reste. À égalité, le plus d'auditeurs passe devant; une œuvre sans popularité vient après celles qui en ont, dans l'ordre que sa source lui donnait. Un titre approximatif ne passe donc jamais devant un titre exact, et aucune œuvre n'est retirée (DEC-05). Chaque type garde au plus 25 résultats, puisque l'app filtre par type sans relancer la recherche. Les trois premiers titres exacts du classement, tous types confondus, forment les « Meilleurs résultats » de l'écran (US-56), dans le champ
bestde chaque réponse; une recherche approximative n'en a pas. Code :SearchMatchesetSearchRanking(Core/Catalog/Helpers/). - Recherche dans la table : les titres exacts par leur index (
search_title,listeners), puis tous les mots par l'index GIN desearch_vector, les plus écoutés d'abord, 25 au plus chacun. Pas de trigrammes sur cette table : les fautes de frappe restent l'affaire de nos œuvres copiées et de MusicBrainz. - Test d'acceptation :
tests/e2e/search-ranking.spec.ts, des recherches connues dont l'œuvre attendue doit sortir dans les 3 premiers. Il ne tourne qu'avectests/run.sh --deploye, contre les vraies données.
Pendant la frappe (US-47)
Décidé avec Samuel le 2 novembre 2026.
- L'écran Recherche cherche après 250 ms sans frappe, à partir de deux caractères, sous Musique comme sous Personnes. Entrée cherche tout de suite, dès un caractère, et ne relance un texte déjà cherché qu'après un échec ou une réponse incomplète. Le champ vidé ramène à l'accueil de l'onglet.
- Chaque pause remplace le texte dans l'adresse, sans entrée d'historique : le retour d'une fiche retrouve la recherche (US-04). Une nouvelle pause abandonne la recherche précédente, par le même
DELETEqu'une relance. - Le miroir n'a pas de limite de débit, l'API publique si. Le descripteur du fournisseur le dit (
RateLimited). Chaque réponse de recherche porterateLimited, vrai quand le dernier fournisseur à répondre limite son débit ou quand aucun n'a répondu. Avant la première réponse, l'API prend la valeur du premier fournisseur interrogé; une réponse venue du cache ne la change pas. - Quand
rateLimitedest vrai, l'app ne cherche plus de musique pendant la frappe, seulement sur Entrée ou sur le bouton. Chaque recherche essaie d'abord le miroir : dès qu'il répond de nouveau,rateLimitedrepasse à faux et les suggestions reprennent. - La recherche de personnes ne passe pas par MusicBrainz et suit la frappe dans tous les cas.
Garder la copie à jour
Mis en place le 1er novembre 2026 (TECH-02).
- Une tâche de fond (
CatalogRefreshWorker) envoieRefreshWorksCommandtoutes les 10 minutes. Chaque passage relit chez le fournisseur au plus 30 œuvres copiées (sorties et chansons) qui n'ont pas été relues depuis 30 jours, les plus anciennes d'abord. La colonnerefreshed_atle date; nulle, c'est la date de copie qui compte. Des passages courts laissent le limiteur aux recherches, qui comptent plus. - Le titre, l'artiste, l'année, le type et les types secondaires suivent le fournisseur. Pour une chanson, la sortie associée ne change pas : les écoutes y pointent.
- Quand MusicBrainz fusionne deux fiches, l'ancien identifiant redirige vers le nouveau : la réponse porte le nouveau, qui remplace l'ancien dans
external_ids. Notre identifiant ne change pas. Si le nouveau appartient déjà à une autre de nos œuvres, on garde l'ancien, qui redirige toujours. - Une œuvre que tous les fournisseurs disent introuvable garde sa ligne chez nous, puisque des écoutes y pointent, marquée
unavailable; l'app affiche « Indisponible ». Ça répond à la question laissée ouverte par DEC-06. Un miroir qui ne la trouve pas laisse l'API publique répondre. - Un fournisseur qui ne répond pas arrête le passage sans rien changer : le reste attend le suivant.
Fiches
Décidé avec Samuel le 31 octobre 2026, pour US-06.
GET /bff/catalog/releases/{id}(GetReleaseQuery), ouverte sans compte, accepte l'identifiant que donne la recherche : le nôtre pour une œuvre copiée chez nous, celui du fournisseur (mb:release-group:…) sinon. La réponse porte le nôtre dès qu'il existe. Une œuvre aura donc deux adresses après sa copie; une redirection viendra quand le partage comptera (US-25).- Consulter une fiche ne copie pas l'œuvre : la liste des chansons vient du fournisseur, en cache 24 h (
HybridCache), comme le reste de ce qui n'est pas encore utilisé. Un échec n'est jamais gardé en cache. - MusicBrainz : un seul appel,
GET /ws/2/release?release-group=<id>&inc=recordings+artist-credits+release-groups+media, qui donne les éditions avec leurs pistes. La liste des chansons est celle de l'édition officielle la plus ancienne; à date égale, celle qui a le moins de pistes (l'édition standard plutôt que la deluxe). Les pistes sont numérotées à la suite d'un disque à l'autre. - Un identifiant qui n'est pas un groupe de MusicBrainz valide répond
404 catalog.release_not_foundsans appel; MusicBrainz qui répond404ou400aussi. MusicBrainz injoignable :503 catalog.unavailable, et l'app propose « Réessayer ». - Une œuvre copiée puis marquée
unavailablemontre ce qu'on a gardé (titre, artiste, type, année), sans appeler le fournisseur et sans liste de chansons. - Un single a la même fiche, avec son type écrit en clair; chaque chanson de la liste ouvre sa propre fiche, ce qui distingue le single de sa chanson (US-07).
- Fiche chanson (US-08) :
GET /bff/catalog/recordings/{id}, mêmes règles d'identifiant, de cache et d'erreurs (catalog.recording_not_found). MusicBrainz : un appel,/ws/2/recording/<id>?inc=artist-credits+releases+release-groups. La sortie associée, qui remplace la liste des pistes, est la sortie officielle d'abord, puis l'album studio avant l'EP, le single, la compilation ou le live, et la plus ancienne à égalité. L'app la montre en carte qui mène à sa fiche. Ajouter à une playlist viendra avec les playlists, repoussées après le MVP (US-30).
Page artiste (US-09)
Décidé avec Samuel le 1er novembre 2026.
GET /bff/catalog/artists/{id}(GetArtistQuery), ouverte sans compte, avec l'identifiant du fournisseur (mb:artist:…), que les fiches donnent dansartistId: le premier artiste crédité.- MusicBrainz :
GET /ws/2/release-group?artist=<id>&type=album|ep|single&inc=artist-credits, 100 groupes au plus, et seulement quand cette liste est vide,GET /ws/2/artist/<id>pour distinguer un artiste sans sortie d'un identifiant inconnu (404 catalog.artist_not_found). - La page montre les albums, puis les EP, puis les singles, chacun du plus récent au plus ancien, sans compilations, live ni autres types secondaires. Rien n'est copié chez nous : un artiste n'est pas une œuvre utilisée. Réponse en cache 24 h, comme une fiche.
- Les résultats de recherche ne mènent pas encore à l'artiste : nos œuvres copiées ne gardent pas son identifiant. Seules les fiches font le lien.
- Une sortie déjà copiée chez nous porte notre identifiant, comme dans la recherche. Le service le retrouve dans
external_idsà chaque lecture, après le cache. Depuis la page artiste, on arrive donc sur la même fiche que depuis le journal.
Plus de l'artiste (US-46)
Décidé avec Samuel le 2 novembre 2026.
- La fiche d'un album, d'un EP ou d'un single montre les trois premières sorties de la page artiste, sans la sienne. Ce sont les albums les plus récents, puis les EP et les singles. Quand TECH-14 donnera une popularité, l'API changera ce tri et l'app n'aura rien à faire.
- L'app réutilise
GET /bff/catalog/artists/{id}dans une requête à part, que la fiche n'attend pas. La section reste cachée pendant le chargement, en cas d'erreur et quand l'artiste n'a pas d'autre sortie.
Pochettes
- Au MVP, l'app les affiche directement depuis Cover Art Archive (Internet Archive), en demandant la vignette à la taille voulue (250 ou 500 px), jamais l'original.
- Si on doit un jour les stocker (lenteur, adresse IP des personnes envoyée à archive.org), elles passeront par un proxy à nous qui les convertit en WebP ou AVIF, en quelques tailles fixes seulement, avec un cache à durée limitée plutôt qu'une copie de tout.
Miroir (TECH-09)
- Avec
MusicBrainz:MirrorUrldans la configuration, un miroir de MusicBrainz (la pilemusicbrainz-docker, Solr compris) est interrogé en premier : sans limiteur, avec un délai de 5 secondes, et sans nouvelle tentative. L'API publique ne prend le relais que s'il échoue ou ne répond qu'en partie. Sans cette clé, l'API publique seule. - Les deux sont le même adaptateur, avec le même identifiant de fournisseur (
mb) : leurs identifiants d'œuvres sont les mêmes. - On n'active le miroir qu'une fois une recherche connue vérifiée (« OK Computer » renvoie Radiohead) : un miroir dont Solr n'a pas fini de charger répond des listes vides, que rien ne distingue d'une recherche sans résultat.
Avant le miroir
Un miroir local supprimerait la limite de débit, mais c'est un service lourd : environ 100 Go de disque pour la base seule, et environ 350 Go, 16 Go de RAM et 16 threads avec la recherche indexée (README de metabrainz/musicbrainz-docker, octobre 2026). Une étape intermédiaire serait la base seule, sans sa recherche, en gardant la nôtre. Le signal pour s'y mettre : le limiteur renvoie Unavailable plus de quelques fois par jour.
Journal
Décidé avec Samuel le 31 octobre 2026, pour US-11.
- Une écoute (
listens) : la personne, la sortie ou la chanson, le jour d'écoute, la visibilité, la note et la critique. L'écoute d'une chanson (US-08) garde la chanson (recording_id) et sa sortie associée (release_id) : le journal les regroupe, et affiche « Paranoid Android, sur OK Computer ». Écouter une chanson n'ajoute pas l'album, ni l'inverse.PUTreçoitreleaseIdourecordingId, jamais les deux (400 journal.work_required). - Copier une chanson : sa première écoute la copie dans
recordings, avec sa sortie associée dansreleases. Une chanson sur aucune sortie ne peut pas encore être écoutée (409 catalog.recording_without_release). - La note (US-12, DEC-02) : facultative, de 0,5 à 5 par demi-étoile. L'API la reçoit et la renvoie en étoiles (
3.5), la base la garde en demi-étoiles (smallintde 1 à 10, contrainteck_listens_rating). Hors de l'échelle :400 journal.rating_invalid. Aucune note (null) n'est pas zéro. L'app affiche « 3,5 sur 5 »; la rangée de valeurs est un groupe de boutons radio, que le clavier parcourt avec les flèches. - La critique (US-13) : facultative, 10 000 caractères au plus, sans espaces autour; vide, elle vaut
null. Elle suit la visibilité de l'écoute, et retirer la note ne la retire pas. - La date est facultative (décision de Samuel, qui s'écarte du dossier vision v0.2, où elle était obligatoire et « aujourd'hui » par défaut). Le formulaire part sur « Il y a un moment », sans date, et propose « Aujourd'hui » et « Choisir une date ». Le jour vient du téléphone : « Aujourd'hui » n'est qu'un raccourci de l'app. Le serveur refuse seulement ce qui ne peut pas être une date de téléphone : avant 1900 ou après demain en temps universel (
400 journal.date_out_of_range). - Ordre du journal (US-54, revue du design du 2 novembre 2026) : par jour, du plus récent au plus ancien. Une écoute sans date se range au jour de son ajout, pris en temps universel (
created_atàUTC), et l'app l'affiche avec « date non précisée ». À égalité, la date d'ajout (created_at) puis l'identifiant départagent. Chaque écoute porteaddedOn, ce jour d'ajout, pour que l'app range et affiche les écoutes dans l'ordre où le serveur les trie. La moyenne et « ma note » gardent leur propre ordre (voir plus bas), où une écoute sans date compte comme la plus ancienne. - Lecture par tranches (US-15, décision de Samuel : pas de pagination visible) :
GET /bff/journal/listens?kind=&after=renvoie 50 écoutes et un curseur (next), opaque pour l'app, qui encode, pour la dernière écoute, son jour dans le journal, sa date d'ajout et son identifiant. Un curseur d'avant US-54 (« - » pour une écoute sans date) répond400. La requête reprend juste après, quelle que soit la longueur du journal. L'app charge la tranche suivante à l'approche du bas, et ne garde dans la page que les lignes visibles (@tanstack/react-virtual). Un curseur trafiqué répond400 journal.cursor_invalid. Une timeline façon profil viendra plus tard sur la même API. - Filtre par type (
kind=album|ep|single|song), côté serveur, gardé dans l'adresse (/journal?kind=album). Le journal se regroupe par mois, et chaque écoute montre son jour sous son titre. - Modifier, supprimer (US-16) : toucher une entrée la rouvre préremplie.
PATCH /bff/journal/listens/{id}remplace le jour, la visibilité, la note et la critique; l'œuvre ne change pas.DELETEl'efface pour de bon, après une confirmation qui dit ce qui disparaîtra; les autres écoutes de la même œuvre restent. L'écoute d'une autre personne répond404 journal.listen_not_found, jamais403. - Moyenne et ma note (US-19, DEC-02; décisions de Samuel du 1er novembre 2026) :
GET /bff/works/ratings?releaseId=|recordingId=, ouverte sans compte. Une note par personne : sa dernière note publique, dans l'ordre du journal (la plus récente date d'écoute; « Il y a un moment » passe pour la plus ancienne; à égalité, la plus récemment ajoutée). Les notes privées n'y entrent pas, et le nombre d'écoutes ne pèse pas. La moyenne s'affiche dès une note publique, toujours avec leur nombre (« 4,0 sur 5 · 1 note publique »); sans note publique, aucun score. « Ma note » est celle de ma dernière écoute notée, publique ou privée, seulement si je suis connecté. Album et chansons ont chacun la leur. Calculée en SQL à chaque lecture. - Critiques (US-20) :
GET /bff/works/reviews?releaseId=|recordingId=&after=, ouverte sans compte, par tranches de 20, de la plus récemment écrite à la plus ancienne. Seules les critiques d'écoutes publiques; toutes celles d'une même personne, chacune avec sa note et sa date. Le nom de l'auteur mène à son profil (US-25); chaque critique d'une autre personne a un menu Signaler (US-35, voir Modération). Les critiques d'un profil privé ne paraissent que pour ses abonnés acceptés (DEC-10). Sans critique : « Sois la première personne à donner ton avis. », avec Ajouter au journal, qui passe par?add=listencomme le bouton du haut. - Visibilité « Amis » : proposée par Samuel, pas dans le dossier (DEC-09), mise de côté le 1er novembre 2026. Une écoute est publique ou privée; un profil privé (DEC-10) restreint ses écoutes publiques à ses abonnés acceptés (voir Social).
- Regrouper un album et ses chansons (décision de Samuel, appliquée avec US-08) : dans le journal, les écoutes d'une même sortie et de ses chansons se regroupent en une entrée quand elles sont du même jour (ou toutes sans date et ajoutées le même jour; jamais une datée avec une sans date) : l'album en titre, puis chaque écoute, chacune modifiable. Sans écoute de l'album entier, le titre dit « Kid A · 2 chansons ». Jamais d'un jour à l'autre, pour garder la chronologie. Le regroupement se fait dans l'app, sur les tranches déjà chargées.
- Filtre « Chansons » : les écoutes de chansons. Les autres filtres ne gardent que les écoutes de sorties entières de ce type.
- Réécouter le même jour (US-14) : avant d'ouvrir la fenêtre d'ajout, l'app demande
GET /bff/journal/listens/on/{jour}?releaseId=|recordingId=, avec le jour du téléphone. S'il existe déjà une écoute de cette œuvre ce jour-là, elle propose « Modifier cette écoute » (la plus récente, préremplie) ou « Ajouter une autre écoute ». Une écoute d'album et une écoute d'une de ses chansons sont des œuvres différentes. Sans réponse du BFF, la fenêtre s'ouvre quand même : la vérification n'est qu'une aide. - Pas de doublon : l'app tire l'identifiant de l'écoute à l'ouverture du formulaire et l'envoie avec
PUT /bff/journal/listens/{id}. Un double appui ou un nouvel essai renvoie la même écoute sans en ajouter. L'identifiant d'une autre personne répond409 journal.listen_id_taken. - Compte inscrit exigé : sans inscription finie,
409 users.registration_required. Sans connexion, la fiche envoie à la connexion; la reprise du formulaire viendra avec US-02. - Copie à la première utilisation : enregistrer une écoute copie la sortie dans
releasesetexternal_idsparWorkService(Core/Shared/Services/), qui sert aussi la fiche. Deux copies simultanées de la même œuvre : la clé d'external_idsrefuse la seconde, qui reprend l'identifiant de la première.
Collections
Décidé avec Samuel le 31 octobre 2026, pour US-17 et US-18.
- Deux collections, indépendantes de la note, du journal et du Top 5 : Favori (US-17), un marqueur personnel sans limite, et À écouter (US-18), toujours privé (DEC-03). Le Top 5, limité à 5 albums et 5 chansons, ordonné et manuel, reste une fonction à part (US-27, US-28) : Samuel a choisi de garder les deux.
- Une table
saved_works: la personne, la collection (FavoriteouListenLater), la sortie ou la chanson (contrainteck_saved_works_one_work), la date d'ajout. Une œuvre au plus une fois par personne et par collection (index uniqueNULLS NOT DISTINCT). Une chanson gardée est la chanson seule, pas sa sortie. - API :
PUT /bff/collections/{favorites|listen-later}/items(corpsreleaseIdourecordingId, notre identifiant ou celui du fournisseur), idempotent;DELETE …/items?releaseId=|recordingId=, sans erreur si l'œuvre n'y est pas;GET /bff/collections/{list}?kind=&after=, par tranches de 50 et curseur, de la plus récente à la plus ancienne, comme le journal;GET /bff/collections/state?releaseId=|recordingId=pour les bascules d'une fiche. Compte inscrit exigé pour ajouter. - Garder copie l'œuvre chez nous (DEC-05 : garder, c'est utiliser). Retirer ou lire l'état ne copie jamais : une œuvre que personne n'a gardée n'est dans aucune collection.
- Écouter retire de À écouter (décision de Samuel, qui s'écarte du dossier v0.2, où l'app devait seulement proposer de le retirer). La réponse de l'écoute le dit (
removedFromListenLater), et l'app affiche « Écoute ajoutée. Retirée de À écouter. ». Une écoute de chanson retire la chanson, pas l'album. Les favoris ne bougent pas. - Visibilité des favoris (décision de Samuel, 1er novembre 2026, avec US-25) : rien par favori. Ils sont tous publics sur le profil, dans les limites du profil privé (DEC-10) :
GET /bff/people/{nom}/favorites, même tranche que/bff/collections/favorites,404 collections.person_not_foundpour qui ne peut pas voir le profil. À écouter ne paraît jamais sur un profil. - App : À écouter et Favori sont des bascules sur les fiches (
aria-pressed). Sans compte, l'écran de connexion, puis la fiche reprend l'action une fois (?add=favoriteou?add=listen-later, comme?add=listen). En attendant le profil, les pages/favoriteset/listen-later, liées depuis la carte « Compte ».
Top 5
Décidé avec Samuel le 1er novembre 2026, pour US-27 et US-28, à partir de DEC-04.
- Deux listes indépendantes dans une table
top_entries: la personne, la liste (AlbumsouSongs), la place (1 à 5, contrainteck_top_entries_position), la sortie, et pour une chanson la chanson avec sa sortie associée, comme une écoute. Clé : personne, liste, place. Une place vide n'a pas de ligne; aucune place ne se remplit à partir des notes. - Enregistrer remplace la liste entière :
PUT /bff/me/top5/{albums|songs}avecitems, 0 à 5 identifiants (les nôtres ou ceux du fournisseur), dans l'ordre. C'est ce qui donne « Enregistrer confirme l'ordre, Annuler garde le précédent » : l'app édite une copie et n'envoie rien avant Enregistrer. L'autre liste ne change jamais. La dernière modification enregistrée gagne, sans avertissement : c'est une liste de cinq. - Ce qui entre (DEC-04) : les albums et les EP dans la liste des albums, ni single ni compilation (
400 tops.album_required); des chansons dans la liste des chansons, un single y entre par sa chanson. Une œuvre au plus une fois (400 tops.duplicate). Ajouter copie l'œuvre chez nous, comme une écoute (DEC-05). - Lecture :
GET /bff/me/top5, etGET /bff/people/{nom}/top5, ouvert sans compte, avec la règle du profil privé (Audiences.SeenBy) :404 tops.person_not_foundpour qui ne peut pas le voir. - App : sur le profil, deux onglets Albums et Chansons. Sur mon profil, Modifier ouvre
/top5/albums(ou/songs) : Monter, Descendre et Retirer sur chaque ligne, utilisables au clavier, et Ajouter, qui ouvre une recherche limitée au bon type. Au-delà de cinq, il faut choisir quelle place remplacer. Quitter avec des changements demande de les garder ou de les abandonner. Le glisser-déposer viendra avec la passe de style.
Rotation
Décidé avec Samuel le 2 novembre 2026, pour US-49, à la demande de Tristan après ses tests du 1er novembre. Pas dans le dossier vision.
- Ce que j'écoute en ce moment : jusqu'à 5 œuvres, choisies et ordonnées à la main, dans une table
rotation_entries: la personne, la place (1 à 5, contrainteck_rotation_entries_position), la sortie, et pour une chanson la chanson avec sa sortie associée, comme une écoute. Clé : personne, place. Rien ne vient du journal. - Tout entre : albums, EP, singles et chansons, compilations et lives compris, dans une seule liste. Une œuvre au plus une fois (
400 rotations.duplicate). Ajouter copie l'œuvre chez nous (DEC-05). - Enregistrer remplace la liste entière :
PUT /bff/me/rotationavecitems, 0 à 5 places, chacunereleaseIdourecordingId(le nôtre ou celui du fournisseur), jamais les deux. Comme le Top 5, l'app édite une copie et n'envoie rien avant Enregistrer; la dernière modification gagne. - Date : chaque enregistrement écrit
saved_at(deTimeProvider) sur toutes les places.updated_atne suffit pas : retirer une œuvre supprime une ligne sans toucher les autres. La réponse donnesavedAt, nul pour une rotation vide. - Lecture :
GET /bff/me/rotation, etGET /bff/people/{nom}/rotation, ouvert sans compte, avec la règle du profil privé (Audiences.SeenBy) :404 rotations.person_not_foundpour qui ne peut pas la voir. - App : la section En rotation du profil, au-dessus du Top 5, avec « mise à jour il y a 3 jours ». Vide, elle n'apparaît pas aux autres. Sur mon profil, Modifier ouvre
/rotation, le même éditeur que le Top 5 (glisser, Monter, Descendre, Retirer, Tout retirer, Ajouter par la recherche sans filtre de type). Le fil Abonnements ne montre pas les changements de rotation.
Social
Décidé avec Samuel le 1er novembre 2026, pour US-05, US-21, US-22 et US-25. Le profil privé et les demandes d'abonnement (DEC-10) et le compte des vues (TECH-11) s'écartent du dossier vision v0.2, qui prévoit un abonnement sans acceptation.
- Profil privé (
users.profile_private, faux par défaut) : un réglage à part de la visibilité par défaut des écoutes, changé parPATCH /bff/me/settings(profilePrivate). Le repasser en public accepte les demandes en attente; le passer en privé garde les abonnés. - Qui voit quoi : une écoute privée ne se montre qu'à son propriétaire, toujours. Personne ne voit rien de l'autre de part et d'autre d'un blocage (US-36). Sinon, les écoutes publiques d'un profil public se montrent à tous, même sans compte; celles d'un profil privé, à ses abonnés acceptés seulement. La règle vit dans un seul helper,
Audiences.SeenBy(Core/Shared/Helpers/), que le profil, le journal d'une personne et les critiques des fiches utilisent. Les notes publiques d'un profil privé comptent dans la moyenne (US-19); ses critiques ne paraissent que pour ses abonnés acceptés (US-20). Les favoris (US-17) suivront la même règle quand le profil les montrera. - Abonnements (
follows) : qui suit, qui est suivi, un état (pendingouaccepted) et la date d'acceptation; clé composée, sans identifiant, et une contrainte qui refuse de se suivre soi-même.PUT /bff/people/{nom}/followrépondacceptedsur un profil public,pendingsur un profil privé; le renvoyer ne change rien.DELETEarrête, ou retire la demande. Supprimer une personne supprime ses abonnements dans les deux sens. - Demandes :
GET /bff/me/follow-requests, de la plus ancienne à la plus récente;POST …/{nom}/acceptaccepte;DELETE …/{nom}refuse, sans prévenir la personne, qui peut redemander. - Recherche de personnes (US-05) :
GET /bff/people?q=, ouverte sans compte, synchrone (nos tables seulement, pas de file comme pour le catalogue). Un profil public se trouve par une partie de son nom d'utilisateur ou de son nom affiché (LIKE, sans index pour l'instant : la table est petite); un profil privé seulement par son nom d'utilisateur exact, et jamais sans compte. Le nom exact d'abord, puis ceux qui commencent par le texte, au plus 20. Dans l'app, les personnes ne paraissent qu'avec le filtre Personnes. - Profil (US-25) :
GET /bff/people/{nom}, ouvert sans compte, renvoie le nom, les nombres d'abonnés et d'abonnements, l'état de mon abonnement etcontentVisible. Un profil privé répond404 users.profile_not_foundsans compte; connecté sans être abonné, il montre son nom et permet de demander à suivre. Son journal :GET /bff/people/{nom}/listens, la même tranche que/bff/journal/listens(ListListensQueryavecUsername), écoutes publiques seulement,404 journal.person_not_foundpour qui ne peut pas le voir. L'app le sert à/@nom. - Abonnés et abonnements (US-48, décisions de Samuel du 2 novembre 2026, à la demande de Tristan après ses tests du 1er novembre).
GET /bff/people/{nom}/followerset/following, ouverts sans compte, envoient tous deuxListFollowsQuery, qui reçoit la liste dans un champ.- Un profil privé ne montre ses listes qu'à lui-même et à ses abonnés acceptés, par
Audiences.SeenBy. Pour les autres,404 social.person_not_found. - Seuls les abonnements acceptés comptent. Le lecteur ne voit personne de part et d'autre d'un blocage avec lui (
Audiences.Unblocked). Un visiteur sans compte ne voit aucun profil privé, puisqu'un profil privé n'existe pas pour lui. - Tranches de 50, la plus récente acceptation d'abord (
accepted_at, puis l'identifiant), avec un curseur opaque comme le journal. Un curseur trafiqué répond400 social.cursor_invalid. - Chaque ligne donne l'état de mon abonnement envers la personne (
follow) et dit si c'est moi (self). - Les nombres du profil comptent tous les abonnements acceptés, blocages compris. Ils peuvent donc dépasser la liste d'une ou deux personnes, sans dire lesquelles.
- Dans l'app, les deux nombres du profil mènent à
/@nom/followerset/@nom/following, deux onglets d'une même page. Chaque ligne propose Suivre (Demander sur un profil privé), Abonné ou Demande envoyée, et un appui inverse l'état. Ma propre ligne n'a pas de bouton. Sans compte, Suivre envoie à la connexion, puis le profil de la personne suit une fois (?follow=1). - On ne peut pas encore retirer un abonné : ce n'est ni dans l'item ni dans le dossier vision.
- Un profil privé ne montre ses listes qu'à lui-même et à ses abonnés acceptés, par
- Vues de profil (TECH-11,
profile_views) : consulter le profil d'une autre personne ajoute une vue, une ligne par profil, visiteur et jour avec un compteur; les visiteurs sans compte partagent la leur (index uniqueNULLS NOT DISTINCT). Pour nos statistiques seulement : la personne consultée ne voit rien. C'est pourquoi l'endpoint envoie une commande (ViewProfileCommand) et non une requête. Les vues de plus de 13 mois sont effacées par la tâche qui nettoie les sessions, et celles d'un compte supprimé partent avec lui. - Fil Abonnements (US-22) :
GET /bff/feed?after=, les écoutes publiques des personnes que je suis avec un abonnement accepté, 30 par tranche, de la plus récente à la plus ancienne selon leur ajout (created_at, puis l'identifiant), avec un curseur opaque comme le journal (400 social.cursor_invalids'il est trafiqué). Le fil se calcule à la lecture, sans table à lui : un indexlistens (user_id, created_at DESC)le sert. Changer la note ou la critique d'une écoute ne la fait pas remonter, ce qui règle la règle « pas de rafale ». Une écoute privée n'y paraît jamais, et une demande en attente n'y donne rien. Seules les écoutes y entrent pour l'instant; les Top 5 et les playlists s'y ajouteront avec leurs items. L'app le montre sur l'accueil, avec l'état vide du dossier (« Tes amis ne sont pas encore ici? »). - Page de profil : l'en-tête (nom, compteurs, Suivre, Partager), le Top 5, puis deux onglets, Journal et Favoris (
?tab=favoris). Le menu d'un autre profil propose Signaler (US-35) et Bloquer (US-36); les playlists publiques viendront après le MVP (US-33). - Partager : l'API Web Share du navigateur (la feuille de partage du système sur mobile, dans la WebView de Capacitor aussi), sinon la copie du lien. L'adresse partagée est toujours publique,
https://app.<domaine>/@nom: sur mobile, l'origine de l'app estcapacitor://, d'oùVITE_PUBLIC_URLdans.env.mobile. L'aperçu du lien (Open Graph) viendra après le MVP (TECH-13) : l'app est un site statique, il faudra un rendu côté serveur pour les robots d'aperçu. - Bloquer (US-36, décisions de Samuel du 1er novembre 2026) : une table
blocks(qui bloque, qui est bloqué; clé composée, contrainteck_blocks_not_self).PUT /bff/people/{nom}/blocksupprime les abonnements et les demandes dans les deux sens, puis bloque; le renvoyer ne change rien.DELETEdébloque, sans rendre les abonnements.GET /bff/me/blocksliste les personnes bloquées, pour la section Blocages des réglages.- Le blocage passe par la même règle,
Audiences.SeenBy, qui exige aucun blocage dans un sens ni dans l'autre : profil, journal, favoris, Top 5, rotation, listes d'abonnés, critiques des fiches et fil s'en trouvent masqués d'un coup. La recherche de personnes exclut les deux côtés (Audiences.Unblocked). La moyenne des notes ne change pas : elle est anonyme. - La personne bloquée ne reçoit rien et ne voit aucun message : le profil de qui l'a bloquée répond
404 users.profile_not_foundpartout, comme un compte qui n'existe pas. Elle ne peut pas le suivre (404 social.person_not_found). - Qui a bloqué voit seulement le nom (
blocked: true) et Débloquer, et ne peut pas suivre avant d'avoir débloqué (409 social.blocked). - On bloque seulement depuis le menu d'un profil, avec une confirmation.
- Le blocage passe par la même règle,
- Suivre sans compte :
/login?next=/@nom?follow=1, puis le profil suit une fois et retire le paramètre, comme?add=listensur une fiche. - Personne connectée facultative : une route ouverte sans compte lit
ICurrentUser.OptionalId(nul pour un visiteur);Idreste réservé aux routes qui exigent une connexion.
Pour toi et sélection communautaire
Décidé avec Samuel le 1er novembre 2026, pour US-23 et US-24. Tristan est responsable de la sélection (DEC-07).
- L'accueil a deux onglets : Pour toi, par défaut, et Abonnements (le fil, US-22). Pour toi juxtapose des sections titrées, chacune chargée à part : « De tes abonnements » (les 5 dernières écoutes du fil,
GET /bff/feed?limit=5, ou l'état sans abonnement du dossier), les sélections publiées (GET /bff/selections), puis « Critiques récentes de la communauté » (GET /bff/works/reviews/recent, les 10 dernières critiques publiques que le lecteur peut voir, règleAudiences.SeenBy). Sans compte : les sélections et les critiques. Pas de goûts personnels au MVP, comme le dit le dossier. - Chaque œuvre s'ouvre, ou s'ajoute à À écouter. Sans compte, ce bouton passe par la connexion, puis la fiche reprend l'action (
?add=listen-later). - La sélection vit dans deux tables :
selections(titre, place parmi les sections, publiée ou non) etselection_items(place de 1 à 12, la sortie, la chanson pour une chanson avec sa sortie associée, une note de 280 caractères au plus). Pas d'outil d'administration pour l'instant (décision de Samuel) : on la compose directement en SQL, sans déploiement. Une œuvre doit être déjà copiée chez nous; pour en copier une, l'ajouter une fois à ses favoris ou à À écouter depuis sa fiche, dont l'adresse donne alors notre identifiant.
-- Une nouvelle section, pas encore publiée.
INSERT INTO selections (id, title, position, published) VALUES (uuidv7(), 'Nouveautés de la semaine', 1, false) RETURNING id;
-- Ses œuvres, dans l'ordre : une sortie, puis une chanson avec sa sortie associée.
INSERT INTO selection_items (selection_id, position, release_id, recording_id, note) VALUES
('<id de la section>', 1, '<id de la sortie>', NULL, 'Le disque de l''automne.'),
('<id de la section>', 2, (SELECT release_id FROM recordings WHERE id = '<id de la chanson>'), '<id de la chanson>', NULL);
-- Publier, retirer, supprimer.
UPDATE selections SET published = true WHERE id = '<id de la section>';
UPDATE selections SET published = false WHERE id = '<id de la section>';
DELETE FROM selections WHERE id = '<id de la section>';
Modération
Décidé avec Samuel le 2 novembre 2026, pour US-35. Tristan est responsable de la modération (DEC-07). Les motifs et le traitement par la modération ne viennent pas du dossier vision, qui demande seulement un motif, un commentaire facultatif et une confirmation de réception.
-
Ce qu'on signale : une critique d'une autre personne, partout où elle paraît (fiche, Critiques récentes de Pour toi, fil Abonnements, journal d'un profil), et un profil, depuis son menu, à côté de Bloquer. Les playlists s'ajouteront avec US-33.
-
Motifs : une seule liste pour une critique et pour un profil, en texte dans l'API et en base :
spam(Spam ou publicité),harassment(Harcèlement ou propos haineux),explicit(Contenu sexuel ou violent),privacy(Informations personnelles),impersonation(Usurpation d'identité),other(Autre chose). Le commentaire reste facultatif, même pour Autre chose, jusqu'à 1 000 caractères. -
Table
reports: qui signale, la personne signalée (l'auteur de la critique, ou le profil), l'écoute pour une critique, le motif, le commentaire, une copie du texte signalé (snapshot), l'état (Open,Dismissed,Removed), qui l'a traité et quand. La copie garde ce qui a été signalé même si l'auteur modifie sa critique ensuite. Supprimer l'écoute, la personne signalée ou la personne qui signale efface le signalement; supprimer le compte de la personne qui l'a traité metresolved_byà nul. -
Signaler :
POST /bff/reportsaveclistenIdouusername,reasonetcomment, réponse204. Compte inscrit exigé (409 users.registration_required). On ne signale que ce qu'on voit : une critique privée, celle d'un profil privé qu'on ne suit pas, ou un contenu de l'autre côté d'un blocage répondent404 moderation.content_not_found, comme ce qui n'existe pas. Un profil privé montre son nom à tous les inscrits, donc ce nom se signale. Son propre contenu :409 moderation.own_content. Signaler deux fois la même chose ne crée rien et répond la même chose (index unique sur la personne qui signale, la personne signalée et l'écoute,NULLS NOT DISTINCT). -
Dans l'app : sans compte, Signaler mène à la connexion, puis revient à la page sans rouvrir la fenêtre. La fenêtre demande un motif (boutons radio) et un commentaire, puis confirme : « Merci. On a bien reçu ton signalement. » La personne qui signale n'apprend pas la suite et la personne signalée n'est pas prévenue : pas de notifications au MVP.
-
Rôle : le rôle de realm
moderation, déclaré dansrealm.yamlet donné à une personne par la console oukcadm(procédure dansdeploy/k8s/README.md). Un mapper des deux clients met les rôles de realm dans le claimroles, de l'id_tokenpour la session web et du jeton d'accès pour le mobile. La politiquemoderationprotège/bff/moderation, et403 auth.forbiddenrépond sans le rôle. Core n'en voit queICurrentUser.IsModerator, queGET /bff/merenvoie (moderator) pour que l'app montre l'entrée Modération du menu du compte. Sur le web, la session garde les rôles de sa connexion : un rôle retiré tient jusqu'à la reconnexion suivante. -
Traiter : la page
/moderationlitGET /bff/moderation/reports?after=, les signalements ouverts du plus ancien au plus récent, par tranches de 50 avec un curseur.POST /bff/moderation/reports/{id}/resolveavecdecision:removeretire la critique (la colonnereviewde l'écoute passe à nul) après une confirmation. L'écoute et sa note restent, et tous les signalements ouverts de cette critique se ferment;dismissclasse le signalement sans suite.
Un profil signalé se classe seulement (
409 moderation.nothing_to_removepourremove) : le renommer ou bannir le compte se fait encore à la main. Un signalement déjà traité répond409 moderation.report_resolved. -
Plus tard : des mécanismes automatiques (DEC-07), une alerte à la modération quand un signalement arrive (il faudra un envoi de courriels, que Keycloak n'a pas non plus), et la réattribution d'un nom d'utilisateur par la modération (voir Noms d'utilisateur).
Confidentialité
US-34 : ce qui est privé ne sort jamais, et un test automatisé couvre chaque chemin. Un échec bloque l'ouverture au public. Les tests sont dans tests/api/Musiqud.Api.Tests/; la colonne donne la classe et le test.
| Chemin | Ce qui ne doit pas sortir | Test |
|---|---|---|
Journal d'un profil public (/bff/people/{nom}/listens) | les écoutes privées, pour un visiteur et une autre personne | PrivacyTests.A_private_listen_on_a_public_profile_shows_to_nobody_else |
| Profil privé (profil, journal) | tout, sans compte; tout sauf le nom, sans abonnement accepté | SocialTests.A_private_profile_shows_nothing_to_a_visitor_and_only_its_name_to_a_stranger |
| Demande d'abonnement en attente | les écoutes du profil privé; puis, acceptée, seulement les publiques | SocialTests.A_request_lets_nothing_through_until_accepted_and_then_only_public_listens |
| Recherche de personnes | un profil privé, sauf par son nom exact et jamais sans compte | SocialTests.People_are_found_by_part_of_their_name_but_a_private_profile_only_by_its_exact_username |
| Fil Abonnements | les écoutes privées, et celles d'un profil privé avant l'acceptation | SocialTests.The_feed_shows_the_public_listens_of_accepted_follows_newest_added_first |
| Critiques récentes (Pour toi) | les écoutes privées, les profils privés non suivis, les blocages | ForYouTests.Recent_reviews_leave_out_private_listens_private_profiles_and_blocks |
| Critiques d'une fiche | celles des écoutes privées | JournalTests.Public_reviews_are_listed_newest_first_with_their_author_and_never_the_private_ones |
| Critiques d'une fiche | celles d'un profil privé, sauf pour ses abonnés acceptés | SocialTests.A_private_profiles_reviews_show_only_to_its_accepted_followers |
| Moyenne d'une fiche | les notes privées (une note publique d'un profil privé compte) | PrivacyTests.A_private_profiles_public_rating_counts_in_the_average_but_its_private_rating_never_does, JournalTests.The_average_counts_each_persons_last_public_rating_of_this_work_only |
| Abonnés et abonnements d'un profil | les listes d'un profil privé, sauf pour lui et ses abonnés acceptés; les demandes en attente; les blocages; les profils privés, sans compte | FollowListTests.A_private_profiles_lists_show_only_to_itself_and_its_accepted_followers, FollowListTests.Nobody_across_a_block_shows_and_a_visitor_sees_no_private_profile |
| Favoris d'un profil | À écouter, et les favoris d'un profil privé | SocialTests.Favourites_show_on_a_public_profile_never_the_listen_later_list_nor_a_private_profiles |
| Top 5 d'un profil | celui d'un profil privé | TopTests.A_private_profiles_top_5_is_not_found_for_a_stranger |
| Rotation d'un profil | celle d'un profil privé, sauf pour ses abonnés acceptés | RotationTests.A_private_profiles_rotation_shows_only_to_its_accepted_followers |
| Rotation d'un profil | elle, dans les deux sens d'un blocage | RotationTests.A_rotation_hides_across_a_block |
| Écoute d'une autre personne (modifier, supprimer, reprendre son identifiant) | son existence | JournalTests.A_listen_is_edited_or_deleted_by_its_author_only, JournalTests.Another_persons_listen_id_is_refused |
| Blocage | tout, dans les deux sens : profil, journal, recherche, suivre | BlockTests.A_block_removes_follows_and_hides_each_from_the_other |
| Blocage | critiques, Top 5 et favoris, qui reviennent au déblocage | BlockTests.Reviews_top_5_and_favourites_hide_across_a_block_and_come_back_after_unblocking |
| Compte supprimé | toute ligne qui le nomme, dans chaque table | AccountDeletionTests.Deleting_an_account_leaves_nothing_of_the_person |
Signaler (POST /bff/reports) | l'existence d'une critique privée, ou d'un contenu de l'autre côté d'un blocage | ReportTests.What_one_cannot_see_or_owns_cannot_be_reported |
File de modération (/bff/moderation/reports) | les signalements et le texte signalé, à qui n'a pas le rôle moderation | ReportTests.Only_the_moderation_role_sees_and_resolves_reports |
Les playlists, repoussées après le MVP, ajouteront leurs lignes. Un nouveau chemin qui montre le contenu d'une personne passe par Audiences.SeenBy et ajoute sa ligne ici, avec son test.
Authentification
Keycloak (TECH-05) garde l'identité; Musiqud garde ce qui lui appartient. Décisions du 30 octobre 2026.
Clients Keycloak
Décrits dans deploy/k8s/base/keycloak/realm.yaml, comme le reste du realm :
musiqud-web, confidentiel : le BFF fait l'échange du code d'autorisation avec son secret, gardé dans un Secret Kubernetes.musiqud-mobile, public, avec PKCE : l'app obtient ses jetons elle-même.- Leurs URI de retour incluent
http://app.localhost:8080, puisqu'en local l'app utilise le Keycloak déployé. - Les jetons portent l'audience
musiqud-api, que l'Api exige. - Le secret de
musiqud-webvit dans le Secretmusiqud-oidc.up.shle tire au hasard à la première installation; le Job du realm et l'Api le lisent.
Web : session côté serveur
- Le BFF fait le flux OIDC avec Keycloak et pose un cookie de session. La session vit côté serveur, dans une table Postgres; le cookie ne porte que son identifiant. On peut donc la révoquer : « me déconnecter partout », un compte supprimé ou banni.
- Cookie
__Host-musiqud:Secure,HttpOnly,SameSite=Lax(Strictcasserait le retour depuis Keycloak). - Durée : 30 jours glissants. Chaque visite prolonge la session; 30 jours sans visite la ferment.
- Les clés de Data Protection d'ASP.NET sont gardées dans Postgres. Sinon chaque déploiement déconnecterait tout le monde.
- Chaque appel
/bffqui modifie quelque chose exige l'en-têteX-CSRF: 1, qu'un formulaire d'un autre site ne peut pas ajouter. - La table
sessionsgarde chaque session : la personne, la date d'expiration et le ticket d'ASP.NET, chiffré par Data Protection (SessionTicketStore, unITicketStore). Le ticket ne garde des jetons de Keycloak que l'id_token, pour la déconnexion. Une tâche de fond supprime les sessions expirées toutes les heures, et supprimer une personne supprime ses sessions. - Le code revient de Keycloak dans l'adresse (
response_mode=query), avec PKCE, sur/bff/signin-oidc. - Sans l'en-tête
X-CSRF: 1, un appel qui modifie répond400avec le codeauth.csrf_header_missing. - Routes :
GET /bff/login?provider=google&returnUrl=/…passekc_idp_hintà Keycloak, qui saute sa propre page.providerest facultatif (googleouapple).returnUrldoit être un chemin de notre site, sinon le retour se fait sur/.POST /bff/logoutsupprime la session et répond l'adresse de déconnexion de Keycloak (redirectUrl). L'app y envoie le navigateur, ce qui ferme aussi la session Keycloak, puis Keycloak renvoie sur/. Sans session,204.GET /bff/me: la personne connectée.
/bff/loginet/bff/logoutsont la seule exception à « un endpoint, un message » : ils pilotent les handlers d'authentification d'ASP.NET, avant le médiateur. Le retour de connexion, lui, envoieSignInCommand.
Mobile : jeton
- L'app ouvre la connexion de Keycloak dans le navigateur du système (pas dans la WebView), avec PKCE, puis appelle
/bffavec son jeton d'accès dans l'en-têteAuthorization. Pas de cookie : la WebView bloque les cookies entre origines. - Retour dans l'app : le schéma maison
musiqud://callbackpour l'instant, sûr avec PKCE. À remplacer par les Universal Links (iOS) et App Links (Android) avec TECH-06 : retour parhttps://app.<domaine>/auth/callback, avec les fichiers de vérification publiés sur le domaine, pour qu'aucune autre app ne puisse intercepter le retour. - Les jetons vivent dans le trousseau d'iOS et le Keystore d'Android, jamais dans
localStorage. Le jeton de rafraîchissement change à chaque usage et vit 30 jours glissants, comme la session web : l'app demande la portéeoffline_access, dont la session dure 30 jours sans usage dans Keycloak, indépendamment de sa session SSO. - Le code vit dans
src/capacitor/src/auth/:session.tspour la logique (PKCE, échange du code, rafraîchissement, inscription), testée sans plugin, etindex.tspour les plugins. L'app importe toujours@platform/auth(contrat danssrc/app/src/lib/auth.ts). Le build mobile (vite build --mode mobile) y met ce module, le build web y metsrc/app/src/lib/web-auth.ts. Le choix se fait au build, pas à l'exécution : le bundle web ne contient rien de Capacitor. - Un seul rafraîchissement à la fois : Keycloak invalide le jeton de rafraîchissement à son premier usage. Un refus de Keycloak (session fermée, jeton révoqué) efface les jetons, et la personne se reconnecte. La déconnexion révoque le jeton de rafraîchissement (
/revoke), sans ouvrir le navigateur. - Le client
musiqud-mobileaccepte les origines de la WebView (capacitor://localhost,https://localhost) : l'échange du code part de l'app. - Après sa première connexion, l'app envoie
POST /bff/me, qui crée la ligneusers(voir Personnes). Le renvoyer ne change rien.
Dans l'Api
- Deux schémas d'authentification : le cookie pour le web, le jeton JWT pour le mobile. Un schéma de choix prend le JWT quand la requête a un en-tête
Authorization, le cookie sinon. - Configuration dans la section
Auth:Authorityest l'adresse publique du realm, que les jetons nomment comme émetteur. Dans le cluster,MetadataAddresslit les métadonnées par le Servicekeycloak(KC_HOSTNAME_BACKCHANNEL_DYNAMICdonne alors les adresses internes pour le jeton et les clés). - Un refus répond en Problem Details, jamais par une redirection :
401auth.requiredsans connexion,401users.not_registeredavec un jeton valide d'une personne pas encore inscrite,403auth.forbiddensinon. Une route qui n'existe pas reste un404. - La connexion est obligatoire par défaut. Une route publique (un profil partagé consulté sans compte,
/v1en lecture) le déclare explicitement. /v1, pour les tiers, est sans authentification pour l'instant, en lecture publique seulement. Clés d'API ou clients OAuth viendront quand un tiers en aura besoin.
Personnes
- Une ligne
userspar personne : identifiant interne (Guidv7) etsubde Keycloak (colonnesubject, unique).SignInCommand(trancheUsers) la crée à la première connexion. Ce qui appartient à Musiqud (nom d'utilisateur public, visibilité par défaut de DEC-03, profil) s'y ajoute avec US-01. - Web : le retour de connexion envoie
SignInCommandet range l'identifiant interne dans la session (claimmusiqud_user). - Mobile : chaque requête retrouve la personne par son
sub(GetUserBySubjectQuery, en cache 5 minutes), sans jamais la créer. Une personne pas encore inscrite reçoit401users.not_registered, sauf surPOST /bff/me. - Keycloak garde l'identité : fournisseurs liés, courriel, nom. On n'en copie que ce qu'il faut afficher.
- Core reçoit la personne par
ICurrentUser(Core/Shared/Interfaces/), qui donne l'identifiant interne, jamais lesub. Les handlers ne lisent jamaisHttpContextet ne savent rien de Keycloak. - Les rôles (modération) sont des rôles du realm, lus dans le jeton ou la session.
- Supprimer son compte (US-42, décisions de Samuel du 1er novembre 2026) :
DELETE /bff/me?releaseUsername=. Supprimer, c'est effacer : la ligneusersdisparaît, et la base efface en cascade ce qui en dépend (écoutes avec notes et critiques, favoris et À écouter, abonnements et demandes, Top 5, blocages, rotation, vues de profil, sessions). Les œuvres copiées restent, elles ne sont à personne.- Le compte Keycloak part ensuite, par l'API d'administration de Keycloak et le compte de service
musiqud-api-admin(seul droit :manage-users; secretadmin-client-secretdu Secretmusiqud-oidc, ajouté parup.sh), derrièreIIdentities. La suppression est notée dansidentity_deletionsdans la même transaction que nos données, puis demandée hors transaction; si Keycloak ne répond pas, la tâche horaire réessaie. Sans le secret (en local, par défaut), la suppression attend, sans erreur pour la personne. - Le nom d'utilisateur reste réservé 90 jours (
reserved_usernames, par son squelette), sauf si la personne coche « Libérer mon nom d'utilisateur tout de suite ». La vérification de disponibilité et l'inscription en tiennent compte; la tâche horaire efface les réservations échues. US-41 (changer de nom) s'en sert pour l'ancien nom. - Le cache qui relie un jeton mobile à la personne l'oublie aussitôt. La confirmation demande de taper son nom d'utilisateur; pas de nouvelle connexion exigée au MVP.
- Le compte Keycloak part ensuite, par l'API d'administration de Keycloak et le compte de service
Reprendre l'action après la connexion (US-02)
Décidé avec Samuel le 31 octobre 2026.
- Une action qui demande un compte (ajouter une écoute; suivre, avec US-21) envoie une personne sans compte à l'écran de connexion
/login?next=<adresse>, oùnextdit où reprendre :/album/<id>?add=listen. L'écran offre « Se connecter avec Google » et « Créer un compte avec Google ». Avec un fournisseur social, les deux font la même chose chez Keycloak; Apple s'y ajoutera avec TECH-06. - Web :
nextdevient lereturnUrlde/bff/login, et Keycloak ramène le navigateur à cette adresse. Mobile : la connexion se fait dans le navigateur du système sans quitter l'app, puis l'app va elle-même ànext. - Une personne connectée mais pas encore inscrite va à
/register?next=<adresse>. Après « Créer mon compte », l'app va ànextau lieu de l'accueil. - La fiche lit
?add=listen: pour une personne inscrite, elle ouvre la fenêtre d'ajout (vide) et retire le paramètre de l'adresse, pour qu'un retour arrière ou un rechargement ne la rouvre pas. nextn'est suivi que s'il est un chemin de notre site (src/app/src/lib/navigation.ts, mêmes règles que le BFF); sinon, l'accueil.- L'action à reprendre vit dans l'adresse, pas dans
sessionStorage: elle survit au passage par Keycloak, même si le navigateur rouvre l'app dans un autre onglet.
Noms d'utilisateur
Décidé avec Samuel le 30 octobre 2026. Le nom d'utilisateur est l'adresse d'une personne (/@nom), sa mention et la preuve de son identité : il est demandé à l'inscription (US-01) et unique. Le nom affiché, à côté, accepte n'importe quel texte (sans autre limite qu'une longueur maximale de 50 caractères) et peut être le même pour plusieurs personnes.
- Caractères : tous sont permis (accents, autres alphabets, emojis, ponctuation), sauf ceux qui cassent une adresse ou une mention : espaces, caractères invisibles (largeur nulle, marques de direction), caractères de contrôle, et
@ / # ? %. Le nom est normalisé en NFC à l'enregistrement. Longueur de 3 à 30 caractères affichés. - Minuscules : le nom est enregistré et affiché en minuscules (repli de casse Unicode à la saisie).
Samdevientsam. - Unicité sur les sosies : deux noms qui se confondent à l'écran sont le même nom. La comparaison se fait sur le squelette de la norme Unicode UTS #39 :
samécrit avec unѕet unаcyrilliques compte commesam. Rien d'autre n'est fusionné :sam.leclercetsamleclercpeuvent coexister. Les données de sosies (confusables.txtd'Unicode) sont versionnées dans le dépôt. - La base garantit l'unicité : un index unique sur la colonne du squelette. Deux inscriptions simultanées donnent un
Conflictà la seconde (parSaveAsync). La vérification pendant la saisie n'est qu'une aide. - Noms réservés : nos routes (
settings,login,api,bff), nos rôles (admin,support,moderation,musiqud) et une liste de termes offensants, dans un fichier versionné, comparés eux aussi par squelette. - Suggestions quand un nom est pris, pour ne pas bloquer l'inscription.
- Changer de nom : une fois tous les 30 jours. L'ancien nom reste réservé à son ancien propriétaire 90 jours, et les liens vers l'ancien nom redirigent vers le nouveau pendant ce temps.
- Compte supprimé : son nom reste bloqué 90 jours avant de redevenir libre, pour que personne ne reprenne les liens et les mentions de l'ancien compte.
- Libération immédiate : la personne peut renoncer à la réserve de 90 jours quand elle change de nom ou supprime son compte, et la modération peut libérer ou réattribuer un nom (imitation, marque).
- Le nom vit dans notre table
users, jamais dans Keycloak, dont le nom d'utilisateur reste un détail interne.
Mise en œuvre (US-01, 31 octobre 2026) :
- Nom par défaut (choix de Samuel) : à la première connexion,
SignInCommanddonneusersuivi de 6 chiffres tirés au hasard (user473562), vérifié libre. Chaque personne a donc toujours une adresse. Le nom affiché part de celui que donne Google (namedu jeton). Une inscription finie se repère àregistered_at, pas au nom. - Colonnes nullables : l'API précédente, qui tourne encore pendant un déploiement, crée des lignes sans nom, et la migration ne peut pas calculer un squelette en SQL. La connexion suivante leur donne un nom par défaut.
- Inscription : l'app envoie à
/registertoute personne connectée dontregisteredest faux.POST /bff/me/registrationreçoit le nom, le nom affiché (facultatif) et la visibilité par défaut, sans valeur d'avance : sans elle,400. Une seule fois (409 users.already_registered). - Vérification pendant la saisie : l'app vérifie la forme sans réseau (
src/app/src/lib/usernames.ts), puis demandeGET /bff/usernames/availability?name=300 ms après la dernière touche :available,taken(un sosie compte) oureserved, avec jusqu'à trois suggestions libres (sam2,sam.musique,sam_42…). Le nom de la personne elle-même compte comme libre. - Règles côté serveur : le validateur vérifie la forme (codes
users.username_length,users.username_characters), le handler les noms réservés (409 users.username_reserved) et les sosies (409 users.username_taken), et l'index unique surusername_skeletontranche entre deux inscriptions simultanées. - Code : le helper
Usernames(Core, trancheUsers) normalise (ToLowerInvariant, NFC), compte les caractères affichés (grappes de graphèmes) et calcule le squelette avecconfusables.txt(UTS #39, version 18.0.0). Ce fichier etreserved-usernames.txtsont dansMusiqud.Core/Users/Resources/, embarqués dans l'assembly. Le liant d'emoji (U+200D) n'est permis qu'entre deux symboles :👨👩👧passe,samnon. - Réglages :
PATCH /bff/me/settingschange la visibilité par défaut, après l'inscription seulement (409 users.registration_required). - Pour tester :
scripts/comptes.sh listeetscripts/comptes.sh supprimer <nom>(--deployepour le k3s). Supprimer efface la ligne et ses sessions; le compte Keycloak reste, et la connexion suivante recommence l'inscription.
Changer de nom (US-41, décisions de Samuel du 2 novembre 2026) :
PUT /bff/me/usernamereçoit le nouveau nom etreleaseOldUsername, et renvoie la personne. Les règles de l'inscription s'appliquent (validateur,409 users.username_reserved,409 users.username_taken), plus409 users.registration_required,409 users.username_change_too_soonavant 30 jours, et409 users.username_unchangedpour le même nom ou un sosie.- Les 30 jours partent du dernier changement (
users.username_changed_at, écrit avecTimeProvider). Le premier choix, à l'inscription, ne compte pas.GET /bff/medonneusernameChangeableAt, nul pour qui n'a jamais changé de nom. - L'ancien nom entre dans
reserved_usernamespour 90 jours, avec son propriétaire (user_id). Le propriétaire peut le reprendre : la vérification de disponibilité et le changement ne le comptent pas comme pris pour lui, et le reprendre efface la réserve. La case « Libérer @ancien tout de suite » de la fenêtre libère l'ancien nom sans réserve. C'est le seul moment pour le faire. - Le profil (
GET /bff/people/{nom}) cherche aussi parmi les anciens noms réservés qui ont un propriétaire, avec les mêmes règles (profil privé, blocage), et renvoie le nom actuel. L'app remplace alors/@ancienpar/@nouveauet garde l'onglet. Les autres adresses (/@ancien/followers, les routes/bff/people/{ancien}/…) répondent 404. - Supprimer son compte laisse ses anciens noms réservés jusqu'à leur date, sans propriétaire (
ON DELETE SET NULL), donc sans redirection. « Libérer mon nom d'utilisateur » libère aussi les anciens. - Rien ne change dans Keycloak : son nom d'utilisateur vient du fournisseur (courriel Google, identifiant Spotify), et l'API ne le lit pas.
Style de code
- Les principes de Clean Code (Robert C. Martin), SOLID, et DRY dès la deuxième occurrence.
- Des méthodes d'environ 10 lignes. Une méthode qui demande de faire défiler se divise.
- Des clauses de garde : le cas exceptionnel d'abord, avec
return(unResulten échec),continue, outhrowpour un argument invalide, pour garder le chemin principal au premier niveau. Pas deelseaprès unreturn, pas deifqui enveloppe tout le corps. Au-delà de deux niveaux d'imbrication, la partie intérieure devient une méthode privée bien nommée. - Jamais un ternaire ou un
??dans une branche d'un ternaire. Chaque étape va dans sa variable nommée, ou dans desifavec retours anticipés. - Une expression
=>tient sur une ligne après la signature. Sinon, un corps en bloc avecreturn. - Les records ont un corps entre accolades et des propriétés
{ get; init; }, jamais de paramètres positionnels. On les construit avec des initialiseurs d'objet. Les entités EF font exception (voir Données). - Constructeurs primaires quand le constructeur ne fait que copier ses paramètres : chaque champ garde son nom et
readonly, initialisé depuis le paramètre (private readonly TimeProvider _time = time;). Les méthodes utilisent les champs, jamais les paramètres. - Les constantes, et les champs
static readonly, sont enUPPER_SNAKE_CASE, et se rangent ensemble, en tête. - Pas de
#region. Les membres d'un type suivent quand même toujours le même ordre : constantes, champs, propriétés, constructeurs (explicites seulement), méthodes. Les types imbriqués viennent en dernier. Dans chaque groupe : public, internal, protected, puis private, et ordre alphabétique (ordinal : les majuscules avant les minuscules). Le build le vérifie (MQ0012). - Une méthode se lit en trois parties : préparation (variables et lectures), logique, puis persistance (écritures).
- Toutes les écritures d'un handler se voient dans
HandleAsync, et celles d'un service dans ses méthodes publiques. Une méthode privée calcule, valide ou prépare, mais n'écrit jamais :SaveAsync,ExecuteUpdateAsyncetExecuteDeleteAsyncn'apparaissent que dans des méthodes publiques. - Le code respire : une ligne vide sépare la préparation de la logique et la logique du retour, et entoure chaque bloc
if,foreach,try,usingouswitch. Pas de ligne vide juste après une accolade ouvrante ni juste avant une fermante. - Les accolades ouvrent sur une nouvelle ligne, et un bloc ne tient jamais sur une ligne (
try { x(); }est interdit). Unif,foreachouusingd'une seule instruction peut omettre ses accolades..editorconfigl'impose; lancerdotnet format whitespaceavant de terminer. - Les noms expliquent le code. Les commentaires servent à l'intention ou à une logique vraiment compliquée, jamais à répéter ce que fait le code.
Linters
Les règles qu'un outil peut vérifier sont vérifiées par un outil, dès le premier commit de la solution, avant tout code de fonctionnalité. Une violation fait échouer le build : pas d'avertissement qu'on laisse traîner.
.editorconfigà la racine : mise en forme (accolades, lignes vides, indentation) et conventions de noms (UPPER_SNAKE_CASEpour les constantes et lesstatic readonly,_camelCasepour les champs privés), en sévéritéerror.Directory.Build.propsdanssrc/api/:Nullable,TreatWarningsAsErrors,EnforceCodeStyleInBuild(le build applique.editorconfig),AnalysisLevelau niveaulatest-recommended.- Les règles de l'analyse recommandée qui contredisent nos conventions sont désactivées une à une dans
.editorconfig, chacune avec sa raison (UPPER_SNAKE_CASE, mots-clés de Visual Basic…). Musiqud.Generators: les diagnostics du médiateur (MQ0001àMQ0009), et les règles propres au projet que rien d'autre ne vérifie :MQ0010: unResultignoré;MQ0011: un?:ou un??dans une branche d'un?:;MQ0012: un membre hors de l'ordre (StyleCop a été écarté : sa règle SA1201 impose les constructeurs avant les propriétés, et ne se configure pas);MQ0013:SaveChangesouSaveChangesAsyncappelé hors d'Infrastructure.
- Tests d'architecture (
Musiqud.Architecture.Tests) pour ce qui dépasse un fichier : dépendances entre projets, entités jamais exposées par un DTO, types de Core rangés dans un dossier de concept d'une tranche, handlers et services qui ne prennent rien d'une autre tranche, handlersinternalet messages publics, services listés dans Services. Plus tard : noms de fournisseurs. - Un hook Claude Code (
.claude/hooks/format-cs.sh) remet en forme chaque fichier C# du backend que Claude écrit (dotnet format whitespace), tout de suite, sans attendre le build.
Restent vérifiées en revue, faute d'outil fiable : la taille des méthodes, la séparation préparation, logique et persistance, et le découpage des services par domaine.
Dépendances
Permis sans discussion : la BCL .NET, les paquets Microsoft (dont EF Core et ASP.NET Core), Npgsql et EFCore.NamingConventions. Toute autre dépendance demande une justification ajoutée à la stack technique avant usage.
Tests
- Les tests vivent dans
tests/api/, un projet par projet testé (Musiqud.Core.Tests,Musiqud.Infrastructure.Tests,Musiqud.Api.Tests), plusMusiqud.Architecture.Tests. Tous font partie deMusiqud.slnx. - Un utilitaire
ShouldFailWith("catalog.release_not_found")vérifie le code exact d'un échec, etShouldSucceed()renvoie la valeur d'un succès. - Les tests de Core remplacent les adaptateurs (catalogue, personne connectée) par des faux, et l'horloge par
FakeTimeProvider, dans un dossierFakes/séparé des tests. Les données, elles, sont un vrai Postgres : un handler se teste avecMusiqudDbContextsur la base de test. - Jamais le fournisseur InMemory d'EF ni SQLite à la place de Postgres : ils ne se comportent pas pareil (transactions, contraintes, SQL brut).
- Core ouvre ses types
internalàMusiqud.Core.Testsseulement (InternalsVisibleTo), pour tester les handlers et les modèles internes. Les tests de Core suivent ses tranches. - Les tests qui touchent la base utilisent
MUSIQUD_TEST_DB, jamais la base déployée. Chaque classe de tests travaille dans une base créée pour elle, migrée, puis supprimée (TestDatabase). SansMUSIQUD_TEST_DB, ces tests sont sautés. - Des tests d'architecture vérifient ce que le générateur ne vérifie pas : les dépendances entre projets, les règles des services et des helpers, la liste des services, les noms de fournisseurs.