{
  "openapi": "3.1.0",
  "info": {
    "title": "Annuaire des radios Mozaik Labs (API v2)",
    "version": "2.0.0",
    "description": "API de l'**annuaire des radios** de Mozaik Labs, servie par le site (https://mozaiklabs.fr).\n\n> **Attention, deux `/api/v1/radios` différents.** `GET https://mozaiklabs.fr/api/v1/radios` (ce document, en bas) est la **liste héritée** de l'annuaire, 847 fiches plates, lue par les anciens Tune : à ne pas utiliser pour la synchronisation. Le `/api/v1/radios` du **lecteur Tune** (port 8888, Technical Guide / API Reference) est une autre API : les favoris radio d'un serveur Tune. Rien à voir avec celle-ci.\n\n## Lecture (sans jeton)\n`GET /api/v2/radios`, `GET /api/v2/radios/categories`, `GET /api/v2/radios/{external_id}`.\n\n## Écriture (jeton `radios:write`)\nEn-tête `Authorization: Bearer <jeton>`. Le jeton est un jeton personnel dont la seule capacité est `radios:write`, porté par un compte curateur radios ou administrateur. Toutes les écritures acceptent `dry_run=1` (dans la requête ou le corps) : le même calcul, dans une transaction annulée ; `import_id` et `radio` valent alors `null`, et le code est 200. POST et PATCH d'une fiche acceptent `strict=1`.\n\n## Limites de débit (par adresse IP, pas par jeton)\nLe limiteur passe avant le contrôle du jeton. Trois compteurs distincts :\n- lectures v2 (les trois GET ensemble, et cette description) : **60 par minute** ;\n- écritures unitaires (POST, PATCH, DELETE ensemble, `PATCH /id/{id}` compris) : **1 200 par heure** ;\n- import complet (`POST /api/v2/radios/import`) : **10 par heure**.\n\nAu-delà : `429`, avec `Retry-After` (secondes). Les réponses portent `X-RateLimit-Limit` et `X-RateLimit-Remaining`.\n\n## Numéros\n- Fiche (`station_id`, `external_id` d'une station) : `S` + chiffres pour radio-metadata.fr ; `M000001`, `M000002`… (`M` + 6 chiffres au moins, une seule suite) pour une fiche créée par POST sans `station_id` et pour toute station Mozaik Labs : créée dans la console, proposée par Tune ou plus ancienne. Elle se lit par `GET /api/v2/radios/M…` et se modifie par `PATCH /api/v2/radios/M…` (sa `source` reste `mozaiklabs` : l'import complet ne la touche ni ne la retire). Sur une station Mozaik Labs (`source` = `mozaiklabs`), ce `M…` est provisoire : `PATCH /api/v2/radios/id/{id}` le remplace par le `S…` de radio-metadata.fr.\n- Flux (`stream_id`, `streams[].external_id`) : ceux de radio-metadata.fr (`S000212-01`) ; **`ML` + l'id interne du flux sur 6 chiffres au moins** (`ML000006`) pour tout flux que le site numérote lui-même (flux Mozaik Labs, flux ajouté dans la console, nouveau flux envoyé sans `stream_id`). La forme `ML…` est réservée au site : un client ne crée jamais de flux sous ce préfixe, mais peut **remplacer** un numéro `ML…` par le sien (`flux[].id` + `stream_id`).\n\n## Erreurs\nUn refus renvoie `error` (message), `details` (liste ou objet), et `code` quand le refus en a un. Une validation Laravel (champ inconnu, calculé, mal typé) renvoie `message` et `errors` (objet champ → messages), toujours en 422.\n",
    "contact": {
      "name": "Mozaik Labs",
      "url": "https://mozaiklabs.fr"
    }
  },
  "servers": [
    {
      "url": "https://mozaiklabs.fr"
    }
  ],
  "tags": [
    {
      "name": "Lecture",
      "description": "Sans jeton, 60 requêtes par minute et par IP (les trois routes ensemble)."
    },
    {
      "name": "Écriture",
      "description": "Jeton radios:write. 1 200 écritures unitaires par heure et par IP ; import complet : 10 par heure."
    },
    {
      "name": "Documentation"
    },
    {
      "name": "Héritée (v1)",
      "description": "Liste plate des anciens Tune. Ses champs ne changent jamais. À ne pas utiliser pour la synchronisation."
    }
  ],
  "paths": {
    "/api/v2/radios": {
      "get": {
        "tags": [
          "Lecture"
        ],
        "operationId": "listRadios",
        "summary": "Toutes les fiches actives",
        "description": "La réponse porte un ETag ; avec If-None-Match (forme W/ acceptée), un annuaire inchangé répond 304 sans corps.",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Code pays à 2 lettres."
          },
          {
            "name": "genre",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Un genre v2, cherché dans genres."
          },
          {
            "name": "category",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "category_id (external_id d'une catégorie, ex. C000017)."
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Dans le nom, sans tenir compte de la casse."
          },
          {
            "name": "updated_since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO 8601 ; 422 si illisible."
          },
          {
            "name": "include_retired",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "1 : les fiches retirées aussi, pour qu'un client synchronisé les enlève."
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "id",
                "popularity",
                "rating"
              ],
              "default": "id"
            },
            "description": "Ordre de la liste. `id` (défaut) : par id. `popularity` : indice de popularité décroissant, les inconnus (null) à la fin, puis id. `rating` : avg_rating décroissante, puis rating_count décroissant, puis id. Une autre valeur : 422."
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Les fiches.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "version",
                    "count",
                    "radios"
                  ],
                  "properties": {
                    "version": {
                      "const": 2
                    },
                    "count": {
                      "type": "integer"
                    },
                    "radios": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Station"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "304": {
            "description": "Inchangé depuis l'ETag rendu."
          },
          "422": {
            "description": "updated_since illisible.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "tags": [
          "Écriture"
        ],
        "operationId": "createRadio",
        "summary": "Crée une fiche",
        "description": "name et au moins une catégorie connue sont obligatoires. Sans station_id, le site donne M000001, M000002… Un nouveau flux sans stream_id reçoit le numéro du site ML… (lisible dans radio.streams). Attention : published: false dans le corps ne crée rien (201, radio: null, compteurs à zéro) ; pour créer une fiche retirée, la créer publiée puis la retirer.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/dry_run"
          },
          {
            "$ref": "#/components/parameters/strict"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StationWrite"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Créée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "200": {
            "description": "Essai (dry_run) : ce qui serait fait.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "409": {
            "description": "La fiche existe déjà : PATCH /api/v2/radios/{station_id}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v2/radios/categories": {
      "get": {
        "tags": [
          "Lecture"
        ],
        "operationId": "listCategories",
        "summary": "Les catégories",
        "description": "Les valeurs valides de categories à l'écriture sont les external_id non nuls (category_id dans l'export ; le champ se nomme external_id ici). Une catégorie à external_id null est locale et non inscriptible.",
        "responses": {
          "200": {
            "description": "Les catégories.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "version": {
                      "const": 2
                    },
                    "count": {
                      "type": "integer"
                    },
                    "categories": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Category"
                      }
                    }
                  }
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "post": {
        "tags": [
          "Écriture"
        ],
        "operationId": "createCategory",
        "summary": "Crée une catégorie",
        "description": "Crée une catégorie de l'annuaire (06/11/2026). `name` est obligatoire (2 à 64 caractères) et unique sans tenir compte de la casse ni des espaces ; le `slug` est calculé. `category_id` est le numéro de radio-metadata (ex. `C000380`) : sans lui, le site donne un numéro `MC000001`… (forme réservée, signalé dans `warnings`), qu'un import de masse remplace par le vôtre s'il porte une catégorie du même nom. `genre` (l'un des genres v2) est le genre que la catégorie donne aux stations (`genre`, `genres`) ; sans lui, le genre par défaut `eclectic` (signalé dans `warnings`). Ensuite la catégorie s'envoie dans `categories` d'une fiche par son `external_id`. Même jeton `radios:write` et même limiteur que les fiches ; `dry_run=1` dit ce qui serait créé sans rien écrire.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/dry_run"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CategoryWrite"
              },
              "examples": {
                "dance": {
                  "value": {
                    "name": "Dance",
                    "category_id": "C000380",
                    "genre": "electronic"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Créée.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CategoryWriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "200": {
            "description": "Essai (dry_run) : la catégorie qui serait créée, `id` nul.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CategoryWriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "409": {
            "description": "Codes : `category_exists` (même nom, ou même slug), `category_id_taken` (numéro porté par une autre catégorie). `category` : la catégorie en place.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Refusal"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "category": {
                          "$ref": "#/components/schemas/Category"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "Champ manquant, trop long ou inconnu, genre inconnu, `category_id` mal formé ou de la forme réservée `MC…` (code `invalid_category_id`).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ValidationError"
                    },
                    {
                      "$ref": "#/components/schemas/Refusal"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v2/radios/import": {
      "post": {
        "tags": [
          "Écriture"
        ],
        "operationId": "importDirectory",
        "summary": "Import de l'export complet",
        "description": "L'export « tuneradio-annuaire » complet. Une fiche absente ou dépubliée passe retirée ; un flux radio-metadata absent passe inactif ; rien n'est supprimé. Un export qui retirerait plus de 20 % des fiches actives est refusé, sauf allow_mass_retire=1. 10 imports par heure et par IP.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/dry_run"
          },
          {
            "name": "allow_mass_retire",
            "in": "query",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "strict",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "1 : un champ inconnu dans une fiche refuse l'export (sinon, avertissement)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExportDocument"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Importé.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImportResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "200": {
            "description": "Essai (dry_run).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImportResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v2/radios/id/{id}": {
      "patch": {
        "tags": [
          "Écriture"
        ],
        "operationId": "numberConsoleStation",
        "summary": "Pose un numéro S… sur une station créée dans la console",
        "description": "Station sans numéro, ou avec son numéro provisoire M… du site, créée dans la console par le compte du jeton (ou proposition de Tune qu'il a reprise). Numéro + source radio-metadata ; même id, donc fiche, flux, notes et favoris gardés.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "$ref": "#/components/parameters/dry_run"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "station_id"
                ],
                "additionalProperties": false,
                "properties": {
                  "station_id": {
                    "type": "string",
                    "examples": [
                      "S003744"
                    ]
                  },
                  "dry_run": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Numéroté (ou essai).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "dry_run": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "integer"
                    },
                    "external_id": {
                      "type": "string"
                    },
                    "changes": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "old": {},
                          "new": {}
                        }
                      }
                    },
                    "warnings": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "radio": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/Station"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    }
                  }
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Codes : station_mozaiklabs, station_not_yours, station_proposition_non_reprise (ou jeton d'un compte non curateur).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "404": {
            "description": "Code station_not_found.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "422": {
            "description": "Codes : invalid_station_id, station_id_taken (avec holder), station_already_numbered.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v2/radios/openapi.json": {
      "get": {
        "tags": [
          "Documentation"
        ],
        "operationId": "openapi",
        "summary": "Cette description (OpenAPI 3.1, JSON)",
        "description": "Page lisible : https://mozaiklabs.fr/docs/api/annuaire. Compte dans le seau des lectures v2.",
        "responses": {
          "200": {
            "description": "La description.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v2/radios/{external_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/external_id"
        }
      ],
      "get": {
        "tags": [
          "Lecture"
        ],
        "operationId": "showRadio",
        "summary": "Une fiche, même retirée",
        "responses": {
          "200": {
            "description": "La fiche.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Station"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "404": {
            "description": "Inconnue."
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "patch": {
        "tags": [
          "Écriture"
        ],
        "operationId": "updateRadio",
        "summary": "Met à jour une fiche",
        "description": "Fiche radio-metadata (S…) ou station Mozaik Labs sous son numéro M… (sa source reste mozaiklabs). Un champ omis garde sa valeur (categories omis : les catégories en place, même sans category_id). flux, categories et mots_cles, s'ils sont envoyés, remplacent toute la liste : un flux radio-metadata absent passe inactif (un flux mozaiklabs ou console absent reste tel quel ; pour l'écarter, l'envoyer avec published: false). station_id est accepté mais sans effet (le numéro est celui de l'adresse). Une fiche retirée ne se modifie qu'en la rouvrant dans le même PATCH (published: true) ; sinon 422 station_retired. published: false la retire.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/dry_run"
          },
          {
            "$ref": "#/components/parameters/strict"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StationWrite"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Mise à jour (ou essai).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "404": {
            "description": "Fiche inconnue : ni une fiche radio-metadata, ni une station Mozaik Labs sous son numéro M… approuvée ou retirée (une proposition en attente reste à la modération).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      },
      "delete": {
        "tags": [
          "Écriture"
        ],
        "operationId": "retireRadio",
        "summary": "Retire la fiche (rien n'est supprimé)",
        "description": "Exactement PATCH {\"published\": false}. PATCH {\"published\": true} la rouvre.",
        "security": [
          {
            "radiosWrite": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/dry_run"
          }
        ],
        "responses": {
          "200": {
            "description": "Retirée (ou essai).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WriteResult"
                }
              }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              }
            }
          },
          "404": {
            "description": "Fiche inconnue.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Refusal"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Unprocessable"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/api/v1/radios": {
      "get": {
        "tags": [
          "Héritée (v1)"
        ],
        "operationId": "legacyListRadios",
        "deprecated": true,
        "summary": "Liste héritée des anciens Tune (pas le /api/v1/radios du lecteur)",
        "description": "Liste plate, triée par note. Ses champs et leurs types ne changent jamais ; legacy_stream_url n'apparaît que lorsqu'il diffère de stream_url. Les fiches retirées ou sans flux lisible par un ancien Tune n'y figurent pas. 60 requêtes par minute et par IP.",
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "genre",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Genre v1."
          },
          {
            "name": "quality",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "search",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "La liste.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LegacyStation"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "radiosWrite": {
        "type": "http",
        "scheme": "bearer",
        "description": "Jeton personnel dont la seule capacité est radios:write."
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "schema": {
          "type": "integer"
        },
        "description": "60 (lectures), 1200 (écritures unitaires) ou 10 (import)."
      },
      "X-RateLimit-Remaining": {
        "schema": {
          "type": "integer"
        }
      },
      "Retry-After": {
        "schema": {
          "type": "integer"
        },
        "description": "Secondes avant de réessayer."
      }
    },
    "parameters": {
      "external_id": {
        "name": "external_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "pattern": "^[A-Z][0-9]{3,12}$"
        },
        "description": "Numéro de la fiche (S…, M…).",
        "example": "S000973"
      },
      "dry_run": {
        "name": "dry_run",
        "in": "query",
        "schema": {
          "type": "boolean"
        },
        "description": "1 : le même calcul dans une transaction annulée ; rien n'est écrit. Aussi accepté dans le corps."
      },
      "strict": {
        "name": "strict",
        "in": "query",
        "schema": {
          "type": "boolean"
        },
        "description": "1 : une seule catégorie inconnue refuse la fiche (422, details.unknown_categories) ; sinon elle est ignorée avec un avertissement. Sans aucune catégorie connue : 422 dans tous les cas."
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Jeton absent, faux, expiré ou d'une autre capacité.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "const": "Unauthorized"
                }
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Le compte du jeton n'est plus curateur radios ni administrateur, ou est inactif.",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "error": {
                  "const": "Forbidden"
                }
              }
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Limite de débit atteinte (par IP).",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          }
        }
      },
      "Unprocessable": {
        "description": "Refus : validation (message + errors) ou refus métier (error + details + code éventuel, ex. station_retired). Un flux à l'URL invalide ou au format refusé fait refuser toute la fiche.",
        "content": {
          "application/json": {
            "schema": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/Refusal"
                },
                {
                  "$ref": "#/components/schemas/ValidationError"
                }
              ]
            }
          }
        }
      }
    },
    "schemas": {
      "Station": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Id interne de la station (stable).",
            "readOnly": true
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Numéro de la fiche (S…, M…) ; null pour une station Mozaik Labs pas encore numérotée (proposition de Tune arrivée depuis le dernier rattrapage des numéros).",
            "examples": [
              "S000973"
            ],
            "readOnly": true
          },
          "source": {
            "type": "string",
            "enum": [
              "radio-metadata",
              "mozaiklabs"
            ],
            "readOnly": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "retired"
            ],
            "readOnly": true
          },
          "name": {
            "type": "string",
            "readOnly": true
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "readOnly": true
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ],
            "format": "uri",
            "readOnly": true
          },
          "country": {
            "type": "string",
            "description": "Code ISO à 2 lettres ; XX si inconnu.",
            "examples": [
              "FR"
            ],
            "readOnly": true
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chemin relatif au site (/storage/…).",
            "readOnly": true
          },
          "radio_metadata_key": {
            "type": [
              "string",
              "null"
            ],
            "readOnly": true
          },
          "stream_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Flux principal v2, recalculé à chaque lecture parmi les flux actifs et publiés (Opus et HLS admis).",
            "readOnly": true
          },
          "v1_stream_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Flux servi par la liste héritée v1 (calculé à l'écriture : jamais d'Opus, HLS seulement faute de flux direct). Peut différer de stream_url.",
            "readOnly": true
          },
          "quality": {
            "type": "string",
            "description": "Qualité du flux principal (ex. flac, aac192, 320k).",
            "readOnly": true
          },
          "genre": {
            "type": "string",
            "description": "Genre v2 de la première catégorie.",
            "readOnly": true
          },
          "genres": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Genres de toutes les catégories, dans leur ordre.",
            "readOnly": true
          },
          "categories": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StationCategory"
            },
            "readOnly": true
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "readOnly": true
          },
          "streams": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Stream"
            },
            "description": "Les flux actifs.",
            "readOnly": true
          },
          "avg_rating": {
            "type": "number",
            "readOnly": true
          },
          "rating_count": {
            "type": "integer",
            "readOnly": true
          },
          "popularity": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 4,
            "description": "Indice de popularité donné par radio-metadata : 1 à 4, null si inconnu. Tri : `?sort=popularity`."
          },
          "retired_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "readOnly": true
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "readOnly": true
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ],
            "minimum": -90,
            "maximum": 90,
            "description": "Lieu déclaré de la station : sa ville, PAS la position d'un émetteur ; précision à l'échelle de la ville. WGS 84, degrés décimaux, conservé à 4 décimales (arrondi à l'écriture). Latitude entre -90 et 90 ; null si inconnue."
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ],
            "minimum": -180,
            "maximum": 180,
            "description": "Lieu déclaré de la station : sa ville, PAS la position d'un émetteur ; précision à l'échelle de la ville. WGS 84, degrés décimaux, conservé à 4 décimales (arrondi à l'écriture). Longitude entre -180 et 180 ; null si inconnue."
          },
          "geo_edited_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "readOnly": true,
            "description": "Date de la dernière modification de latitude/longitude faite dans la console d'administration (jamais par l'API ni l'import) ; null si jamais éditée en console."
          }
        }
      },
      "StationCategory": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          }
        }
      },
      "Category": {
        "type": "object",
        "properties": {
          "id": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Nul seulement dans la réponse d'un essai (dry_run)."
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Le category_id à envoyer dans categories."
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "genre": {
            "type": "string"
          },
          "genre_v1": {
            "type": "string"
          },
          "stations_count": {
            "type": "integer"
          }
        }
      },
      "Stream": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Id interne du flux : sert à le désigner dans flux[].id.",
            "readOnly": true
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Numéro du flux (stream_id) : S…-NN de radio-metadata.fr, ou ML + id sur 6 chiffres (ML000006) pour un flux numéroté par le site.",
            "examples": [
              "S000212-01",
              "ML000006"
            ],
            "readOnly": true
          },
          "url": {
            "type": "string",
            "readOnly": true
          },
          "format": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "FLAC",
              "OPUS",
              "AAC",
              "OGG_VORBIS",
              "MP3",
              null
            ],
            "description": "Le codec ; null pour un flux HLS au codec inconnu.",
            "readOnly": true
          },
          "is_hls": {
            "type": "boolean",
            "readOnly": true
          },
          "bitrate": {
            "type": [
              "integer",
              "null"
            ],
            "readOnly": true
          },
          "sample_rate": {
            "type": [
              "integer",
              "null"
            ],
            "readOnly": true
          },
          "restricted_to_countries": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "null (ou []) = partout.",
            "readOnly": true
          },
          "is_published": {
            "type": "boolean",
            "readOnly": true
          },
          "is_primary": {
            "type": "boolean",
            "description": "Calculé : FLAC, puis le débit, puis OPUS > AAC > VORBIS > MP3 ; à égalité, flux direct, puis https, puis sans restriction de pays.",
            "readOnly": true
          },
          "origin": {
            "type": "string",
            "enum": [
              "radio-metadata",
              "mozaiklabs",
              "curator"
            ],
            "readOnly": true
          }
        }
      },
      "StationWrite": {
        "type": "object",
        "description": "Une fiche, dans la forme d'une station de l'export. Un champ inconnu ou calculé donne un 422 (jamais ignoré). Les champs calculés refusés sont listés dans x-refused-fields.",
        "additionalProperties": false,
        "x-refused-fields": {
          "genre": "calculé depuis la première catégorie (envoyer categories)",
          "genres": "calculés depuis les catégories (envoyer categories)",
          "quality": "calculée depuis le meilleur flux (envoyer flux)",
          "stream_url": "calculé : le meilleur flux l'emporte (envoyer flux)",
          "v1_stream_url": "calculé : le meilleur flux l'emporte (envoyer flux)",
          "avg_rating": "calculée par les notes des utilisateurs",
          "rating_count": "calculé par les notes des utilisateurs",
          "retired_at": "calculé : envoyer status ou published",
          "updated_at": "calculé",
          "source": "non modifiable",
          "external_id": "non modifiable : c'est l'identifiant dans l'URL (station_id à la création)",
          "id": "non modifiable",
          "logo_url": "non inscriptible : envoyer logo (WebP en base64)",
          "geo_edited_at": "calculé : posé par la console d'administration seulement"
        },
        "properties": {
          "station_id": {
            "type": "string",
            "pattern": "^[A-Z][0-9]{3,12}$",
            "description": "POST seulement : le numéro de la fiche. Sans effet dans un PATCH."
          },
          "name": {
            "type": "string",
            "maxLength": 255,
            "description": "Obligatoire au POST."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 5000
          },
          "website": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 255,
            "description": "Adresse http(s). Sans schéma mais de la forme d'un nom de domaine (pas d'espace, au moins un point : `www.100p.nl`), elle est préfixée `https://` et la réponse le dit dans `warnings`. Une valeur invalide est ignorée avec un avertissement : elle n'efface JAMAIS le site déjà enregistré. `null` ou `\"\"` efface le site. Même règle à l'import de masse.",
            "examples": [
              "https://www.100p.nl",
              "www.100p.nl"
            ]
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Alias de website (nom de la lecture v2)."
          },
          "country": {
            "type": "string",
            "minLength": 2,
            "maxLength": 2,
            "description": "Un code non ISO devient XX, avec un avertissement."
          },
          "radio_metadata_key": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 120
          },
          "popularity": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 1,
            "maximum": 4,
            "description": "Indice de popularité : entier de 1 à 4, null si inconnu (efface l'indice). Omis : l'indice en place est gardé. Toute autre valeur (\"3\", 3.5, 0, 5, true) : 422. À l'import de masse, une valeur invalide est ignorée avec un avertissement."
          },
          "published": {
            "type": "boolean",
            "description": "false retire la fiche, true la rouvre."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "retired"
            ],
            "description": "Équivalent de published ; s'ils se contredisent : 422."
          },
          "logo": {
            "type": [
              "string",
              "null"
            ],
            "description": "WebP 600 × 600 en base64 ou data URI, 2 Mio au plus ; null retire le logo."
          },
          "flux": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "$ref": "#/components/schemas/FluxWrite"
            },
            "description": "Remplace la liste des flux radio-metadata."
          },
          "categories": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string",
              "maxLength": 32
            },
            "description": "category_id ; au moins une connue."
          },
          "mots_cles": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "string",
              "maxLength": 120
            }
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Alias de mots_cles."
          },
          "dry_run": {
            "type": "boolean"
          },
          "strict": {
            "type": "boolean"
          },
          "latitude": {
            "type": [
              "number",
              "null"
            ],
            "minimum": -90,
            "maximum": 90,
            "description": "Latitude WGS 84 (-90 à 90), 4 décimales conservées (arrondi à l'écriture). Lieu déclaré = la ville de la station, pas un émetteur. Règle : latitude et longitude vont ENSEMBLE (deux nombres JSON, pas de chaînes, ou les deux à null qui effacent le lieu). Hors bornes, non numérique, une seule des deux envoyée ou une seule à null : 422 avec un message clair, rien n'est écrit. Omises, le lieu en place est gardé."
          },
          "longitude": {
            "type": [
              "number",
              "null"
            ],
            "minimum": -180,
            "maximum": 180,
            "description": "Longitude WGS 84 (-180 à 180), 4 décimales conservées (arrondi à l'écriture). Règle : latitude et longitude vont ENSEMBLE (deux nombres JSON, pas de chaînes, ou les deux à null qui effacent le lieu). Hors bornes, non numérique, une seule des deux envoyée ou une seule à null : 422 avec un message clair, rien n'est écrit. Omises, le lieu en place est gardé."
          }
        }
      },
      "FluxWrite": {
        "type": "object",
        "required": [
          "url"
        ],
        "additionalProperties": false,
        "x-refused-fields": {
          "is_primary": "calculé : le meilleur flux l'emporte",
          "is_hls": "calculé depuis l'URL (.m3u8) ; le format est le codec",
          "origin": "non modifiable",
          "external_id": "se nomme stream_id",
          "is_published": "se nomme published"
        },
        "description": "Un flux. Désigné par stream_id s'il existe ; sinon un nouveau flux est créé. Un flux sans numéro se désigne par id (streams[].id). id + stream_id : le flux reçoit ce stream_id, s'il n'avait pas de numéro ou un numéro du site ML… ; un numéro S… déjà posé ne change pas (422). id seul : le flux garde son numéro (ML… s'il vient du site).",
        "properties": {
          "id": {
            "type": "integer",
            "minimum": 1,
            "description": "Id numérique lu en v2 (streams[].id)."
          },
          "stream_id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$",
            "description": "Votre numéro du flux. La forme ML… est réservée au site : elle ne désigne qu'un flux existant."
          },
          "url": {
            "type": "string",
            "description": "http ou https."
          },
          "format": {
            "type": "string",
            "description": "FLAC, OPUS, AAC, OGG_VORBIS, MP3 ; variantes admises AAC+, HE-AAC, OGG, VORBIS, MPEG. HLS est accepté avec un avertissement (is_hls, codec inconnu)."
          },
          "bitrate": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1 à 100 000 ; hors bornes, vidé."
          },
          "sample_rate": {
            "type": [
              "integer",
              "null"
            ],
            "description": "1 000 à 1 000 000."
          },
          "published": {
            "type": "boolean",
            "default": true
          },
          "restricted_to_countries": {
            "oneOf": [
              {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              {
                "type": "string",
                "description": "\"FR,BE\""
              },
              {
                "type": "null"
              }
            ],
            "description": "Codes à 2 lettres ; un code invalide est ignoré avec un avertissement. [] et null = partout (enregistré null)."
          }
        }
      },
      "Counters": {
        "type": "object",
        "properties": {
          "stations_in_export": {
            "type": "integer"
          },
          "stations_skipped": {
            "type": "integer"
          },
          "stations_created": {
            "type": "integer"
          },
          "stations_linked": {
            "type": "integer"
          },
          "stations_updated": {
            "type": "integer"
          },
          "stations_unchanged": {
            "type": "integer"
          },
          "stations_reactivated": {
            "type": "integer"
          },
          "stations_retired": {
            "type": "integer"
          },
          "stations_protected": {
            "type": "integer"
          },
          "stations_retired_ours": {
            "type": "integer"
          },
          "streams_created": {
            "type": "integer"
          },
          "streams_updated": {
            "type": "integer",
            "description": "Flux dont une donnée réelle a changé. Ne comptent pas : restricted_to_countries passé de [] à null (même sens), positions renumérotées sans que l'ordre des flux change."
          },
          "streams_deactivated": {
            "type": "integer"
          },
          "streams_rejected": {
            "type": "integer",
            "description": "Flux écartés (URL invalide, format refusé, stream_id absent ou de la forme ML… réservée). En écriture unitaire, un seul flux écarté refuse toute la fiche (422)."
          },
          "categories_created": {
            "type": "integer"
          },
          "categories_updated": {
            "type": "integer"
          },
          "logos_copied": {
            "type": "integer",
            "description": "Un logo envoyé par l'API est toujours réécrit et compté, même identique (le fichier porte l'empreinte de son contenu : la fiche ne change pas)."
          },
          "logos_unchanged": {
            "type": "integer"
          },
          "logos_rejected": {
            "type": "integer"
          },
          "logos_missing": {
            "type": "integer"
          }
        }
      },
      "Change": {
        "type": "object",
        "properties": {
          "action": {
            "type": "string",
            "enum": [
              "created",
              "linked",
              "updated",
              "reactivated",
              "retired"
            ]
          },
          "external_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "name": {
            "type": "string"
          },
          "fields": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "ImportResult": {
        "type": "object",
        "properties": {
          "dry_run": {
            "type": "boolean"
          },
          "import_id": {
            "type": [
              "integer",
              "null"
            ]
          },
          "format_version": {
            "type": "integer"
          },
          "counters": {
            "$ref": "#/components/schemas/Counters"
          },
          "changes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Change"
            }
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "WriteResult": {
        "allOf": [
          {
            "$ref": "#/components/schemas/ImportResult"
          },
          {
            "type": "object",
            "properties": {
              "external_id": {
                "type": "string",
                "description": "Numéro de la fiche écrite."
              },
              "radio": {
                "oneOf": [
                  {
                    "$ref": "#/components/schemas/Station"
                  },
                  {
                    "type": "null"
                  }
                ],
                "description": "La fiche v2 après l'écriture ; null pour un essai."
              }
            }
          }
        ]
      },
      "ExportDocument": {
        "type": "object",
        "required": [
          "format",
          "version",
          "stations"
        ],
        "properties": {
          "format": {
            "const": "tuneradio-annuaire"
          },
          "version": {
            "type": "integer",
            "enum": [
              0,
              1
            ]
          },
          "genere_le": {
            "type": [
              "string",
              "null"
            ]
          },
          "categories": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "category_id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "stations": {
            "type": "array",
            "items": {
              "type": "object",
              "description": "station_id, name, description, website, country, radio_metadata_key, image, published, import_tune, flux, categories, mots_cles, popularity (1 à 4 ou null ; absent, l'indice en place est gardé), latitude et longitude (nombres WGS 84 en degrés décimaux, ensemble ou null/null qui efface ; absents, le lieu en place est gardé ; hors bornes, non numériques ou l'un sans l'autre : le document entier est refusé, 422, rien n'est écrit). `website` sans schéma (`www.100p.nl`) est préfixé `https://` avec un avertissement ; invalide, il est ignoré avec un avertissement et le site en place est gardé."
            }
          },
          "dry_run": {
            "type": "boolean"
          },
          "allow_mass_retire": {
            "type": "boolean"
          },
          "strict": {
            "type": "boolean"
          }
        }
      },
      "Refusal": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "details": {
            "description": "Liste de messages, ou objet champ → messages (ex. unknown_categories en mode strict)."
          },
          "code": {
            "type": "string",
            "enum": [
              "station_retired",
              "station_not_found",
              "station_mozaiklabs",
              "station_not_yours",
              "station_proposition_non_reprise",
              "invalid_station_id",
              "station_id_taken",
              "station_already_numbered"
            ]
          }
        }
      },
      "ValidationError": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          }
        }
      },
      "LegacyStation": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "stream_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "logo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ]
          },
          "genre": {
            "type": [
              "string",
              "null"
            ]
          },
          "quality": {
            "type": [
              "string",
              "null"
            ]
          },
          "website_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "avg_rating": {
            "type": "number"
          },
          "rating_count": {
            "type": "integer"
          },
          "legacy_stream_url": {
            "type": "string",
            "description": "Présent seulement s'il diffère de stream_url."
          }
        }
      },
      "CategoryWrite": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 2,
            "maxLength": 64,
            "examples": [
              "Dance",
              "Alternative/Indie Rock"
            ]
          },
          "category_id": {
            "type": "string",
            "pattern": "^[A-Za-z0-9_-]{1,32}$",
            "description": "Le numéro de radio-metadata. Facultatif ; la forme `MC…` est réservée au site.",
            "examples": [
              "C000380"
            ]
          },
          "genre": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "jazz",
              "classical",
              "rock",
              "pop",
              "electronic",
              "hiphop",
              "rnb",
              "blues",
              "reggae",
              "world",
              "folk",
              "country",
              "metal",
              "funk",
              "latin",
              "ambient",
              "lounge",
              "chanson",
              "eclectic",
              "news",
              "holiday",
              "oldies",
              "soundtrack",
              null
            ],
            "description": "Le genre v2 que donne la catégorie. Facultatif (défaut : `eclectic`). Une catégorie déjà décrite par la table du site garde le genre de la table."
          },
          "dry_run": {
            "type": "boolean"
          }
        }
      },
      "CategoryWriteResult": {
        "type": "object",
        "properties": {
          "dry_run": {
            "type": "boolean"
          },
          "warnings": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "category": {
            "$ref": "#/components/schemas/Category"
          }
        }
      }
    }
  }
}
