# Sona API

REST API для сайта и сторонних клиентов. База: `http://localhost:8765/api`.

- Формат ответов — JSON, кроме аудио, обложек и Markdown-документации.
- Авторизация: `Authorization: Bearer <token>` **или** cookie `sona_session`.
- Сессия живёт 30 дней (настраивается через `SESSION_DAYS`).
- Ошибки имеют вид:

```json
{ "error": { "status": 401, "message": "Требуется авторизация" } }
```

- OpenAPI-спецификация: [`/api/openapi.json`](/api/openapi.json).
- Эта документация доступна по адресу [`/api/docs`](/api/docs).

Публичные эндпоинты без авторизации: `GET /api/health`, `/api/auth/register`, `/api/auth/login`,
`GET /api/covers/:name`, `/api/docs`, `/api/openapi.json`, а также публичные плейлисты —
`GET /api/playlists/:id` и `GET|HEAD /api/tracks/:id/stream` для их треков.

`GET /api/health` сообщает `registrationEnabled` и `demoAvailable` — веб-интерфейс использует
эти флаги, чтобы скрыть регистрацию и демо-вход на закрытом сервере.

---

## Авторизация

### `POST /api/auth/register`

```json
{
  "name": "Алекс",
  "email": "alex@example.com",
  "password": "strongpass123"
}
```

**201 Created**

```json
{
  "user": {
    "id": "usr_...",
    "name": "Алекс",
    "email": "alex@example.com",
    "role": "user",
    "avatarColor": "#b8f45c",
    "createdAt": "2026-10-24T10:00:00.000Z"
  },
  "token": "eyJzdWIiOiJ1c3Jf..."
}
```

В ответе также приходит `Set-Cookie: sona_session=...; HttpOnly; SameSite=Lax`.

Ошибки: `400` — неверные поля, `409` — email уже занят, `429` — слишком много попыток,
`403` — регистрация отключена администратором (`ALLOW_REGISTRATION=0`).

### `POST /api/auth/login`

```json
{ "email": "demo@sona.local", "password": "demo1234" }
```

**200 OK** — та же структура, что и у регистрации.

### `POST /api/auth/logout`

Требует авторизации. Сбрасывает cookie. **200 OK**.

### `GET /api/auth/me`

Требует авторизации.

```json
{
  "user": { "...": "..." },
  "library": { "likes": 4, "playlists": 4 }
}
```

### `PATCH /api/auth/me`

Меняет имя. Тело: `{ "name": "Новое имя" }`.

---

## Каталог

### `GET /api/home`

Персональная главная страница. Требует авторизации.

```json
{
  "greeting": "С возвращением",
  "recentlyPlayed": [ /* треки + playedAt */ ],
  "madeForYou": [ /* плейлисты */ ],
  "albums": [ /* альбомы с треками */ ],
  "artists": [ /* исполнители */ ],
  "newTracks": [ /* треки */ ],
  "topTracks": [ /* треки */ ],
  "likedTracks": [ /* треки */ ],
  "stats": { "tracks": 12, "albums": 3, "artists": 3, "playlists": 4, "liked": 4, "hours": 0.2 }
}
```

### `GET /api/tracks`

Параметры:

| Параметр | Описание | По умолчанию |
|---|---|---|
| `q` | поиск по названию/исполнителю/альбому/жанру | — |
| `genre` | точный жанр | — |
| `artist` | точный исполнитель | — |
| `album` | точный альбом | — |
| `externalSource` | `youtube`, `spotify`, `yandex`, `jamendo`, `archive`, `fma`, `local`, `manual` | — |
| `externalId` | id во внешнем источнике | — |
| `hasAudio` | `1` — только треки с загруженным аудио | — |
| `sort` | `recent`, `title`, `artist`, `plays`, `duration` | `recent` |
| `order` | `asc`, `desc` | `desc` |
| `limit` | 1–200 | `100` |
| `offset` | смещение | `0` |

```json
{
  "tracks": [ /* Track */ ],
  "total": 12,
  "limit": 100,
  "offset": 0
}
```

### `GET /api/tracks/:id`

```json
{ "track": { /* Track */ } }
```

### `PATCH /api/tracks/:id`

Только владелец загрузки/заглушки или администратор. Поля: `title`, `artist`, `albumArtist`,
`album`, `genre`, `year`, `trackNo`, `discNo`, `explicit`, `externalId`, `tags`, `colors`, `meta`, `color`.
`tags`, `colors`, `meta` проходят ту же санитизацию, что и импорт.

### `DELETE /api/tracks/:id`

Только владелец загрузки. Удаляет трек из плейлистов, лайков и истории. **204 No Content**.

### Загрузка трека

### `POST /api/tracks`

Тело — **сырые байты аудиофайла**, не multipart. Метаданные передаются в заголовках:

| Заголовок | Обязательно | Описание |
|---|---|---|
| `Content-Type` | да | `audio/mpeg`, `audio/flac`, `audio/wav`, `application/octet-stream` и т. п. |
| `X-File-Name` | желательно | Имя с расширением, определяет формат (`upload.mp3`) |
| `X-Upload-Metadata` | желательно | base64url без padding от JSON метаданных |

Поля JSON метаданных: `title`, `artist`, `album`, `genre`, `year`, `duration`, `filename`.
Если поля не переданы, сервер попробует ID3-теги и имя файла. Обложка генерируется автоматически.

Пример на Node.js:

```js
import { readFile } from 'node:fs/promises';

const token = process.env.SONA_TOKEN;
const metadata = Buffer.from(JSON.stringify({
  title: 'My Song',
  artist: 'Me',
  album: 'Home Recordings',
  genre: 'Indie',
  duration: 215,
  filename: 'my-song.mp3'
})).toString('base64url');

const response = await fetch('http://localhost:8765/api/tracks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'audio/mpeg',
    'X-File-Name': 'my-song.mp3',
    'X-Upload-Metadata': metadata
  },
  body: await readFile('my-song.mp3')
});

console.log(await response.json());
```

**201 Created**

```json
{ "track": { "id": "trk_...", "title": "My Song", "coverUrl": "/api/covers/trk_....svg", "...": "..." } }
```

Ошибки: `413` — превышен `MAX_UPLOAD_MB`, `415` — неподдерживаемый формат.

### `POST /api/tracks/:id/cover`

Сырые байты изображения. Поддерживаются JPEG, PNG, WEBP, GIF (до 10 МБ). Только владелец трека.

```bash
curl -X POST "http://localhost:8765/api/tracks/trk_x/cover" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: image/jpeg" \
  -H "X-File-Name: cover.jpg" \
  --data-binary @cover.jpg
```

**200 OK** — `{ "track": { /* обновлённый Track */ } }`.

### Стриминг

### `GET|HEAD /api/tracks/:id/stream`

Требует авторизации (cookie или Bearer). Поддерживает `Range: bytes=...`, отвечает `206 Partial Content`
и `Accept-Ranges: bytes`. Cookie позволяет использовать URL напрямую в `<audio src>`.

```bash
curl -H "Authorization: Bearer $TOKEN" \
     -H "Range: bytes=0-1023" \
     -D - -o /dev/null \
     http://localhost:8765/api/tracks/trk_neon_horizon/stream
```

Некорректный диапазон → `416`.

### Лайки и прослушивания

- `POST /api/tracks/:id/like` — тело `{ "liked": true }` или `{}` для переключения.
  Ответ: `{ "liked": true, "track": { ... } }`.
- `POST /api/tracks/:id/play` — увеличивает счётчик прослушиваний и пишет историю.
- `GET /api/likes` — `{ "tracks": [ ... ], "total": 4 }`.
- `POST /api/likes/:id` — идемпотентно добавляет трек в любимые.

### История

- `GET /api/history?limit=40` → `{ "items": [ /* Track + playedAt */ ], "total": 6 }`
- `POST /api/history` — тело `{ "trackId": "trk_x" }`.
- `DELETE /api/history/clear` — очищает историю.

---

## Альбомы и исполнители

Альбомы и исполнители не хранятся отдельно, а собираются из треков по полям `album` и `artist`.

- `GET /api/albums?limit=50` → `{ "albums": [ Album ], "total": 3 }`
- `GET /api/albums/:id` → `{ "album": Album }`
- `GET /api/artists?limit=50` → `{ "artists": [ Artist ], "total": 3 }`
- `GET /api/artists/:id` → `{ "artist": Artist }`

### Объект Album

```json
{
  "id": "alb_e9191469b289",
  "title": "Midnight Transit",
  "artist": "The Paper Satellites",
  "albumArtist": "The Paper Satellites",
  "artistId": "art_...",
  "coverUrl": "/api/covers/midnight-transit.svg",
  "color": "#8c8cff",
  "year": 2026,
  "genre": "Indie Electronic",
  "trackCount": 4,
  "duration": 187,
  "tracks": [ /* Track */ ],
  "createdAt": "..."
}
```

### Объект Artist

```json
{
  "id": "art_...",
  "name": "The Paper Satellites",
  "coverUrl": "...",
  "trackCount": 4,
  "albumCount": 1,
  "monthlyListeners": 4200,
  "tracks": [ /* Track */ ],
  "albums": [ /* Album */ ]
}
```

---

## Плейлисты

- `GET /api/playlists?scope=all` — свои и публичные плейлисты.
- `GET /api/playlists?scope=mine` — только свои.
- `GET /api/playlists/:id` — плейлист с массивом `tracks`. Публичный плейлист доступен без
  авторизации (удобно для ссылки `/playlist/:id`), поэтому приватный чужой плейлист → `403`
  для авторизованного пользователя и `404` для анонимного запроса.
- `POST /api/playlists` — создать:

```json
{
  "name": "Ночная поездка",
  "description": "Для окна вниз",
  "isPublic": false,
  "color": "#e8383d",
  "tags": ["spotify"],
  "meta": { "source": "spotify", "sourceId": "37i9...", "owner": "Spotify" }
}
```

- `PATCH /api/playlists/:id` — изменить `name`, `description`, `isPublic`, `color`, `tags`, `meta`
  (только владелец).
- `DELETE /api/playlists/:id` — удалить (только владелец), **204**; загруженная обложка удаляется с диска.
- `POST /api/playlists/:id/cover` — загрузить обложку плейлиста (только владелец).
  Тело — сырые байты JPEG/PNG/WEBP/GIF, заголовок `X-File-Name` с расширением:

```bash
curl -X POST http://localhost:8765/api/playlists/pl_x/cover \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: image/jpeg" \
  -H "X-File-Name: playlist.jpg" \
  --data-binary @playlist.jpg
```
- `POST /api/playlists/:id/tracks` — добавить трек:

```json
{ "trackId": "trk_neon_horizon", "position": "start" }
```

`position` необязателен; по умолчанию — в конец.
- `PATCH /api/playlists/:id/tracks` — изменить порядок:

```json
{ "trackIds": ["trk_neon_horizon", "trk_chrome_sunset"] }
```

- `DELETE /api/playlists/:id/tracks/:trackId` — удалить трек из плейлиста.

### Объект Playlist

```json
{
  "id": "pl_...",
  "name": "Ночная поездка",
  "description": "Синтвейв и огни города",
  "isPublic": true,
  "owner": { "id": "usr_demo", "name": "Алекс" },
  "trackCount": 6,
  "duration": 266,
  "coverUrl": "/api/covers/neon-horizon.svg",
  "color": "#ff5d8f",
  "tags": ["spotify", "synced"],
  "meta": { "source": "spotify", "sourceId": "37i9...", "snapshotId": "..." },
  "createdAt": "...",
  "updatedAt": "...",
  "tracks": [ /* Track; только при GET по id */ ]
}
```

---

## Импорт метаданных и заглушки

Sona не скачивает аудио из внешних сервисов. Импорт создаёт **карточки-заглушки**
с метаданными и ссылкой на источник; позже владелец может загрузить свой аудиофайл.

Поддерживаемые источники:

- **YouTube** — официальный oEmbed без ключа, либо YouTube Data API при наличии `YOUTUBE_API_KEY`
  (длительность, описание). Ссылки на отдельные видео.
- **Spotify** — официальный Web API при наличии `SPOTIFY_CLIENT_ID` и `SPOTIFY_CLIENT_SECRET`.
  Ссылки на трек, альбом или плейлист.
- **Яндекс Музыка** — публичного API нет, поэтому метаданные заполняются вручную.
  Ссылка сохраняется как источник.
- **Jamendo, Internet Archive, FMA, локальные файлы** — через `/api/import/manual` из
  сервиса-компаньона (`provider`: `jamendo`, `archive`, `fma`, `local`). Sona хранит метаданные
  и источник; аудио загружается только в разрешённых случаях и только владельцем/админом.

### `GET /api/import/providers`

Возвращает доступность источников и режим работы (без секретов).

### `POST /api/import/preview`

```json
{ "url": "https://open.spotify.com/album/..." }
```

**200 OK**

```json
{
  "token": "временный-токен-импорта",
  "preview": {
    "provider": "spotify",
    "mode": "api",
    "kind": "album",
    "title": "Название",
    "subtitle": "Исполнитель",
    "sourceUrl": "https://open.spotify.com/album/...",
    "coverRemote": "https://i.scdn.co/...",
    "manualRequired": false,
    "entries": [
      {
        "title": "Трек",
        "artist": "Исполнитель",
        "album": "Альбом",
        "genre": "Spotify",
        "year": 2026,
        "duration": 214,
        "coverRemote": null,
        "externalSource": "spotify",
        "externalUrl": "https://open.spotify.com/track/..."
      }
    ]
  }
}
```

Для Яндекса `manualRequired: true`, `entries: []` — используйте `/api/import/manual`.
Токен живёт 30 минут и привязан к пользователю.

### `POST /api/import/commit`

```json
{ "token": "временный-токен-импорта", "indices": [0, 1, 2] }
```

Создаёт выбранные заглушки. Дубликаты по источнику и external id пропускаются.

```json
{
  "tracks": [ { "id": "trk_...", "placeholder": true, "playable": false } ],
  "skipped": 0
}
```

### `POST /api/import/manual`

Ручная заглушка с обогащёнными метаданными. Подходит для fetcher и Яндекс Музыки:

```json
{
  "provider": "spotify",
  "externalId": "6OmhkSOpvYBokMKQxpIGx2",
  "url": "https://open.spotify.com/track/6OmhkSOpvYBokMKQxpIGx2",
  "title": "Global Warming (feat. Sensato)",
  "artist": "Pitbull, Sensato",
  "albumArtist": "Pitbull",
  "album": "Global Warming",
  "genre": "dance-pop",
  "year": 2012,
  "trackNo": 1,
  "discNo": 1,
  "duration": 85,
  "explicit": true,
  "tags": ["dance-pop", "hip hop", "latin"],
  "colors": {
    "dominant": "#9e0e0e",
    "accent": "#e8383d",
    "background": "#610000",
    "text": "#ffffff",
    "palette": ["#9e0e0e", "#1b1b1b"],
    "source": "spotify"
  },
  "meta": {
    "isrc": "USRC11201999",
    "label": "RCA Records",
    "releaseDate": "2012-11-16",
    "genres": ["dance-pop"],
    "license": { "name": "CC BY", "url": "https://creativecommons.org/licenses/by/4.0/" },
    "collection": { "kind": "album", "title": "Global Warming" }
  },
  "description": "Необязательно"
}
```

`tags`: до 40 уникальных строк по 60 символов. `colors`: только `#rrggbb`, палитра до 8 цветов.
`meta`: ключи `[A-Za-z0-9_.:-]`, вложенность до 3, не больше 16 КБ — иначе `413`.

**201 Created** — `{ "track": Track }`.

Дубликат по `provider + externalId` или `provider + url`:

```json
{
  "error": {
    "status": 409,
    "message": "Заглушка с этим источником уже есть в библиотеке",
    "details": { "trackId": "trk_..." }
  }
}
```

### `POST /api/tracks/:id/audio`

Загрузить аудио в существующую заглушку (сырые байты, как при обычной загрузке).
Только владелец заглушки или администратор. Заголовки: `Content-Type: audio/...`,
`X-File-Name`, опционально `X-Upload-Metadata`. После загрузки `placeholder` становится `false`.

```bash
curl -X POST http://localhost:8765/api/tracks/trk_x/audio \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: audio/mpeg" \
  -H "X-File-Name: track.mp3" \
  --data-binary @track.mp3
```

Плейсхолдеры нельзя воспроизводить: `GET /stream` и запись в историю возвращают `409`.

### Разделение длинного аудио на треки

Работает для трека с загруженным аудио. Нужен `ffmpeg` и `ffprobe` на сервере.
Доступно владельцу трека и администратору.

#### `GET /api/tracks/:id/chapters`

Возвращает длительность и главы файла:

```json
{
  "duration": 2450,
  "chapters": [
    { "start": 0, "end": 245, "title": "Intro" },
    { "start": 245, "end": 512, "title": "Track 2" }
  ]
}
```

#### `POST /api/tracks/:id/detect-splits`

Ищет паузы через `ffmpeg silencedetect`:

```json
{ "thresholdDb": -35, "minSilence": 1.2 }
```

**200 OK**

```json
{
  "duration": 2450,
  "segments": [
    { "start": 0, "end": 244.7, "title": "Трек 1" },
    { "start": 244.7, "end": 511.2, "title": "Трек 2" }
  ]
}
```

Если пауз не найдено — `422`.

#### `POST /api/tracks/:id/split`

```json
{
  "album": "Название альбома",
  "albumArtist": "Исполнитель",
  "artist": "Исполнитель",
  "genre": "Pop",
  "year": 2026,
  "deleteOriginal": false,
  "segments": [
    { "start": 0, "title": "Track 1" },
    { "start": 245, "title": "Track 2" }
  ]
}
```

- минимум 2 сегмента, максимум 100;
- первый сегмент должен начинаться с 0:00;
- последний заканчивается на длительности файла;
- каждый сегмент не короче 1 секунды.

**201 Created**

```json
{
  "tracks": [ { "id": "trk_...", "title": "Track 1", "trackNo": 1 } ],
  "albumId": "alb_...",
  "originalDeleted": false
}
```

Аудио перекодируется в MP3 192 kbps, обложка и расширенные метаданные наследуются.
При `deleteOriginal: true` исходный трек удаляется из библиотеки, плейлистов, любимых и истории.

### Переменные окружения импорта

```env
YOUTUBE_API_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
```

## Поиск

### `GET /api/search?q=neon`

```json
{
  "query": "neon",
  "topResult": { "type": "track", "item": { /* Track */ } },
  "tracks": [ /* до 30 */ ],
  "albums": [ /* до 12 */ ],
  "artists": [ /* до 12 */ ],
  "playlists": [ /* до 12, доступные пользователю */ ]
}
```

`topResult.type` — `track`, `album`, `artist` или `playlist`.

---

## Обложки

### `GET /api/covers/:name`

Публично. Отдаёт SVG-обложку из `seed/covers/` или загруженную/сгенерированную из `storage/covers/`.
Поддерживает `If-None-Match` и `HEAD`.

---

## Объект Track

```json
{
  "id": "trk_neon_horizon",
  "title": "Neon Horizon",
  "artist": "Aurora Lane",
  "artistId": "art_...",
  "albumArtist": "Aurora Lane",
  "album": "Neon Horizon",
  "albumId": "alb_...",
  "genre": "Synthwave",
  "year": 2026,
  "trackNo": 1,
  "discNo": 1,
  "duration": 46,
  "coverUrl": "/api/covers/neon-horizon.svg",
  "color": "#ff5d8f",
  "plays": 4213,
  "liked": true,
  "explicit": false,
  "uploadedBy": "usr_demo",
  "canDelete": true,
  "createdAt": "2026-09-17T12:00:00.000Z",
  "placeholder": false,
  "playable": true,
  "externalUrl": null,
  "externalSource": null,
  "externalId": null,
  "tags": [],
  "colors": null,
  "meta": null,
  "description": null,
  "streamUrl": "/api/tracks/trk_neon_horizon/stream"
}
```

Для заглушки: `placeholder: true`, `playable: false`, `streamUrl: null`,
`externalUrl` и `externalSource` заполнены. `canDelete` вычисляется для текущего пользователя;
`liked` также зависит от текущего пользователя.

---

## Служебные эндпоинты

- `GET /api/health` — статус, версия, uptime, доступность регистрации/демо и размеры каталога.
- `GET /api` — краткий список эндпоинтов.
- `GET /api/docs` — эта документация.
- `GET /api/openapi.json` — OpenAPI 3.1.

---

## Для мобильного/desktop-приложения

1. Получите токен через `/api/auth/login` или `/api/auth/register` и храните его в защищённом хранилище.
2. Передавайте `Authorization: Bearer <token>`.
3. Для воспроизведения скачивайте поток с Range-запросами и передавайте его в свой аудиоплеер.
   `streamUrl` уже содержит нужный путь; заголовок авторизации обязателен.
4. Обложки публичны, их можно кешировать.

CORS по умолчанию не включён: SPA работает на том же origin. Нативным приложениям CORS не нужен.
Если делаете сторонний веб-клиент, поставьте Sona за reverse-proxy с нужной CORS-политикой.

## Ограничения и рекомендации

- Лимит загрузки — `MAX_UPLOAD_MB` (по умолчанию 200 МБ).
- Параллельные записи в JSON-базу сериализуются внутри процесса; для нескольких инстансов нужна общая БД.
- Периодически делайте backup `storage/data/db.json` и каталогов загрузок.
- Для HTTPS включите `COOKIE_SECURE=1`.
