From 60a68b420e07a01dfdaa9de0727623db588ac317 Mon Sep 17 00:00:00 2001 From: AndreyDanyliuk Date: Mon, 5 Oct 2026 20:38:28 +0200 Subject: [PATCH] Initial commit --- .dockerignore | 11 + .env.dev.example | 43 + .env.example | 56 + .gitignore | 10 + README.md | 198 + apps/server/Dockerfile | 38 + apps/server/Dockerfile.dev | 11 + apps/server/drizzle.config.ts | 12 + apps/server/drizzle/0000_clever_famine.sql | 174 + .../drizzle/0001_colossal_roxanne_simpson.sql | 1 + .../server/drizzle/0002_red_susan_delgado.sql | 14 + apps/server/drizzle/meta/0000_snapshot.json | 1408 ++++ apps/server/drizzle/meta/0001_snapshot.json | 1393 +++ apps/server/drizzle/meta/0002_snapshot.json | 1483 ++++ apps/server/drizzle/meta/_journal.json | 27 + apps/server/package.json | 49 + apps/server/src/db/index.ts | 13 + apps/server/src/db/migrate.ts | 38 + apps/server/src/db/queries.ts | 10 + apps/server/src/db/schema.ts | 434 + apps/server/src/db/seed.ts | 40 + apps/server/src/env.ts | 56 + apps/server/src/express.d.ts | 13 + apps/server/src/index.ts | 93 + apps/server/src/lib/http.ts | 49 + apps/server/src/lib/logger.ts | 13 + apps/server/src/lib/metadata/acoustid.ts | 67 + apps/server/src/lib/metadata/coverart.ts | 35 + apps/server/src/lib/metadata/index.ts | 132 + apps/server/src/lib/metadata/lrclib.ts | 108 + apps/server/src/lib/metadata/musicbrainz.ts | 142 + apps/server/src/lib/metadata/tags.ts | 27 + apps/server/src/lib/metadata/types.ts | 76 + apps/server/src/lib/metadata/wikipedia.ts | 49 + apps/server/src/lib/password.ts | 9 + apps/server/src/lib/permissions.ts | 27 + apps/server/src/lib/presenters.ts | 181 + apps/server/src/lib/queue.ts | 33 + apps/server/src/lib/redis.ts | 21 + apps/server/src/lib/session.ts | 70 + apps/server/src/lib/storage.ts | 81 + apps/server/src/middleware/auth.ts | 43 + apps/server/src/routes/admin.ts | 324 + apps/server/src/routes/albums.ts | 60 + apps/server/src/routes/artists.ts | 142 + apps/server/src/routes/auth.ts | 68 + apps/server/src/routes/comments.ts | 132 + apps/server/src/routes/index.ts | 30 + apps/server/src/routes/library.ts | 80 + apps/server/src/routes/me.ts | 89 + apps/server/src/routes/media.ts | 28 + apps/server/src/routes/playlists.ts | 450 + apps/server/src/routes/search.ts | 68 + apps/server/src/routes/tracks.ts | 311 + apps/server/src/routes/uploads.ts | 207 + apps/server/src/routes/users.ts | 72 + apps/server/src/services/access.ts | 67 + apps/server/src/services/trackIngest.ts | 235 + apps/server/src/workers/metadata.worker.ts | 34 + apps/server/tsconfig.json | 13 + apps/web/Dockerfile.dev | 8 + apps/web/index.html | 19 + apps/web/package.json | 32 + apps/web/postcss.config.js | 6 + apps/web/src/App.tsx | 66 + .../web/src/components/AddToPlaylistModal.tsx | 80 + apps/web/src/components/ArtistLinks.tsx | 37 + apps/web/src/components/Comments.tsx | 168 + apps/web/src/components/Cover.tsx | 25 + .../src/components/CreatePlaylistModal.tsx | 83 + apps/web/src/components/Layout.tsx | 176 + apps/web/src/components/LyricsView.tsx | 78 + apps/web/src/components/Modal.tsx | 39 + apps/web/src/components/Player.tsx | 655 ++ apps/web/src/components/PlaylistCard.tsx | 21 + apps/web/src/components/QueuePanel.tsx | 121 + apps/web/src/components/ShareButton.tsx | 22 + apps/web/src/components/TrackRow.tsx | 165 + apps/web/src/index.css | 82 + apps/web/src/lib/api.ts | 209 + apps/web/src/lib/format.ts | 35 + apps/web/src/lib/permissions.ts | 16 + apps/web/src/lib/types.ts | 130 + apps/web/src/main.tsx | 29 + apps/web/src/pages/Account.tsx | 190 + apps/web/src/pages/AddTrack.tsx | 262 + apps/web/src/pages/Admin.tsx | 652 ++ apps/web/src/pages/AlbumPage.tsx | 58 + apps/web/src/pages/ArtistPage.tsx | 95 + apps/web/src/pages/Home.tsx | 59 + apps/web/src/pages/Library.tsx | 138 + apps/web/src/pages/Login.tsx | 76 + apps/web/src/pages/PlaylistPage.tsx | 365 + apps/web/src/pages/SearchPage.tsx | 140 + apps/web/src/pages/TrackPage.tsx | 108 + apps/web/src/pages/UserPage.tsx | 62 + apps/web/src/store/auth.tsx | 57 + apps/web/src/store/player.tsx | 170 + apps/web/tailwind.config.js | 35 + apps/web/tsconfig.json | 22 + apps/web/tsconfig.tsbuildinfo | 1 + apps/web/vite.config.ts | 22 + docker-compose.dev.yml | 120 + docker-compose.yml | 88 + package-lock.json | 7453 +++++++++++++++++ package.json | 27 + tsconfig.base.json | 16 + 107 files changed, 21716 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.dev.example create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/server/Dockerfile create mode 100644 apps/server/Dockerfile.dev create mode 100644 apps/server/drizzle.config.ts create mode 100644 apps/server/drizzle/0000_clever_famine.sql create mode 100644 apps/server/drizzle/0001_colossal_roxanne_simpson.sql create mode 100644 apps/server/drizzle/0002_red_susan_delgado.sql create mode 100644 apps/server/drizzle/meta/0000_snapshot.json create mode 100644 apps/server/drizzle/meta/0001_snapshot.json create mode 100644 apps/server/drizzle/meta/0002_snapshot.json create mode 100644 apps/server/drizzle/meta/_journal.json create mode 100644 apps/server/package.json create mode 100644 apps/server/src/db/index.ts create mode 100644 apps/server/src/db/migrate.ts create mode 100644 apps/server/src/db/queries.ts create mode 100644 apps/server/src/db/schema.ts create mode 100644 apps/server/src/db/seed.ts create mode 100644 apps/server/src/env.ts create mode 100644 apps/server/src/express.d.ts create mode 100644 apps/server/src/index.ts create mode 100644 apps/server/src/lib/http.ts create mode 100644 apps/server/src/lib/logger.ts create mode 100644 apps/server/src/lib/metadata/acoustid.ts create mode 100644 apps/server/src/lib/metadata/coverart.ts create mode 100644 apps/server/src/lib/metadata/index.ts create mode 100644 apps/server/src/lib/metadata/lrclib.ts create mode 100644 apps/server/src/lib/metadata/musicbrainz.ts create mode 100644 apps/server/src/lib/metadata/tags.ts create mode 100644 apps/server/src/lib/metadata/types.ts create mode 100644 apps/server/src/lib/metadata/wikipedia.ts create mode 100644 apps/server/src/lib/password.ts create mode 100644 apps/server/src/lib/permissions.ts create mode 100644 apps/server/src/lib/presenters.ts create mode 100644 apps/server/src/lib/queue.ts create mode 100644 apps/server/src/lib/redis.ts create mode 100644 apps/server/src/lib/session.ts create mode 100644 apps/server/src/lib/storage.ts create mode 100644 apps/server/src/middleware/auth.ts create mode 100644 apps/server/src/routes/admin.ts create mode 100644 apps/server/src/routes/albums.ts create mode 100644 apps/server/src/routes/artists.ts create mode 100644 apps/server/src/routes/auth.ts create mode 100644 apps/server/src/routes/comments.ts create mode 100644 apps/server/src/routes/index.ts create mode 100644 apps/server/src/routes/library.ts create mode 100644 apps/server/src/routes/me.ts create mode 100644 apps/server/src/routes/media.ts create mode 100644 apps/server/src/routes/playlists.ts create mode 100644 apps/server/src/routes/search.ts create mode 100644 apps/server/src/routes/tracks.ts create mode 100644 apps/server/src/routes/uploads.ts create mode 100644 apps/server/src/routes/users.ts create mode 100644 apps/server/src/services/access.ts create mode 100644 apps/server/src/services/trackIngest.ts create mode 100644 apps/server/src/workers/metadata.worker.ts create mode 100644 apps/server/tsconfig.json create mode 100644 apps/web/Dockerfile.dev create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/AddToPlaylistModal.tsx create mode 100644 apps/web/src/components/ArtistLinks.tsx create mode 100644 apps/web/src/components/Comments.tsx create mode 100644 apps/web/src/components/Cover.tsx create mode 100644 apps/web/src/components/CreatePlaylistModal.tsx create mode 100644 apps/web/src/components/Layout.tsx create mode 100644 apps/web/src/components/LyricsView.tsx create mode 100644 apps/web/src/components/Modal.tsx create mode 100644 apps/web/src/components/Player.tsx create mode 100644 apps/web/src/components/PlaylistCard.tsx create mode 100644 apps/web/src/components/QueuePanel.tsx create mode 100644 apps/web/src/components/ShareButton.tsx create mode 100644 apps/web/src/components/TrackRow.tsx create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/permissions.ts create mode 100644 apps/web/src/lib/types.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/Account.tsx create mode 100644 apps/web/src/pages/AddTrack.tsx create mode 100644 apps/web/src/pages/Admin.tsx create mode 100644 apps/web/src/pages/AlbumPage.tsx create mode 100644 apps/web/src/pages/ArtistPage.tsx create mode 100644 apps/web/src/pages/Home.tsx create mode 100644 apps/web/src/pages/Library.tsx create mode 100644 apps/web/src/pages/Login.tsx create mode 100644 apps/web/src/pages/PlaylistPage.tsx create mode 100644 apps/web/src/pages/SearchPage.tsx create mode 100644 apps/web/src/pages/TrackPage.tsx create mode 100644 apps/web/src/pages/UserPage.tsx create mode 100644 apps/web/src/store/auth.tsx create mode 100644 apps/web/src/store/player.tsx create mode 100644 apps/web/tailwind.config.js create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.tsbuildinfo create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 tsconfig.base.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cfc4924 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +**/node_modules +**/dist +**/build +**/.git +**/.env +**/.env.* +!**/.env.example +!**/.env.dev.example +**/*.log +**/.DS_Store +**/coverage diff --git a/.env.dev.example b/.env.dev.example new file mode 100644 index 0000000..f5fd7b2 --- /dev/null +++ b/.env.dev.example @@ -0,0 +1,43 @@ +# Dev compose (hot reload). Same vars as .env.example, but service hostnames. +NODE_ENV=development +PORT=3001 +APP_URL=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173 + +SESSION_SECRET=dev-secret-change-me-please-32chars-min +SESSION_TTL_DAYS=30 + +POSTGRES_USER=adc +POSTGRES_PASSWORD=adc +POSTGRES_DB=adc +DATABASE_URL=postgres://adc:adc@postgres:5432/adc + +REDIS_URL=redis://redis:6379 + +# Object storage image. Upstream minio/minio was removed from Docker Hub; use a fork. +# NOTE: compose reads this for ${...} substitution only via the .env file or --env-file, +# NOT from env_file. Run: docker compose --env-file .env.dev -f docker-compose.dev.yml up +MINIO_IMAGE=firstfinger/minio:latest +# Leave EMPTY to use the image's own entrypoint/start script (firstfinger/minio). +# For stock minio/minio set: server /data --console-address ":9001" +MINIO_COMMAND= +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_ENDPOINT=minio +MINIO_PORT=9000 +MINIO_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=adc-retivo +MINIO_PUBLIC_URL=http://localhost:9000 + +# Vite dev-server proxy target (inside the dev compose network) +VITE_PROXY_TARGET=http://server:3001 + +MB_USER_AGENT=ADCRetivo/1.0 ( https://example.com contact:admin@example.com ) +ACOUSTID_API_KEY= +FPCALC_PATH=fpcalc + +ADMIN_EMAIL=admin@adc.retivo +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..56e826b --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# ================= ADC Retivo ================= +# Copy to .env and adjust. Used by docker-compose.yml (prod-like). + +# --- App --- +NODE_ENV=production +PORT=3000 +# Public URL of the app (used for share links, cookie domain awareness) +APP_URL=http://localhost:3000 +# Comma-separated list of extra allowed origins for CORS (optional in prod, same-origin) +CORS_ORIGINS= + +# --- Security --- +SESSION_SECRET=change-me-to-a-long-random-string-at-least-32-chars +SESSION_TTL_DAYS=30 + +# --- Postgres --- +POSTGRES_USER=adc +POSTGRES_PASSWORD=adc +POSTGRES_DB=adc +DATABASE_URL=postgres://adc:adc@postgres:5432/adc + +# --- Redis --- +REDIS_URL=redis://redis:6379 + +# --- Object storage (S3/MinIO) --- +# NOTE: the upstream minio/minio image is no longer published on Docker Hub. +# Use a compatible fork and set its image here. This value is read by compose +# for ${...} substitution (from .env / --env-file), not from the container env_file. +MINIO_IMAGE=firstfinger/minio:latest +# Command passed to the storage container. Leave EMPTY for images that ship their +# own entrypoint/start script (like firstfinger/minio). For stock minio/minio use: +# server /data --console-address ":9001" +MINIO_COMMAND= +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=minioadmin +MINIO_ENDPOINT=minio +MINIO_PORT=9000 +MINIO_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET=adc-retivo +# URL the *browser* uses for direct/presigned access (optional; streaming is proxied through the API by default) +MINIO_PUBLIC_URL=http://localhost:9000 + +# --- Metadata providers --- +# MusicBrainz requires a descriptive User-Agent with contact info. +MB_USER_AGENT=ADCRetivo/1.0 ( https://github.com/adc-retivo contact:admin@example.com ) +# AcoustID application API key (free: https://acoustid.org/new-application). Leave empty to disable fingerprint lookup. +ACOUSTID_API_KEY= +# Optional path to fpcalc binary (chromaprint). Defaults to "fpcalc". +FPCALC_PATH=fpcalc + +# --- First admin (created on first migration if no users exist) --- +ADMIN_EMAIL=admin@adc.retivo +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7208220 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +build +coverage +.env +.env.dev +.env.local +*.log +.DS_Store +apps/web/dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..a377a98 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# ADC Retivo + +Самохостинговая альтернатива Spotify: загрузка своей музыки, автоматическое обогащение +метаданных из открытых источников, модерация, публичные/приватные плейлисты, подписки, +лайки, каскадные комментарии, общий поиск и постоянный плеер с очередью и текстами песен. + +Стек: + +- **Backend:** Node.js + Express + TypeScript, Drizzle ORM (PostgreSQL) +- **Frontend:** React + Vite + TypeScript + Tailwind (раздаётся Express-ом в проде) +- **Инфраструктура:** PostgreSQL, Redis (сессии + очередь BullMQ), MinIO (S3) +- **Метаданные:** MusicBrainz, AcoustID (Chromaprint/fpcalc), Cover Art Archive, LRCLIB, Wikipedia + +--- + +## Возможности + +- Вход только по аккаунту (экран логина для гостей). **Регистрация — из админ-панели.** +- Первый супер-админ создаётся автоматически из переменных окружения. +- Гибкие разрешения на пользователя: создание плейлистов, загрузка треков, автопринятие, + модерация, управление пользователями/библиотекой, просмотр всех плейлистов. +- Загрузка треков: определение дубликатов (SHA-256), сохранение в S3, асинхронный сбор + метаданных (название, исполнитель, альбом, обложка, дата, текст с таймкодами), модерация. +- Несколько исполнителей на трек: `A, B`, `A feat. B`, `A & B` разбираются в отдельные кредиты + (`main`/`featured`) со ссылками на страницы каждого артиста; редактируется в админке. +- Плейлисты: публичные / по ссылке / приватные; добавление в библиотеку **ссылкой или копией**; + соавторы; настройка «разрешить редактирование из библиотеки». +- Подписки на артистов, лайки треков, каскадные комментарии с ответами. +- Общий поиск: треки, артисты, альбомы, публичные плейлисты, пользователи. +- Плеер не прерывается при переходах: мини-бар и развёрнутый вид, очередь с + редактированием и перемешиванием, повтор, текст песни (синхронизированный). +- Системные медиа-контролы (Media Session): обложка, название, исполнители и переключение + треков с локскрина/OS-плеера. +- Публичные страницы артиста/альбома/трека/пользователя/плейлиста + кнопка «Поделиться». +- Адаптивный минималистичный интерфейс (blur/glass) на русском. + +--- + +## Быстрый старт (Docker, dev) + +```bash +cp .env.dev.example .env.dev +docker compose --env-file .env.dev -f docker-compose.dev.yml up --build +``` + +> Почему `--env-file`: Docker Compose использует `.env.dev` двумя разными путями. +> `env_file` в сервисах кладёт переменные **внутрь контейнеров**, но подстановка +> `${VAR}` в самом compose-файле читается только из `.env` (или из файла, +> указанного через `--env-file`). Чтобы `MINIO_IMAGE`/`MINIO_COMMAND`/порты точно +> подхватились, передавайте файл явно, как выше. + +- Frontend (Vite, hot reload): http://localhost:5173 +- Backend API: http://localhost:3001/api +- MinIO Console: http://localhost:9001 + +Первый админ: `admin@adc.retivo` / `admin123` (задаётся в `.env.dev`, смените после входа). + +## Быстрый старт (Docker, prod) + +```bash +cp .env.example .env +# обязательно смените SESSION_SECRET, пароли и ADMIN_PASSWORD +docker compose up --build -d +``` + +Приложение (API + собранный SPA) будет доступно на http://localhost:3000. + +## Локальная разработка без Docker + +Нужны локальные PostgreSQL, Redis и S3-совместимое хранилище (MinIO). + +```bash +npm install +cp .env.example .env # укажите localhost-хосты и порты +npm run db:migrate -w @adc-retivo/server +npm run db:seed -w @adc-retivo/server +npm run dev # server (3001) + web (5173) +``` + +> Перед сборкой прод-образов нужно сгенерировать миграции: `npm run db:generate -w @adc-retivo/server`. + +--- + +## Переменные окружения + +См. `.env.example` (prod) и `.env.dev.example` (dev). Ключевые: + +| Переменная | Назначение | +| --- | --- | +| `DATABASE_URL` | строка подключения к PostgreSQL | +| `REDIS_URL` | подключение к Redis (сессии, очередь) | +| `MINIO_*` | доступ к S3/MinIO (endpoint, ключи, bucket) | +| `MINIO_IMAGE` | docker-образ объектного хранилища (upstream `minio/minio` удалён из Docker Hub — укажите форк) | +| `MINIO_COMMAND` | команда контейнера хранилища. Пусто = использовать entrypoint образа (форк). Для stock: `server /data --console-address ":9001"` | +| `SESSION_SECRET` | секрет для cookie-сессий | +| `MB_USER_AGENT` | описательный User-Agent для MusicBrainz (с контактом) | +| `ACOUSTID_API_KEY` | ключ AcoustID (необязательно; без него fingerprint пропускается) | +| `FPCALC_PATH` | путь к `fpcalc` из Chromaprint (по умолчанию `fpcalc`) | +| `ADMIN_*` | данные первого супер-админа | + +--- + +## Как работает конвейер загрузки + +1. `POST /api/uploads` (multipart) → SHA-256, поиск дубликата по хэшу. +2. Файл кладётся в S3 (`tracks/...`), создаётся запись трека (`pending` либо `approved`, если + у автора есть `canAutoApprove`), задача ставится в очередь BullMQ. +3. Воркер: + - читает теги (`music-metadata`) и встроенную обложку; + - считает аудио-отпечаток (`fpcalc`) и спрашивает AcoustID; + - тянет канонические данные из MusicBrainz, обложку из Cover Art Archive; + - берёт биографию/фото артиста из Википедии; + - находит текст и синхронизацию из LRCLIB (LRC → таймкоды); + - создаёт/обновляет артиста и альбом, сохраняет метаданные в трек. +4. Если автопринятие выключено — трек ждёт модерации в админ-панели. + +Сетевые источники необязательны: при ошибке/404 соответствующее поле просто остаётся пустым. + +--- + +## Основные разделы API + +- `POST /api/auth/login|logout`, `GET /api/auth/session|me` +- `GET/PATCH /api/me`, `POST /api/me/password`, `POST /api/me/avatar` +- `GET /api/tracks`, `/api/tracks/random`, `/api/tracks/:id`, `/api/tracks/:id/stream` (Range), + `/api/tracks/:id/lyrics`, `/api/tracks/:id/comments`, `/api/tracks/:id/like` +- `POST /api/uploads`, `GET /api/uploads/mine`, `POST /api/uploads/:id/retry` +- `GET /api/artists/:id`, `/api/albums/:id`, `/api/playlists/:id`, `/api/library`, `/api/search` +- `POST/PATCH/DELETE /api/playlists`, `/api/playlists/:id/tracks`, `/order`, `/library`, `/collaborators` +- `GET /api/admin/stats|users|moderation|tracks|playlists` +- `POST /api/admin/users`, `PATCH/DELETE /api/admin/users/:id` +- `POST /api/admin/tracks/:id/approve|reject` + +--- + +## Структура проекта + +``` +adc_retivo/ +├─ apps/ +│ ├─ server/ # Express API + воркер метаданных + раздача SPA +│ │ ├─ src/ +│ │ │ ├─ db/ # схема Drizzle, миграции, seed +│ │ │ ├─ lib/ # env, redis, session, storage, queue, metadata/*, presenters +│ │ │ ├─ middleware/ # auth/permissions +│ │ │ ├─ routes/ # REST-роуты +│ │ │ ├─ services/ # ingest, доступ к плейлистам +│ │ │ └─ workers/ # BullMQ-воркер обогащения +│ │ ├─ drizzle/ # сгенерированные SQL-миграции +│ │ └─ Dockerfile(.dev) +│ └─ web/ # React SPA +│ └─ src/{components,pages,store,lib} +├─ docker-compose.yml # prod (app раздаёт SPA) +└─ docker-compose.dev.yml # dev (Vite + watcher) +``` + +## Полезные команды + +```bash +npm run typecheck # проверка типов обоих воркспейсов +npm run build # собрать web + server +npm run db:generate -w @adc-retivo/server # сгенерировать миграции из схемы +npm run db:migrate -w @adc-retivo/server # применить миграции +npm run db:seed -w @adc-retivo/server # создать первого админа (если база пуста) +``` + +## Частые проблемы + +**`exec: "server": executable file not found in $PATH` (MinIO).** +Upstream-образ `minio/minio` удалён из Docker Hub, поэтому вы используете форк. У многих форков +свой entrypoint (`start.sh`), который сам запускает сервер, и им НЕ нужно передавать +`server /data ...`. Решение: задайте `MINIO_IMAGE=<ваш-форк>` и `MINIO_COMMAND=` (пусто) — +тогда compose отдаёт `command: []` и образ запускается своим entrypoint. Для настоящего +stock-образа, наоборот, укажите `MINIO_COMMAND=server /data --console-address ":9001"`. + +**`.env.dev` «не подхватывается».** +Так и есть: `env_file` в сервисах кладёт переменные в контейнеры, но подстановка `${VAR}` +в compose-файле читается из `.env` (или из файла, переданного через `--env-file`). +Запускайте dev так: + +```bash +docker compose --env-file .env.dev -f docker-compose.dev.yml up --build +``` + +А в prod-режиме можно просто скопировать `.env.example` в `.env` — он читается автоматически. + +**Объектное хранилище недоступно / бакет не создаётся.** +Bucket создаётся самим приложением при старте (`ensureBucket`), отдельный `minio-init` не нужен. +Проверьте, что `MINIO_ENDPOINT/PORT/ключи` совпадают с сервисом хранилища. + +--- + +## Заметки по безопасности + +- Сессии хранятся в Redis, cookie `httpOnly` + `sameSite=lax` (+ `secure` в проде). +- Пароли — bcrypt. +- Стриминг аудио проксируется через API с проверкой доступа и поддержкой HTTP Range. +- Обязательно смените `SESSION_SECRET` и пароль первого админа перед публикацией. diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile new file mode 100644 index 0000000..7b8b929 --- /dev/null +++ b/apps/server/Dockerfile @@ -0,0 +1,38 @@ +# ---- Build stage ---- +FROM node:22-bookworm-slim AS build +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates python3 make g++ \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json* ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +COPY . . +RUN npm run build -w @adc-retivo/web +RUN npm run build -w @adc-retivo/server + +# ---- Prune prod deps ---- +FROM build AS prod-deps +RUN npm prune --omit=dev \ + && mkdir -p /app/apps/server/node_modules /app/apps/web/node_modules + +# ---- Runner stage ---- +FROM node:22-bookworm-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libchromaprint-tools ffmpeg \ + && rm -rf /var/lib/apt/lists/* +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=prod-deps /app/apps/server/node_modules ./apps/server/node_modules +COPY package.json ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +COPY --from=build /app/apps/server/dist ./apps/server/dist +COPY --from=build /app/apps/web/dist ./apps/web/dist +COPY apps/server/drizzle ./apps/server/drizzle +ENV SERVE_WEB_DIST=/app/apps/web/dist +WORKDIR /app/apps/server +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/apps/server/Dockerfile.dev b/apps/server/Dockerfile.dev new file mode 100644 index 0000000..72fe52d --- /dev/null +++ b/apps/server/Dockerfile.dev @@ -0,0 +1,11 @@ +FROM node:22-bookworm-slim +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates libchromaprint-tools ffmpeg \ + && rm -rf /var/lib/apt/lists/* +COPY package.json package-lock.json* ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +EXPOSE 3001 +CMD ["npm", "run", "dev", "-w", "@adc-retivo/server"] diff --git a/apps/server/drizzle.config.ts b/apps/server/drizzle.config.ts new file mode 100644 index 0000000..e39a2dc --- /dev/null +++ b/apps/server/drizzle.config.ts @@ -0,0 +1,12 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL ?? "postgres://adc:adc@localhost:5432/adc", + }, + strict: true, + verbose: true, +} satisfies Config; diff --git a/apps/server/drizzle/0000_clever_famine.sql b/apps/server/drizzle/0000_clever_famine.sql new file mode 100644 index 0000000..f60a69d --- /dev/null +++ b/apps/server/drizzle/0000_clever_famine.sql @@ -0,0 +1,174 @@ +CREATE TYPE "public"."library_mode" AS ENUM('reference', 'copy');--> statement-breakpoint +CREATE TYPE "public"."playlist_visibility" AS ENUM('public', 'unlisted', 'private');--> statement-breakpoint +CREATE TYPE "public"."track_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint +CREATE TABLE "albums" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" text NOT NULL, + "artist_id" uuid, + "mb_release_group_id" text, + "mb_release_id" text, + "release_date" text, + "cover_url" text, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "artist_follows" ( + "user_id" uuid NOT NULL, + "artist_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "artist_follows_user_id_artist_id_pk" PRIMARY KEY("user_id","artist_id") +); +--> statement-breakpoint +CREATE TABLE "artists" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "sort_name" text, + "mb_artist_id" text, + "description" text, + "image_url" text, + "country" text, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "comments" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "track_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "parent_id" uuid, + "body" text NOT NULL, + "deleted" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "library_playlists" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "playlist_id" uuid NOT NULL, + "mode" "library_mode" DEFAULT 'reference' NOT NULL, + "copied_playlist_id" uuid, + "can_edit" boolean DEFAULT false NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "playlist_collaborators" ( + "playlist_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "can_edit" boolean DEFAULT true NOT NULL, + CONSTRAINT "playlist_collaborators_playlist_id_user_id_pk" PRIMARY KEY("playlist_id","user_id") +); +--> statement-breakpoint +CREATE TABLE "playlist_tracks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "playlist_id" uuid NOT NULL, + "track_id" uuid NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + "added_by_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "playlists" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "owner_id" uuid NOT NULL, + "title" text NOT NULL, + "description" text, + "cover_url" text, + "visibility" "playlist_visibility" DEFAULT 'private' NOT NULL, + "is_shareable" boolean DEFAULT false NOT NULL, + "allow_collaborator_edit" boolean DEFAULT true NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "track_likes" ( + "user_id" uuid NOT NULL, + "track_id" uuid NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "track_likes_user_id_track_id_pk" PRIMARY KEY("user_id","track_id") +); +--> statement-breakpoint +CREATE TABLE "tracks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" text NOT NULL, + "artist_id" uuid, + "album_id" uuid, + "duration_ms" integer, + "track_number" integer, + "disc_number" integer, + "release_date" text, + "lyrics_plain" text, + "lyrics_synced" jsonb, + "lyrics_source" text, + "file_key" text NOT NULL, + "file_size" bigint, + "mime_type" text, + "uploaded_by_id" uuid, + "status" "track_status" DEFAULT 'pending' NOT NULL, + "rejection_reason" text, + "fingerprint" text, + "mb_recording_id" text, + "cover_url" text, + "play_count" integer DEFAULT 0 NOT NULL, + "metadata" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "approved_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "username" text NOT NULL, + "display_name" text, + "password_hash" text NOT NULL, + "avatar_url" text, + "bio" text, + "is_super_admin" boolean DEFAULT false NOT NULL, + "permissions" jsonb DEFAULT '{"canCreatePlaylists":true,"canUploadTracks":true,"canAutoApprove":false,"canModerate":false,"canManageUsers":false,"canManageLibrary":false,"canViewAllPlaylists":false}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "albums" ADD CONSTRAINT "albums_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "artist_follows" ADD CONSTRAINT "artist_follows_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "artist_follows" ADD CONSTRAINT "artist_follows_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "library_playlists" ADD CONSTRAINT "library_playlists_copied_playlist_id_playlists_id_fk" FOREIGN KEY ("copied_playlist_id") REFERENCES "public"."playlists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_collaborators" ADD CONSTRAINT "playlist_collaborators_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_collaborators" ADD CONSTRAINT "playlist_collaborators_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_playlist_id_playlists_id_fk" FOREIGN KEY ("playlist_id") REFERENCES "public"."playlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlist_tracks" ADD CONSTRAINT "playlist_tracks_added_by_id_users_id_fk" FOREIGN KEY ("added_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "playlists" ADD CONSTRAINT "playlists_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracks" ADD CONSTRAINT "tracks_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracks" ADD CONSTRAINT "tracks_album_id_albums_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."albums"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tracks" ADD CONSTRAINT "tracks_uploaded_by_id_users_id_fk" FOREIGN KEY ("uploaded_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "albums_title_idx" ON "albums" USING btree ("title");--> statement-breakpoint +CREATE UNIQUE INDEX "albums_mb_rg_idx" ON "albums" USING btree ("mb_release_group_id");--> statement-breakpoint +CREATE INDEX "artists_name_idx" ON "artists" USING btree ("name");--> statement-breakpoint +CREATE UNIQUE INDEX "artists_mb_idx" ON "artists" USING btree ("mb_artist_id");--> statement-breakpoint +CREATE INDEX "comments_track_idx" ON "comments" USING btree ("track_id","created_at");--> statement-breakpoint +CREATE INDEX "comments_parent_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint +CREATE UNIQUE INDEX "library_playlists_uniq" ON "library_playlists" USING btree ("user_id","playlist_id");--> statement-breakpoint +CREATE INDEX "library_playlists_user_idx" ON "library_playlists" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "playlist_tracks_playlist_idx" ON "playlist_tracks" USING btree ("playlist_id","position");--> statement-breakpoint +CREATE INDEX "playlist_tracks_track_idx" ON "playlist_tracks" USING btree ("track_id");--> statement-breakpoint +CREATE INDEX "playlists_owner_idx" ON "playlists" USING btree ("owner_id");--> statement-breakpoint +CREATE INDEX "playlists_visibility_idx" ON "playlists" USING btree ("visibility");--> statement-breakpoint +CREATE INDEX "tracks_status_idx" ON "tracks" USING btree ("status");--> statement-breakpoint +CREATE INDEX "tracks_title_idx" ON "tracks" USING btree ("title");--> statement-breakpoint +CREATE INDEX "tracks_uploader_idx" ON "tracks" USING btree ("uploaded_by_id");--> statement-breakpoint +CREATE INDEX "tracks_fingerprint_idx" ON "tracks" USING btree ("fingerprint");--> statement-breakpoint +CREATE INDEX "tracks_mb_recording_idx" ON "tracks" USING btree ("mb_recording_id");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree (lower("email"));--> statement-breakpoint +CREATE UNIQUE INDEX "users_username_idx" ON "users" USING btree (lower("username")); \ No newline at end of file diff --git a/apps/server/drizzle/0001_colossal_roxanne_simpson.sql b/apps/server/drizzle/0001_colossal_roxanne_simpson.sql new file mode 100644 index 0000000..00f7cf0 --- /dev/null +++ b/apps/server/drizzle/0001_colossal_roxanne_simpson.sql @@ -0,0 +1 @@ +DROP INDEX "tracks_fingerprint_idx"; \ No newline at end of file diff --git a/apps/server/drizzle/0002_red_susan_delgado.sql b/apps/server/drizzle/0002_red_susan_delgado.sql new file mode 100644 index 0000000..053bdbc --- /dev/null +++ b/apps/server/drizzle/0002_red_susan_delgado.sql @@ -0,0 +1,14 @@ +CREATE TABLE "track_artists" ( + "track_id" uuid NOT NULL, + "artist_id" uuid NOT NULL, + "position" integer DEFAULT 0 NOT NULL, + "role" text DEFAULT 'main' NOT NULL, + CONSTRAINT "track_artists_track_id_artist_id_pk" PRIMARY KEY("track_id","artist_id") +); +--> statement-breakpoint +ALTER TABLE "track_artists" ADD CONSTRAINT "track_artists_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "track_artists" ADD CONSTRAINT "track_artists_artist_id_artists_id_fk" FOREIGN KEY ("artist_id") REFERENCES "public"."artists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "track_artists_artist_idx" ON "track_artists" USING btree ("artist_id");--> statement-breakpoint +-- Backfill: make the existing single artist of each track a main credit. +INSERT INTO "track_artists" ("track_id", "artist_id", "position", "role") +SELECT "id", "artist_id", 0, 'main' FROM "tracks" WHERE "artist_id" IS NOT NULL; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0000_snapshot.json b/apps/server/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..37f245c --- /dev/null +++ b/apps/server/drizzle/meta/0000_snapshot.json @@ -0,0 +1,1408 @@ +{ + "id": "730709e3-cb21-4413-ba1f-3f71c70402ee", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.albums": { + "name": "albums", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mb_release_group_id": { + "name": "mb_release_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_release_id": { + "name": "mb_release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "albums_title_idx": { + "name": "albums_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "albums_mb_rg_idx": { + "name": "albums_mb_rg_idx", + "columns": [ + { + "expression": "mb_release_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "albums_artist_id_artists_id_fk": { + "name": "albums_artist_id_artists_id_fk", + "tableFrom": "albums", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artist_follows": { + "name": "artist_follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artist_follows_user_id_users_id_fk": { + "name": "artist_follows_user_id_users_id_fk", + "tableFrom": "artist_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artist_follows_artist_id_artists_id_fk": { + "name": "artist_follows_artist_id_artists_id_fk", + "tableFrom": "artist_follows", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artist_follows_user_id_artist_id_pk": { + "name": "artist_follows_user_id_artist_id_pk", + "columns": [ + "user_id", + "artist_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artists": { + "name": "artists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_artist_id": { + "name": "mb_artist_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artists_name_idx": { + "name": "artists_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artists_mb_idx": { + "name": "artists_mb_idx", + "columns": [ + { + "expression": "mb_artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_track_idx": { + "name": "comments_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_track_id_tracks_id_fk": { + "name": "comments_track_id_tracks_id_fk", + "tableFrom": "comments", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_playlists": { + "name": "library_playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "library_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reference'" + }, + "copied_playlist_id": { + "name": "copied_playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "library_playlists_uniq": { + "name": "library_playlists_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_playlists_user_idx": { + "name": "library_playlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_playlists_user_id_users_id_fk": { + "name": "library_playlists_user_id_users_id_fk", + "tableFrom": "library_playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_playlist_id_playlists_id_fk": { + "name": "library_playlists_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_copied_playlist_id_playlists_id_fk": { + "name": "library_playlists_copied_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "copied_playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_collaborators": { + "name": "playlist_collaborators", + "schema": "", + "columns": { + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "playlist_collaborators_playlist_id_playlists_id_fk": { + "name": "playlist_collaborators_playlist_id_playlists_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_collaborators_user_id_users_id_fk": { + "name": "playlist_collaborators_user_id_users_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "playlist_collaborators_playlist_id_user_id_pk": { + "name": "playlist_collaborators_playlist_id_user_id_pk", + "columns": [ + "playlist_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_tracks": { + "name": "playlist_tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added_by_id": { + "name": "added_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlist_tracks_playlist_idx": { + "name": "playlist_tracks_playlist_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_tracks_track_idx": { + "name": "playlist_tracks_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_track_id_tracks_id_fk": { + "name": "playlist_tracks_track_id_tracks_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_added_by_id_users_id_fk": { + "name": "playlist_tracks_added_by_id_users_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlists": { + "name": "playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "playlist_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "is_shareable": { + "name": "is_shareable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_collaborator_edit": { + "name": "allow_collaborator_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlists_owner_idx": { + "name": "playlists_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_visibility_idx": { + "name": "playlists_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlists_owner_id_users_id_fk": { + "name": "playlists_owner_id_users_id_fk", + "tableFrom": "playlists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_likes": { + "name": "track_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "track_likes_user_id_users_id_fk": { + "name": "track_likes_user_id_users_id_fk", + "tableFrom": "track_likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_likes_track_id_tracks_id_fk": { + "name": "track_likes_track_id_tracks_id_fk", + "tableFrom": "track_likes", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_likes_user_id_track_id_pk": { + "name": "track_likes_user_id_track_id_pk", + "columns": [ + "user_id", + "track_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "album_id": { + "name": "album_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "track_number": { + "name": "track_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disc_number": { + "name": "disc_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_plain": { + "name": "lyrics_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_synced": { + "name": "lyrics_synced", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lyrics_source": { + "name": "lyrics_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "track_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_recording_id": { + "name": "mb_recording_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "play_count": { + "name": "play_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tracks_status_idx": { + "name": "tracks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_title_idx": { + "name": "tracks_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_uploader_idx": { + "name": "tracks_uploader_idx", + "columns": [ + { + "expression": "uploaded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_fingerprint_idx": { + "name": "tracks_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_mb_recording_idx": { + "name": "tracks_mb_recording_idx", + "columns": [ + { + "expression": "mb_recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracks_artist_id_artists_id_fk": { + "name": "tracks_artist_id_artists_id_fk", + "tableFrom": "tracks", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_album_id_albums_id_fk": { + "name": "tracks_album_id_albums_id_fk", + "tableFrom": "tracks", + "tableTo": "albums", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_uploaded_by_id_users_id_fk": { + "name": "tracks_uploaded_by_id_users_id_fk", + "tableFrom": "tracks", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"canCreatePlaylists\":true,\"canUploadTracks\":true,\"canAutoApprove\":false,\"canModerate\":false,\"canManageUsers\":false,\"canManageLibrary\":false,\"canViewAllPlaylists\":false}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.library_mode": { + "name": "library_mode", + "schema": "public", + "values": [ + "reference", + "copy" + ] + }, + "public.playlist_visibility": { + "name": "playlist_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private" + ] + }, + "public.track_status": { + "name": "track_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0001_snapshot.json b/apps/server/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..c179d69 --- /dev/null +++ b/apps/server/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1393 @@ +{ + "id": "f1d2e229-348e-4f6c-b04d-8e9d9f0e87d4", + "prevId": "730709e3-cb21-4413-ba1f-3f71c70402ee", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.albums": { + "name": "albums", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mb_release_group_id": { + "name": "mb_release_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_release_id": { + "name": "mb_release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "albums_title_idx": { + "name": "albums_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "albums_mb_rg_idx": { + "name": "albums_mb_rg_idx", + "columns": [ + { + "expression": "mb_release_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "albums_artist_id_artists_id_fk": { + "name": "albums_artist_id_artists_id_fk", + "tableFrom": "albums", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artist_follows": { + "name": "artist_follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artist_follows_user_id_users_id_fk": { + "name": "artist_follows_user_id_users_id_fk", + "tableFrom": "artist_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artist_follows_artist_id_artists_id_fk": { + "name": "artist_follows_artist_id_artists_id_fk", + "tableFrom": "artist_follows", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artist_follows_user_id_artist_id_pk": { + "name": "artist_follows_user_id_artist_id_pk", + "columns": [ + "user_id", + "artist_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artists": { + "name": "artists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_artist_id": { + "name": "mb_artist_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artists_name_idx": { + "name": "artists_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artists_mb_idx": { + "name": "artists_mb_idx", + "columns": [ + { + "expression": "mb_artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_track_idx": { + "name": "comments_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_track_id_tracks_id_fk": { + "name": "comments_track_id_tracks_id_fk", + "tableFrom": "comments", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_playlists": { + "name": "library_playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "library_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reference'" + }, + "copied_playlist_id": { + "name": "copied_playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "library_playlists_uniq": { + "name": "library_playlists_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_playlists_user_idx": { + "name": "library_playlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_playlists_user_id_users_id_fk": { + "name": "library_playlists_user_id_users_id_fk", + "tableFrom": "library_playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_playlist_id_playlists_id_fk": { + "name": "library_playlists_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_copied_playlist_id_playlists_id_fk": { + "name": "library_playlists_copied_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "copied_playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_collaborators": { + "name": "playlist_collaborators", + "schema": "", + "columns": { + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "playlist_collaborators_playlist_id_playlists_id_fk": { + "name": "playlist_collaborators_playlist_id_playlists_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_collaborators_user_id_users_id_fk": { + "name": "playlist_collaborators_user_id_users_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "playlist_collaborators_playlist_id_user_id_pk": { + "name": "playlist_collaborators_playlist_id_user_id_pk", + "columns": [ + "playlist_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_tracks": { + "name": "playlist_tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added_by_id": { + "name": "added_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlist_tracks_playlist_idx": { + "name": "playlist_tracks_playlist_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_tracks_track_idx": { + "name": "playlist_tracks_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_track_id_tracks_id_fk": { + "name": "playlist_tracks_track_id_tracks_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_added_by_id_users_id_fk": { + "name": "playlist_tracks_added_by_id_users_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlists": { + "name": "playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "playlist_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "is_shareable": { + "name": "is_shareable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_collaborator_edit": { + "name": "allow_collaborator_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlists_owner_idx": { + "name": "playlists_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_visibility_idx": { + "name": "playlists_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlists_owner_id_users_id_fk": { + "name": "playlists_owner_id_users_id_fk", + "tableFrom": "playlists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_likes": { + "name": "track_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "track_likes_user_id_users_id_fk": { + "name": "track_likes_user_id_users_id_fk", + "tableFrom": "track_likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_likes_track_id_tracks_id_fk": { + "name": "track_likes_track_id_tracks_id_fk", + "tableFrom": "track_likes", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_likes_user_id_track_id_pk": { + "name": "track_likes_user_id_track_id_pk", + "columns": [ + "user_id", + "track_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "album_id": { + "name": "album_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "track_number": { + "name": "track_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disc_number": { + "name": "disc_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_plain": { + "name": "lyrics_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_synced": { + "name": "lyrics_synced", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lyrics_source": { + "name": "lyrics_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "track_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_recording_id": { + "name": "mb_recording_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "play_count": { + "name": "play_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tracks_status_idx": { + "name": "tracks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_title_idx": { + "name": "tracks_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_uploader_idx": { + "name": "tracks_uploader_idx", + "columns": [ + { + "expression": "uploaded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_mb_recording_idx": { + "name": "tracks_mb_recording_idx", + "columns": [ + { + "expression": "mb_recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracks_artist_id_artists_id_fk": { + "name": "tracks_artist_id_artists_id_fk", + "tableFrom": "tracks", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_album_id_albums_id_fk": { + "name": "tracks_album_id_albums_id_fk", + "tableFrom": "tracks", + "tableTo": "albums", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_uploaded_by_id_users_id_fk": { + "name": "tracks_uploaded_by_id_users_id_fk", + "tableFrom": "tracks", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"canCreatePlaylists\":true,\"canUploadTracks\":true,\"canAutoApprove\":false,\"canModerate\":false,\"canManageUsers\":false,\"canManageLibrary\":false,\"canViewAllPlaylists\":false}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.library_mode": { + "name": "library_mode", + "schema": "public", + "values": [ + "reference", + "copy" + ] + }, + "public.playlist_visibility": { + "name": "playlist_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private" + ] + }, + "public.track_status": { + "name": "track_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/0002_snapshot.json b/apps/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fe289d1 --- /dev/null +++ b/apps/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,1483 @@ +{ + "id": "8f28cede-375d-4b06-a78c-4cb1a40d019b", + "prevId": "f1d2e229-348e-4f6c-b04d-8e9d9f0e87d4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.albums": { + "name": "albums", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mb_release_group_id": { + "name": "mb_release_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_release_id": { + "name": "mb_release_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "albums_title_idx": { + "name": "albums_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "albums_mb_rg_idx": { + "name": "albums_mb_rg_idx", + "columns": [ + { + "expression": "mb_release_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "albums_artist_id_artists_id_fk": { + "name": "albums_artist_id_artists_id_fk", + "tableFrom": "albums", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artist_follows": { + "name": "artist_follows", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artist_follows_user_id_users_id_fk": { + "name": "artist_follows_user_id_users_id_fk", + "tableFrom": "artist_follows", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artist_follows_artist_id_artists_id_fk": { + "name": "artist_follows_artist_id_artists_id_fk", + "tableFrom": "artist_follows", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "artist_follows_user_id_artist_id_pk": { + "name": "artist_follows_user_id_artist_id_pk", + "columns": [ + "user_id", + "artist_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artists": { + "name": "artists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_name": { + "name": "sort_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_artist_id": { + "name": "mb_artist_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artists_name_idx": { + "name": "artists_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artists_mb_idx": { + "name": "artists_mb_idx", + "columns": [ + { + "expression": "mb_artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_track_idx": { + "name": "comments_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_track_id_tracks_id_fk": { + "name": "comments_track_id_tracks_id_fk", + "tableFrom": "comments", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_comments_id_fk": { + "name": "comments_parent_id_comments_id_fk", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library_playlists": { + "name": "library_playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "library_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'reference'" + }, + "copied_playlist_id": { + "name": "copied_playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "library_playlists_uniq": { + "name": "library_playlists_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "library_playlists_user_idx": { + "name": "library_playlists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "library_playlists_user_id_users_id_fk": { + "name": "library_playlists_user_id_users_id_fk", + "tableFrom": "library_playlists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_playlist_id_playlists_id_fk": { + "name": "library_playlists_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_playlists_copied_playlist_id_playlists_id_fk": { + "name": "library_playlists_copied_playlist_id_playlists_id_fk", + "tableFrom": "library_playlists", + "tableTo": "playlists", + "columnsFrom": [ + "copied_playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_collaborators": { + "name": "playlist_collaborators", + "schema": "", + "columns": { + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "can_edit": { + "name": "can_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "playlist_collaborators_playlist_id_playlists_id_fk": { + "name": "playlist_collaborators_playlist_id_playlists_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_collaborators_user_id_users_id_fk": { + "name": "playlist_collaborators_user_id_users_id_fk", + "tableFrom": "playlist_collaborators", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "playlist_collaborators_playlist_id_user_id_pk": { + "name": "playlist_collaborators_playlist_id_user_id_pk", + "columns": [ + "playlist_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlist_tracks": { + "name": "playlist_tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "playlist_id": { + "name": "playlist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "added_by_id": { + "name": "added_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlist_tracks_playlist_idx": { + "name": "playlist_tracks_playlist_idx", + "columns": [ + { + "expression": "playlist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlist_tracks_track_idx": { + "name": "playlist_tracks_track_idx", + "columns": [ + { + "expression": "track_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlist_tracks_playlist_id_playlists_id_fk": { + "name": "playlist_tracks_playlist_id_playlists_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "playlists", + "columnsFrom": [ + "playlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_track_id_tracks_id_fk": { + "name": "playlist_tracks_track_id_tracks_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "playlist_tracks_added_by_id_users_id_fk": { + "name": "playlist_tracks_added_by_id_users_id_fk", + "tableFrom": "playlist_tracks", + "tableTo": "users", + "columnsFrom": [ + "added_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.playlists": { + "name": "playlists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "playlist_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "is_shareable": { + "name": "is_shareable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_collaborator_edit": { + "name": "allow_collaborator_edit", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "playlists_owner_idx": { + "name": "playlists_owner_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "playlists_visibility_idx": { + "name": "playlists_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "playlists_owner_id_users_id_fk": { + "name": "playlists_owner_id_users_id_fk", + "tableFrom": "playlists", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_artists": { + "name": "track_artists", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + } + }, + "indexes": { + "track_artists_artist_idx": { + "name": "track_artists_artist_idx", + "columns": [ + { + "expression": "artist_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "track_artists_track_id_tracks_id_fk": { + "name": "track_artists_track_id_tracks_id_fk", + "tableFrom": "track_artists", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_artists_artist_id_artists_id_fk": { + "name": "track_artists_artist_id_artists_id_fk", + "tableFrom": "track_artists", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_artists_track_id_artist_id_pk": { + "name": "track_artists_track_id_artist_id_pk", + "columns": [ + "track_id", + "artist_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.track_likes": { + "name": "track_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "track_likes_user_id_users_id_fk": { + "name": "track_likes_user_id_users_id_fk", + "tableFrom": "track_likes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "track_likes_track_id_tracks_id_fk": { + "name": "track_likes_track_id_tracks_id_fk", + "tableFrom": "track_likes", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "track_likes_user_id_track_id_pk": { + "name": "track_likes_user_id_track_id_pk", + "columns": [ + "user_id", + "track_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tracks": { + "name": "tracks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_id": { + "name": "artist_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "album_id": { + "name": "album_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "track_number": { + "name": "track_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disc_number": { + "name": "disc_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_plain": { + "name": "lyrics_plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lyrics_synced": { + "name": "lyrics_synced", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lyrics_source": { + "name": "lyrics_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_key": { + "name": "file_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by_id": { + "name": "uploaded_by_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "track_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mb_recording_id": { + "name": "mb_recording_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "play_count": { + "name": "play_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tracks_status_idx": { + "name": "tracks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_title_idx": { + "name": "tracks_title_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_uploader_idx": { + "name": "tracks_uploader_idx", + "columns": [ + { + "expression": "uploaded_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tracks_mb_recording_idx": { + "name": "tracks_mb_recording_idx", + "columns": [ + { + "expression": "mb_recording_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tracks_artist_id_artists_id_fk": { + "name": "tracks_artist_id_artists_id_fk", + "tableFrom": "tracks", + "tableTo": "artists", + "columnsFrom": [ + "artist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_album_id_albums_id_fk": { + "name": "tracks_album_id_albums_id_fk", + "tableFrom": "tracks", + "tableTo": "albums", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tracks_uploaded_by_id_users_id_fk": { + "name": "tracks_uploaded_by_id_users_id_fk", + "tableFrom": "tracks", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"canCreatePlaylists\":true,\"canUploadTracks\":true,\"canAutoApprove\":false,\"canModerate\":false,\"canManageUsers\":false,\"canManageLibrary\":false,\"canViewAllPlaylists\":false}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_username_idx": { + "name": "users_username_idx", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.library_mode": { + "name": "library_mode", + "schema": "public", + "values": [ + "reference", + "copy" + ] + }, + "public.playlist_visibility": { + "name": "playlist_visibility", + "schema": "public", + "values": [ + "public", + "unlisted", + "private" + ] + }, + "public.track_status": { + "name": "track_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json new file mode 100644 index 0000000..e525b76 --- /dev/null +++ b/apps/server/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1791209057358, + "tag": "0000_clever_famine", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1791213238259, + "tag": "0001_colossal_roxanne_simpson", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1791215521207, + "tag": "0002_red_susan_delgado", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..749f6c8 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,49 @@ +{ + "name": "@adc-retivo/server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:push": "drizzle-kit push", + "db:seed": "tsx src/db/seed.ts", + "worker": "tsx src/workers/metadata.worker.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "bullmq": "^5.34.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^16.6.1", + "drizzle-orm": "^0.38.3", + "express": "^4.21.2", + "express-rate-limit": "^7.5.0", + "helmet": "^8.0.0", + "ioredis": "^5.4.2", + "minio": "^8.0.2", + "multer": "^1.4.5-lts.1", + "music-metadata": "^10.6.0", + "nanoid": "^5.0.9", + "pino": "^9.6.0", + "postgres": "^3.4.5", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.5", + "drizzle-kit": "^0.30.1", + "pino-pretty": "^13.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts new file mode 100644 index 0000000..273bb58 --- /dev/null +++ b/apps/server/src/db/index.ts @@ -0,0 +1,13 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { env } from "../env.js"; +import * as schema from "./schema.js"; + +export const sql = postgres(env.DATABASE_URL, { + max: 10, + onnotice: () => {}, +}); + +export const db = drizzle(sql, { schema }); +export { schema }; +export type DB = typeof db; diff --git a/apps/server/src/db/migrate.ts b/apps/server/src/db/migrate.ts new file mode 100644 index 0000000..b0fb540 --- /dev/null +++ b/apps/server/src/db/migrate.ts @@ -0,0 +1,38 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import postgres from "postgres"; +import { env } from "../env.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// migrations live in /drizzle (two levels up from dist/src/db or src/db) +const migrationsFolder = path.resolve(__dirname, "../../drizzle"); + +async function main() { + const client = postgres(env.DATABASE_URL, { max: 1, onnotice: () => {} }); + const db = drizzle(client); + console.log(`[migrate] applying migrations from ${migrationsFolder}`); + // Retry a few times: on Docker Desktop (macOS) bind-mounted files can briefly + // read as empty right after the container starts. + let lastError: unknown; + for (let attempt = 1; attempt <= 5; attempt++) { + try { + await migrate(db, { migrationsFolder }); + console.log("[migrate] done"); + lastError = undefined; + break; + } catch (err) { + lastError = err; + console.warn(`[migrate] attempt ${attempt} failed, retrying...`); + await new Promise((r) => setTimeout(r, 2000)); + } + } + await client.end(); + if (lastError) throw lastError; +} + +main().catch((err) => { + console.error("[migrate] failed", err); + process.exit(1); +}); diff --git a/apps/server/src/db/queries.ts b/apps/server/src/db/queries.ts new file mode 100644 index 0000000..292de32 --- /dev/null +++ b/apps/server/src/db/queries.ts @@ -0,0 +1,10 @@ +/** + * Shared relational `with` clause for tracks so every endpoint returns the + * same shape (primary artist, full artist credits, album, uploader). + */ +export const trackWith = { + artist: true, + album: true, + uploader: true, + artists: { with: { artist: true } }, +} as const; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts new file mode 100644 index 0000000..2ccf8d3 --- /dev/null +++ b/apps/server/src/db/schema.ts @@ -0,0 +1,434 @@ +import { relations, sql } from "drizzle-orm"; +import { + bigint, + boolean, + index, + integer, + jsonb, + pgEnum, + pgTable, + primaryKey, + text, + timestamp, + uniqueIndex, + uuid, + type AnyPgColumn, +} from "drizzle-orm/pg-core"; + +/* ------------------------------------------------------------------ */ +/* Enums */ +/* ------------------------------------------------------------------ */ + +export const trackStatus = pgEnum("track_status", ["pending", "approved", "rejected"]); +export const playlistVisibility = pgEnum("playlist_visibility", [ + "public", + "unlisted", + "private", +]); +export const libraryMode = pgEnum("library_mode", ["reference", "copy"]); + +/* ------------------------------------------------------------------ */ +/* Permissions */ +/* ------------------------------------------------------------------ */ + +export interface Permissions { + canCreatePlaylists: boolean; + canUploadTracks: boolean; + canAutoApprove: boolean; + canModerate: boolean; + canManageUsers: boolean; + canManageLibrary: boolean; + canViewAllPlaylists: boolean; +} + +export const defaultPermissions: Permissions = { + canCreatePlaylists: true, + canUploadTracks: true, + canAutoApprove: false, + canModerate: false, + canManageUsers: false, + canManageLibrary: false, + canViewAllPlaylists: false, +}; + +export const superAdminPermissions: Permissions = { + canCreatePlaylists: true, + canUploadTracks: true, + canAutoApprove: true, + canModerate: true, + canManageUsers: true, + canManageLibrary: true, + canViewAllPlaylists: true, +}; + +/* ------------------------------------------------------------------ */ +/* Users */ +/* ------------------------------------------------------------------ */ + +export const users = pgTable( + "users", + { + id: uuid("id").primaryKey().defaultRandom(), + email: text("email").notNull(), + username: text("username").notNull(), + displayName: text("display_name"), + passwordHash: text("password_hash").notNull(), + avatarUrl: text("avatar_url"), + bio: text("bio"), + isSuperAdmin: boolean("is_super_admin").notNull().default(false), + permissions: jsonb("permissions").$type().notNull().default(defaultPermissions), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + emailIdx: uniqueIndex("users_email_idx").on(sql`lower(${t.email})`), + usernameIdx: uniqueIndex("users_username_idx").on(sql`lower(${t.username})`), + }), +); + +/* ------------------------------------------------------------------ */ +/* Artists */ +/* ------------------------------------------------------------------ */ + +export const artists = pgTable( + "artists", + { + id: uuid("id").primaryKey().defaultRandom(), + name: text("name").notNull(), + sortName: text("sort_name"), + mbArtistId: text("mb_artist_id"), + description: text("description"), + imageUrl: text("image_url"), + country: text("country"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + nameIdx: index("artists_name_idx").on(t.name), + mbIdx: uniqueIndex("artists_mb_idx").on(t.mbArtistId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Albums */ +/* ------------------------------------------------------------------ */ + +export const albums = pgTable( + "albums", + { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), + artistId: uuid("artist_id").references(() => artists.id, { onDelete: "set null" }), + mbReleaseGroupId: text("mb_release_group_id"), + mbReleaseId: text("mb_release_id"), + releaseDate: text("release_date"), + coverUrl: text("cover_url"), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + titleIdx: index("albums_title_idx").on(t.title), + rgIdx: uniqueIndex("albums_mb_rg_idx").on(t.mbReleaseGroupId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Tracks */ +/* ------------------------------------------------------------------ */ + +export interface LyricsLine { + timeMs: number; + text: string; +} + +export const tracks = pgTable( + "tracks", + { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), + artistId: uuid("artist_id").references(() => artists.id, { onDelete: "set null" }), + albumId: uuid("album_id").references(() => albums.id, { onDelete: "set null" }), + durationMs: integer("duration_ms"), + trackNumber: integer("track_number"), + discNumber: integer("disc_number"), + releaseDate: text("release_date"), + lyricsPlain: text("lyrics_plain"), + lyricsSynced: jsonb("lyrics_synced").$type(), + lyricsSource: text("lyrics_source"), + fileKey: text("file_key").notNull(), + fileSize: bigint("file_size", { mode: "number" }), + mimeType: text("mime_type"), + uploadedById: uuid("uploaded_by_id").references(() => users.id, { onDelete: "set null" }), + status: trackStatus("status").notNull().default("pending"), + rejectionReason: text("rejection_reason"), + fingerprint: text("fingerprint"), + mbRecordingId: text("mb_recording_id"), + coverUrl: text("cover_url"), + playCount: integer("play_count").notNull().default(0), + metadata: jsonb("metadata").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + approvedAt: timestamp("approved_at", { withTimezone: true }), + }, + (t) => ({ + statusIdx: index("tracks_status_idx").on(t.status), + titleIdx: index("tracks_title_idx").on(t.title), + uploaderIdx: index("tracks_uploader_idx").on(t.uploadedById), + mbIdx: index("tracks_mb_recording_idx").on(t.mbRecordingId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Track artists (many-to-many) */ +/* ------------------------------------------------------------------ */ + +export const trackArtists = pgTable( + "track_artists", + { + trackId: uuid("track_id") + .notNull() + .references(() => tracks.id, { onDelete: "cascade" }), + artistId: uuid("artist_id") + .notNull() + .references(() => artists.id, { onDelete: "cascade" }), + position: integer("position").notNull().default(0), + role: text("role").notNull().default("main"), + }, + (t) => ({ + pk: primaryKey({ columns: [t.trackId, t.artistId] }), + artistIdx: index("track_artists_artist_idx").on(t.artistId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Likes & follows */ +/* ------------------------------------------------------------------ */ + +export const trackLikes = pgTable( + "track_likes", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => tracks.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ pk: primaryKey({ columns: [t.userId, t.trackId] }) }), +); + +export const artistFollows = pgTable( + "artist_follows", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + artistId: uuid("artist_id") + .notNull() + .references(() => artists.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ pk: primaryKey({ columns: [t.userId, t.artistId] }) }), +); + +/* ------------------------------------------------------------------ */ +/* Playlists */ +/* ------------------------------------------------------------------ */ + +export const playlists = pgTable( + "playlists", + { + id: uuid("id").primaryKey().defaultRandom(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + title: text("title").notNull(), + description: text("description"), + coverUrl: text("cover_url"), + visibility: playlistVisibility("visibility").notNull().default("private"), + isShareable: boolean("is_shareable").notNull().default(false), + allowCollaboratorEdit: boolean("allow_collaborator_edit").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + ownerIdx: index("playlists_owner_idx").on(t.ownerId), + visibilityIdx: index("playlists_visibility_idx").on(t.visibility), + }), +); + +export const playlistTracks = pgTable( + "playlist_tracks", + { + id: uuid("id").primaryKey().defaultRandom(), + playlistId: uuid("playlist_id") + .notNull() + .references(() => playlists.id, { onDelete: "cascade" }), + trackId: uuid("track_id") + .notNull() + .references(() => tracks.id, { onDelete: "cascade" }), + position: integer("position").notNull().default(0), + addedById: uuid("added_by_id").references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + playlistIdx: index("playlist_tracks_playlist_idx").on(t.playlistId, t.position), + trackIdx: index("playlist_tracks_track_idx").on(t.trackId), + }), +); + +export const playlistCollaborators = pgTable( + "playlist_collaborators", + { + playlistId: uuid("playlist_id") + .notNull() + .references(() => playlists.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + canEdit: boolean("can_edit").notNull().default(true), + }, + (t) => ({ pk: primaryKey({ columns: [t.playlistId, t.userId] }) }), +); + +/** Playlists a user has added to their personal library (reference or copy). */ +export const libraryPlaylists = pgTable( + "library_playlists", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + playlistId: uuid("playlist_id") + .notNull() + .references(() => playlists.id, { onDelete: "cascade" }), + mode: libraryMode("mode").notNull().default("reference"), + /** When mode = copy, points at the user-owned cloned playlist. */ + copiedPlaylistId: uuid("copied_playlist_id").references(() => playlists.id, { + onDelete: "set null", + }), + canEdit: boolean("can_edit").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + uniq: uniqueIndex("library_playlists_uniq").on(t.userId, t.playlistId), + userIdx: index("library_playlists_user_idx").on(t.userId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Comments */ +/* ------------------------------------------------------------------ */ + +export const comments = pgTable( + "comments", + { + id: uuid("id").primaryKey().defaultRandom(), + trackId: uuid("track_id") + .notNull() + .references(() => tracks.id, { onDelete: "cascade" }), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + parentId: uuid("parent_id").references((): AnyPgColumn => comments.id, { + onDelete: "cascade", + }), + body: text("body").notNull(), + deleted: boolean("deleted").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + trackIdx: index("comments_track_idx").on(t.trackId, t.createdAt), + parentIdx: index("comments_parent_idx").on(t.parentId), + }), +); + +/* ------------------------------------------------------------------ */ +/* Relations (for query builder convenience) */ +/* ------------------------------------------------------------------ */ + +export const usersRelations = relations(users, ({ many }) => ({ + tracks: many(tracks), + playlists: many(playlists), + comments: many(comments), +})); + +export const artistsRelations = relations(artists, ({ many }) => ({ + albums: many(albums), + tracks: many(tracks), + credits: many(trackArtists), + followers: many(artistFollows), +})); + +export const albumsRelations = relations(albums, ({ one, many }) => ({ + artist: one(artists, { fields: [albums.artistId], references: [artists.id] }), + tracks: many(tracks), +})); + +export const tracksRelations = relations(tracks, ({ one, many }) => ({ + artist: one(artists, { fields: [tracks.artistId], references: [artists.id] }), + album: one(albums, { fields: [tracks.albumId], references: [albums.id] }), + uploader: one(users, { fields: [tracks.uploadedById], references: [users.id] }), + artists: many(trackArtists), + playlistEntries: many(playlistTracks), + comments: many(comments), +})); + +export const trackArtistsRelations = relations(trackArtists, ({ one }) => ({ + track: one(tracks, { fields: [trackArtists.trackId], references: [tracks.id] }), + artist: one(artists, { fields: [trackArtists.artistId], references: [artists.id] }), +})); + +export const playlistsRelations = relations(playlists, ({ one, many }) => ({ + owner: one(users, { fields: [playlists.ownerId], references: [users.id] }), + entries: many(playlistTracks), + collaborators: many(playlistCollaborators), +})); + +export const playlistTracksRelations = relations(playlistTracks, ({ one }) => ({ + playlist: one(playlists, { + fields: [playlistTracks.playlistId], + references: [playlists.id], + }), + track: one(tracks, { fields: [playlistTracks.trackId], references: [tracks.id] }), + addedBy: one(users, { fields: [playlistTracks.addedById], references: [users.id] }), +})); + +export const playlistCollaboratorsRelations = relations(playlistCollaborators, ({ one }) => ({ + playlist: one(playlists, { + fields: [playlistCollaborators.playlistId], + references: [playlists.id], + }), + user: one(users, { fields: [playlistCollaborators.userId], references: [users.id] }), +})); + +export const libraryPlaylistsRelations = relations(libraryPlaylists, ({ one }) => ({ + user: one(users, { fields: [libraryPlaylists.userId], references: [users.id] }), + playlist: one(playlists, { + fields: [libraryPlaylists.playlistId], + references: [playlists.id], + }), +})); + +export const commentsRelations = relations(comments, ({ one, many }) => ({ + user: one(users, { fields: [comments.userId], references: [users.id] }), + track: one(tracks, { fields: [comments.trackId], references: [tracks.id] }), + parent: one(comments, { + fields: [comments.parentId], + references: [comments.id], + relationName: "commentReplies", + }), + replies: many(comments, { relationName: "commentReplies" }), +})); + +export type User = typeof users.$inferSelect; +export type Track = typeof tracks.$inferSelect; +export type TrackArtist = typeof trackArtists.$inferSelect; +export type Artist = typeof artists.$inferSelect; +export type Album = typeof albums.$inferSelect; +export type Playlist = typeof playlists.$inferSelect; +export type Comment = typeof comments.$inferSelect; diff --git a/apps/server/src/db/seed.ts b/apps/server/src/db/seed.ts new file mode 100644 index 0000000..956e576 --- /dev/null +++ b/apps/server/src/db/seed.ts @@ -0,0 +1,40 @@ +import bcrypt from "bcryptjs"; +import { sql as rawSql } from "drizzle-orm"; +import { env } from "../env.js"; +import { db, sql } from "./index.js"; +import { superAdminPermissions, users } from "./schema.js"; + +async function main() { + const [{ count }] = await db + .select({ count: rawSql`count(*)::int` }) + .from(users); + + if (count > 0) { + console.log("[seed] users already exist, skipping admin bootstrap"); + return; + } + + const passwordHash = await bcrypt.hash(env.ADMIN_PASSWORD, 10); + const [admin] = await db + .insert(users) + .values({ + email: env.ADMIN_EMAIL.trim().toLowerCase(), + username: env.ADMIN_USERNAME.trim(), + displayName: "Administrator", + passwordHash, + isSuperAdmin: true, + permissions: superAdminPermissions, + }) + .returning(); + + console.log(`[seed] created super admin: ${admin.email} (username: ${admin.username})`); +} + +main() + .catch((err) => { + console.error("[seed] failed", err); + process.exitCode = 1; + }) + .finally(async () => { + await sql.end(); + }); diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 0000000..0a14375 --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,56 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { config as loadEnv } from "dotenv"; +import { z } from "zod"; + +// Load .env from the repo root when running outside Docker (no-op in containers). +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ path: path.resolve(__dirname, "../../../.env") }); +loadEnv(); + +const bool = (def: boolean) => + z + .string() + .optional() + .transform((v) => (v === undefined ? def : v === "true" || v === "1")); + +const schema = z.object({ + NODE_ENV: z.enum(["development", "production", "test"]).default("development"), + PORT: z.coerce.number().default(3000), + APP_URL: z.string().default("http://localhost:3000"), + CORS_ORIGINS: z.string().optional().default(""), + + SESSION_SECRET: z.string().min(16).default("dev-secret-change-me-please-32chars-min"), + SESSION_TTL_DAYS: z.coerce.number().default(30), + + DATABASE_URL: z.string().default("postgres://adc:adc@localhost:5432/adc"), + REDIS_URL: z.string().default("redis://localhost:6379"), + + MINIO_ENDPOINT: z.string().default("localhost"), + MINIO_PORT: z.coerce.number().default(9000), + MINIO_SSL: bool(false), + MINIO_ACCESS_KEY: z.string().default("minioadmin"), + MINIO_SECRET_KEY: z.string().default("minioadmin"), + MINIO_BUCKET: z.string().default("adc-retivo"), + MINIO_PUBLIC_URL: z.string().optional().default(""), + + MB_USER_AGENT: z + .string() + .default("ADCRetivo/1.0 ( https://example.com contact:admin@example.com )"), + ACOUSTID_API_KEY: z.string().optional().default(""), + FPCALC_PATH: z.string().default("fpcalc"), + + ADMIN_EMAIL: z.string().default("admin@adc.retivo"), + ADMIN_USERNAME: z.string().default("admin"), + ADMIN_PASSWORD: z.string().default("admin123"), + + SERVE_WEB_DIST: z.string().optional().default(""), +}); + +export const env = schema.parse(process.env); + +export const isProd = env.NODE_ENV === "production"; + +export const corsOrigins = env.CORS_ORIGINS.split(",") + .map((s) => s.trim()) + .filter(Boolean); diff --git a/apps/server/src/express.d.ts b/apps/server/src/express.d.ts new file mode 100644 index 0000000..83cc08e --- /dev/null +++ b/apps/server/src/express.d.ts @@ -0,0 +1,13 @@ +import type { User } from "../db/schema.js"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user?: User; + sessionId?: string; + } + } +} + +export {}; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..8983b0b --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,93 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import cookieParser from "cookie-parser"; +import cors from "cors"; +import express, { type NextFunction, type Request, type Response } from "express"; +import rateLimit from "express-rate-limit"; +import helmet from "helmet"; +import { corsOrigins, env, isProd } from "./env.js"; +import { HttpError } from "./lib/http.js"; +import { logger } from "./lib/logger.js"; +import { ensureBucket } from "./lib/storage.js"; +import { attachUser } from "./middleware/auth.js"; +import { apiRouter } from "./routes/index.js"; +import { startMetadataWorker } from "./workers/metadata.worker.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function main() { + const app = express(); + app.set("trust proxy", 1); + + app.use( + helmet({ + crossOriginResourcePolicy: { policy: "cross-origin" }, + contentSecurityPolicy: false, + }), + ); + app.use( + cors({ + origin: corsOrigins.length ? corsOrigins : true, + credentials: true, + }), + ); + app.use(express.json({ limit: "2mb" })); + app.use(cookieParser()); + if (!isProd) { + app.use((req, res, next) => { + const start = Date.now(); + res.on("finish", () => { + logger.debug( + { method: req.method, url: req.originalUrl, status: res.statusCode, ms: Date.now() - start }, + "request", + ); + }); + next(); + }); + } + + app.use( + "/api/auth/login", + rateLimit({ windowMs: 60_000, limit: 20, standardHeaders: true, legacyHeaders: false }), + ); + + app.use(attachUser); + app.use("/api", apiRouter); + + app.get("/api/health", (_req, res) => res.json({ ok: true, time: new Date().toISOString() })); + + // Serve the built SPA in production + const webDist = env.SERVE_WEB_DIST || path.resolve(__dirname, "../../web/dist"); + app.use(express.static(webDist)); + app.get("*", (req, res, next) => { + if (req.path.startsWith("/api")) return next(); + res.sendFile(path.join(webDist, "index.html"), (err) => { + if (err) next(); + }); + }); + + // 404 for unknown API routes + app.use("/api", (_req, res) => res.status(404).json({ error: "Не найдено" })); + + // Error handler + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof HttpError) { + return res.status(err.status).json({ error: err.message, details: err.details }); + } + const message = err instanceof Error ? err.message : "Внутренняя ошибка"; + logger.error({ err }, "unhandled error"); + res.status(500).json({ error: isProd ? "Внутренняя ошибка сервера" : message }); + }); + + await ensureBucket(); + startMetadataWorker(); + + app.listen(env.PORT, () => { + logger.info(`ADC Retivo API listening on :${env.PORT} (${env.NODE_ENV})`); + }); +} + +main().catch((err) => { + logger.error({ err }, "failed to start server"); + process.exit(1); +}); diff --git a/apps/server/src/lib/http.ts b/apps/server/src/lib/http.ts new file mode 100644 index 0000000..8b5ffc0 --- /dev/null +++ b/apps/server/src/lib/http.ts @@ -0,0 +1,49 @@ +import type { NextFunction, Request, Response } from "express"; +import type { ZodSchema } from "zod"; + +export class HttpError extends Error { + status: number; + details?: unknown; + constructor(status: number, message: string, details?: unknown) { + super(message); + this.status = status; + this.details = details; + } +} + +export const badRequest = (msg = "Некорректный запрос", details?: unknown) => + new HttpError(400, msg, details); +export const unauthorized = (msg = "Требуется вход") => new HttpError(401, msg); +export const forbidden = (msg = "Недостаточно прав") => new HttpError(403, msg); +export const notFound = (msg = "Не найдено") => new HttpError(404, msg); +export const conflict = (msg = "Конфликт") => new HttpError(409, msg); + +type Handler = (req: Request, res: Response, next: NextFunction) => Promise | unknown; + +export function asyncHandler(fn: Handler) { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +} + +export function parseBody(schema: ZodSchema, body: unknown): T { + const result = schema.safeParse(body); + if (!result.success) { + throw badRequest("Ошибка валидации", result.error.flatten()); + } + return result.data; +} + +export function parsePagination(req: Request, defaultLimit = 40) { + const limit = Math.min(Math.max(Number(req.query.limit) || defaultLimit, 1), 200); + const offset = Math.max(Number(req.query.offset) || 0, 0); + return { limit, offset }; +} + +export const uuidRe = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function assertUuid(value: string, message = "Некорректный идентификатор"): string { + if (!uuidRe.test(value)) throw badRequest(message); + return value; +} diff --git a/apps/server/src/lib/logger.ts b/apps/server/src/lib/logger.ts new file mode 100644 index 0000000..93ca3b7 --- /dev/null +++ b/apps/server/src/lib/logger.ts @@ -0,0 +1,13 @@ +import pino from "pino"; +import { env, isProd } from "../env.js"; + +export const logger = pino({ + level: process.env.LOG_LEVEL ?? (isProd ? "info" : "debug"), + transport: isProd + ? undefined + : { + target: "pino-pretty", + options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" }, + }, + base: { app: env.APP_URL }, +}); diff --git a/apps/server/src/lib/metadata/acoustid.ts b/apps/server/src/lib/metadata/acoustid.ts new file mode 100644 index 0000000..a3beeab --- /dev/null +++ b/apps/server/src/lib/metadata/acoustid.ts @@ -0,0 +1,67 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { env } from "../../env.js"; +import { logger } from "../logger.js"; + +const execFileAsync = promisify(execFile); + +export interface ChromaprintResult { + duration: number; + fingerprint: string; +} + +export async function computeFingerprint(filePath: string): Promise { + try { + const { stdout } = await execFileAsync(env.FPCALC_PATH, ["-json", filePath], { + maxBuffer: 16 * 1024 * 1024, + }); + const parsed = JSON.parse(stdout) as { duration?: number; fingerprint?: string }; + if (!parsed.fingerprint) return null; + return { duration: parsed.duration ?? 0, fingerprint: parsed.fingerprint }; + } catch (err) { + logger.warn({ err }, "fpcalc unavailable or failed; skipping fingerprint"); + return null; + } +} + +export interface AcoustIdMatch { + recordingId?: string; + score: number; + releaseGroupIds: string[]; +} + +interface AcoustIdResponse { + status: string; + results?: Array<{ + score: number; + recordings?: Array<{ id: string; releasegroups?: Array<{ id: string }> }>; + }>; +} + +export async function lookupAcoustId( + fingerprint: string, + durationSeconds: number, +): Promise { + if (!env.ACOUSTID_API_KEY) return null; + try { + const url = new URL("https://api.acoustid.org/v2/lookup"); + url.searchParams.set("client", env.ACOUSTID_API_KEY); + url.searchParams.set("meta", "recordings releasegroups"); + url.searchParams.set("duration", String(Math.round(durationSeconds))); + url.searchParams.set("fingerprint", fingerprint); + const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); + if (!res.ok) return null; + const data = (await res.json()) as AcoustIdResponse; + if (data.status !== "ok" || !data.results?.length) return null; + const best = [...data.results].sort((a, b) => b.score - a.score)[0]; + const recording = best.recordings?.[0]; + return { + recordingId: recording?.id, + score: best.score, + releaseGroupIds: (recording?.releasegroups ?? []).map((r) => r.id), + }; + } catch (err) { + logger.warn({ err }, "acoustid lookup failed"); + return null; + } +} diff --git a/apps/server/src/lib/metadata/coverart.ts b/apps/server/src/lib/metadata/coverart.ts new file mode 100644 index 0000000..4608e1f --- /dev/null +++ b/apps/server/src/lib/metadata/coverart.ts @@ -0,0 +1,35 @@ +import { logger } from "../logger.js"; + +async function exists(url: string): Promise { + try { + const res = await fetch(url, { + method: "GET", + headers: { Range: "bytes=0-0" }, + signal: AbortSignal.timeout(8000), + }); + return res.ok || res.status === 206; + } catch { + return false; + } +} + +/** + * Cover Art Archive front cover. Prefers the release-group (album-level) image. + */ +export async function findCoverArt(opts: { + releaseGroupId?: string; + releaseId?: string; +}): Promise { + const candidates: string[] = []; + if (opts.releaseGroupId) { + candidates.push(`https://coverartarchive.org/release-group/${opts.releaseGroupId}/front-500`); + } + if (opts.releaseId) { + candidates.push(`https://coverartarchive.org/release/${opts.releaseId}/front-500`); + } + for (const url of candidates) { + if (await exists(url)) return url; + } + logger.debug({ opts }, "no cover art found"); + return null; +} diff --git a/apps/server/src/lib/metadata/index.ts b/apps/server/src/lib/metadata/index.ts new file mode 100644 index 0000000..89074a3 --- /dev/null +++ b/apps/server/src/lib/metadata/index.ts @@ -0,0 +1,132 @@ +import { logger } from "../logger.js"; +import { computeFingerprint, lookupAcoustId } from "./acoustid.js"; +import { findCoverArt } from "./coverart.js"; +import { fetchLyrics } from "./lrclib.js"; +import { lookupRecording, searchRecording } from "./musicbrainz.js"; +import { readTags } from "./tags.js"; +import { cleanFilename, parseArtistList, type Enrichment, type TagData } from "./types.js"; +import { fetchArtistInfo } from "./wikipedia.js"; + +export interface EnrichResult { + tags: TagData; + data: Enrichment; +} + +export async function enrichTrack(opts: { + filePath: string; + originalName?: string; +}): Promise { + const tags = await readTags(opts.filePath); + const data: Enrichment = { sources: [] }; + + // 1) Acoustic fingerprint via Chromaprint + AcoustID (optional) + const fp = await computeFingerprint(opts.filePath); + let acoustidRecordingId: string | undefined; + if (fp) { + data.fingerprint = fp.fingerprint; + if (fp.duration) data.durationMs = Math.round(fp.duration * 1000); + const match = await lookupAcoustId(fp.fingerprint, fp.duration); + if (match?.recordingId && match.score >= 0.5) { + acoustidRecordingId = match.recordingId; + data.sources.push("acoustid"); + } + } + + // 2) Derive title/artist guesses from tags, then from filename "Artist - Title" + const filename = opts.originalName ? cleanFilename(opts.originalName) : ""; + let guessTitle = tags.title; + let guessArtist = tags.artist ?? tags.albumArtist; + if (!guessTitle && filename) { + const m = filename.match(/^(.+?)\s+-\s+(.+)$/); + if (m) { + guessArtist = guessArtist ?? m[1].trim(); + guessTitle = m[2].trim(); + } else { + guessTitle = filename; + } + } + + // 3) MusicBrainz canonical metadata + let mb: Enrichment | null = null; + if (acoustidRecordingId) mb = await lookupRecording(acoustidRecordingId); + if (!mb) mb = await searchRecording(guessTitle ?? "", guessArtist, tags.durationMs); + if (mb) { + data.title = mb.title ?? data.title; + data.artistName = mb.artistName ?? data.artistName; + data.albumTitle = mb.albumTitle ?? data.albumTitle; + data.releaseDate = mb.releaseDate ?? data.releaseDate; + data.trackNumber = mb.trackNumber ?? data.trackNumber; + data.discNumber = mb.discNumber ?? data.discNumber; + data.mbRecordingId = mb.mbRecordingId; + data.mbArtistId = mb.mbArtistId; + data.mbReleaseGroupId = mb.mbReleaseGroupId; + data.mbReleaseId = mb.mbReleaseId; + if (!data.durationMs && mb.durationMs) data.durationMs = mb.durationMs; + data.sources.push(...mb.sources); + } + + // 4) Fallbacks from local tags / filename. + // The actual file duration is authoritative for playback, so it wins over + // MusicBrainz's catalog length (which may differ from the uploaded file). + if (tags.durationMs) data.durationMs = tags.durationMs; + data.title ??= guessTitle; + data.albumTitle ??= tags.album; + data.releaseDate ??= tags.date; + data.trackNumber ??= tags.trackNumber; + data.discNumber ??= tags.discNumber; + + // Multiple artists: from MB, or split the tag / filename artist string + // ("A, B", "A feat. B", "A & B", ...) into separate credits. + if (!data.artists?.length) { + const candidates = ( + tags.artists?.length + ? tags.artists + : [tags.artist ?? tags.albumArtist ?? guessArtist].filter(Boolean) + ) as string[]; + const parsed = candidates.flatMap((name) => parseArtistList(name)); + if (parsed.length) data.artists = parsed; + } + data.artistName = data.artistName ?? data.artists?.[0]?.name ?? guessArtist; + + // 5) Cover art + if (data.mbReleaseGroupId || data.mbReleaseId) { + const coverUrl = await findCoverArt({ + releaseGroupId: data.mbReleaseGroupId, + releaseId: data.mbReleaseId, + }); + if (coverUrl) { + data.coverUrl = coverUrl; + data.sources.push("coverartarchive"); + } + } + + // 6) Artist bio + image + if (data.artistName) { + const info = await fetchArtistInfo(data.artistName); + if (info) { + data.artistDescription = info.extract; + data.artistImageUrl = info.imageUrl; + data.sources.push("wikipedia"); + } + } + + // 7) Lyrics (plain + synced/LRC) + const lyrics = await fetchLyrics({ + title: data.title ?? "", + artist: data.artistName, + album: data.albumTitle, + durationMs: data.durationMs, + }); + if (lyrics) { + data.lyricsPlain = lyrics.plain; + data.lyricsSynced = lyrics.synced; + data.lyricsSource = lyrics.source; + data.sources.push(lyrics.source); + } + + logger.debug( + { title: data.title, artist: data.artistName, sources: data.sources }, + "metadata enrichment finished", + ); + return { tags, data }; +} diff --git a/apps/server/src/lib/metadata/lrclib.ts b/apps/server/src/lib/metadata/lrclib.ts new file mode 100644 index 0000000..2e8f4dd --- /dev/null +++ b/apps/server/src/lib/metadata/lrclib.ts @@ -0,0 +1,108 @@ +import type { LyricsLine } from "../../db/schema.js"; +import { logger } from "../logger.js"; + +export interface LyricsResult { + plain?: string; + synced?: LyricsLine[] | null; + source: string; +} + +const LRC_LINE = /\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]([^\[]*)/g; + +export function parseLrc(lrc: string): LyricsLine[] { + const lines: LyricsLine[] = []; + for (const rawLine of lrc.split(/\r?\n/)) { + let match: RegExpExecArray | null; + LRC_LINE.lastIndex = 0; + const matches: LayoutMatch[] = []; + while ((match = LRC_LINE.exec(rawLine)) !== null) { + const minutes = Number(match[1]); + const seconds = Number(match[2].replace(":", ".")); + const text = match[3].trim(); + matches.push({ timeMs: Math.round((minutes * 60 + seconds) * 1000), text }); + } + for (const m of matches) { + if (m.text.length > 0) lines.push(m); + } + } + return lines.sort((a, b) => a.timeMs - b.timeMs); +} + +interface LayoutMatch { + timeMs: number; + text: string; +} + +interface LrclibTrack { + id: number; + trackName?: string; + artistName?: string; + albumName?: string; + duration?: number; + plainLyrics?: string | null; + syncedLyrics?: string | null; +} + +async function lrclibFetch(url: URL): Promise { + try { + const res = await fetch(url, { + headers: { + "User-Agent": "ADCRetivo/1.0 (https://github.com/adc-retivo)", + Accept: "application/json", + }, + signal: AbortSignal.timeout(12000), + }); + if (!res.ok) return null; + return (await res.json()) as LrclibTrack | LrclibTrack[]; + } catch (err) { + logger.warn({ err }, "lrclib request failed"); + return null; + } +} + +function pickBest(tracks: LrclibTrack[], durationMs?: number): LrclibTrack | undefined { + if (!tracks.length) return undefined; + const withSynced = tracks.filter((t) => t.syncedLyrics); + const pool = withSynced.length ? withSynced : tracks; + if (!durationMs) return pool[0]; + const targetSec = durationMs / 1000; + return pool.reduce((acc, cur) => { + const da = Math.abs((acc.duration ?? targetSec) - targetSec); + const dc = Math.abs((cur.duration ?? targetSec) - targetSec); + return dc < da ? cur : acc; + }, pool[0]); +} + +export async function fetchLyrics(opts: { + title: string; + artist?: string; + album?: string; + durationMs?: number; +}): Promise { + if (!opts.title) return null; + + const exact = new URL("https://lrclib.net/api/get"); + exact.searchParams.set("track_name", opts.title); + if (opts.artist) exact.searchParams.set("artist_name", opts.artist); + if (opts.album) exact.searchParams.set("album_name", opts.album); + if (opts.durationMs) exact.searchParams.set("duration", String(Math.round(opts.durationMs / 1000))); + + let track = (await lrclibFetch(exact)) as LrclibTrack | null; + if (!track || !Array.isArray(track)) { + const search = new URL("https://lrclib.net/api/search"); + search.searchParams.set("q", [opts.title, opts.artist].filter(Boolean).join(" ")); + const results = (await lrclibFetch(search)) as LrclibTrack[] | null; + if (results && Array.isArray(results) && results.length) { + track = pickBest(results, opts.durationMs) ?? null; + } + } + + if (!track || Array.isArray(track)) return null; + const synced = track.syncedLyrics ? parseLrc(track.syncedLyrics) : null; + if (!synced?.length && !track.plainLyrics) return null; + return { + plain: track.plainLyrics ?? undefined, + synced: synced?.length ? synced : null, + source: "lrclib", + }; +} diff --git a/apps/server/src/lib/metadata/musicbrainz.ts b/apps/server/src/lib/metadata/musicbrainz.ts new file mode 100644 index 0000000..c51c2a0 --- /dev/null +++ b/apps/server/src/lib/metadata/musicbrainz.ts @@ -0,0 +1,142 @@ +import { env } from "../../env.js"; +import { logger } from "../logger.js"; +import { parseArtistList, type ArtistCredit, type Enrichment } from "./types.js"; + +let lastRequest = 0; + +async function mbFetch( + path: string, + params: Record = {}, +): Promise { + const wait = Math.max(0, 1100 - (Date.now() - lastRequest)); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + lastRequest = Date.now(); + + const url = new URL(`https://musicbrainz.org${path}`); + url.searchParams.set("fmt", "json"); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + + try { + const res = await fetch(url, { + headers: { "User-Agent": env.MB_USER_AGENT, Accept: "application/json" }, + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) { + logger.warn({ status: res.status, url: url.toString() }, "musicbrainz request failed"); + return null; + } + return (await res.json()) as T; + } catch (err) { + logger.warn({ err }, "musicbrainz request error"); + return null; + } +} + +interface MBArtistCredit { + name?: string; + joinphrase?: string; + artist?: { id: string; name: string }; +} + +interface MBRelease { + id: string; + title?: string; + date?: string; + "release-group"?: { + id: string; + "first-release-date"?: string; + "primary-type"?: string; + }; +} + +interface MBRecording { + id: string; + title: string; + length?: number; + "artist-credit"?: MBArtistCredit[]; + releases?: MBRelease[]; +} + +interface MBSearchResponse { + recordings?: MBRecording[]; +} + +function normalizeRecording(rec: MBRecording, sourceLabel: string): Enrichment { + const credits = rec["artist-credit"] ?? []; + const credit = credits[0]; + const releases = rec.releases ?? []; + // prefer release with a release-group and an earliest date + const withGroup = releases.filter((r) => r["release-group"]); + withGroup.sort((a, b) => { + const da = a["release-group"]?.["first-release-date"] ?? a.date ?? "9999"; + const db = b["release-group"]?.["first-release-date"] ?? b.date ?? "9999"; + return da.localeCompare(db); + }); + const release = withGroup[0] ?? releases[0]; + + let artists: ArtistCredit[] = credits + .filter((c) => c.artist) + .map((c) => ({ name: c.artist!.name, mbArtistId: c.artist!.id, role: "main" })); + + const combined = artists.length <= 1 ? credit?.name ?? credit?.artist?.name : undefined; + if (combined && /[,;&]|feat\.?|ft\.?|при участии/i.test(combined)) { + const parsed = parseArtistList(combined); + if (parsed.length > 1) artists = parsed; + } + + return { + title: rec.title, + artistName: artists[0]?.name ?? credit?.artist?.name ?? credit?.name, + artists, + mbRecordingId: rec.id, + mbArtistId: artists[0]?.mbArtistId ?? credit?.artist?.id, + mbReleaseId: release?.id, + mbReleaseGroupId: release?.["release-group"]?.id, + albumTitle: release?.title, + releaseDate: + release?.["release-group"]?.["first-release-date"] ?? release?.date ?? undefined, + durationMs: rec.length ?? undefined, + sources: [sourceLabel], + }; +} + +export async function lookupRecording(mbid: string): Promise { + const rec = await mbFetch(`/ws/2/recording/${mbid}`, { + inc: "artist-credits+releases+release-groups", + }); + if (!rec) return null; + return normalizeRecording(rec, "musicbrainz:recording"); +} + +export async function searchRecording( + title: string, + artist?: string, + durationMs?: number, +): Promise { + if (!title) return null; + const parts = [`recording:"${title.replace(/"/g, "")}"`]; + if (artist) parts.push(`artist:"${artist.replace(/"/g, "")}"`); + const data = await mbFetch("/ws/2/recording", { + query: parts.join(" AND "), + limit: "5", + inc: "artist-credits", + }); + const recordings = data?.recordings ?? []; + if (!recordings.length) return null; + + // pick closest by duration when available + let best = recordings[0]; + if (durationMs) { + best = recordings.reduce((acc, cur) => { + const da = Math.abs((acc.length ?? durationMs) - durationMs); + const dc = Math.abs((cur.length ?? durationMs) - durationMs); + return dc < da ? cur : acc; + }, recordings[0]); + } + return normalizeRecording(best, "musicbrainz:search"); +} + +export async function lookupArtist(mbid: string): Promise<{ name?: string } | null> { + const artist = await mbFetch<{ name?: string }>(`/ws/2/artist/${mbid}`); + return artist ?? null; +} diff --git a/apps/server/src/lib/metadata/tags.ts b/apps/server/src/lib/metadata/tags.ts new file mode 100644 index 0000000..db44a0a --- /dev/null +++ b/apps/server/src/lib/metadata/tags.ts @@ -0,0 +1,27 @@ +import { parseFile, selectCover } from "music-metadata"; +import type { TagData } from "./types.js"; + +export async function readTags(filePath: string): Promise { + try { + const metadata = await parseFile(filePath, { duration: true }); + const common = metadata.common; + const cover = selectCover(common.picture); + return { + title: common.title?.trim() || undefined, + artist: common.artist?.trim() || undefined, + artists: common.artists?.length ? common.artists : undefined, + albumArtist: common.albumartist?.trim() || undefined, + album: common.album?.trim() || undefined, + date: common.date || (common.year ? String(common.year) : undefined), + trackNumber: common.track?.no ?? undefined, + discNumber: common.disk?.no ?? undefined, + durationMs: metadata.format.duration + ? Math.round(metadata.format.duration * 1000) + : undefined, + pictureBuffer: cover?.data ? Buffer.from(cover.data) : undefined, + pictureMime: cover?.format, + }; + } catch { + return {}; + } +} diff --git a/apps/server/src/lib/metadata/types.ts b/apps/server/src/lib/metadata/types.ts new file mode 100644 index 0000000..8b9f1d9 --- /dev/null +++ b/apps/server/src/lib/metadata/types.ts @@ -0,0 +1,76 @@ +import type { LyricsLine } from "../../db/schema.js"; + +export interface TagData { + title?: string; + artist?: string; + artists?: string[]; + albumArtist?: string; + album?: string; + date?: string; + trackNumber?: number; + discNumber?: number; + durationMs?: number; + pictureBuffer?: Buffer; + pictureMime?: string; +} + +export interface ArtistCredit { + name: string; + mbArtistId?: string; + role: string; +} + +export interface Enrichment { + title?: string; + artistName?: string; + artists?: ArtistCredit[]; + albumTitle?: string; + releaseDate?: string; + trackNumber?: number; + discNumber?: number; + durationMs?: number; + mbRecordingId?: string; + mbArtistId?: string; + mbReleaseGroupId?: string; + mbReleaseId?: string; + coverUrl?: string; + artistDescription?: string; + artistImageUrl?: string; + lyricsPlain?: string; + lyricsSynced?: LyricsLine[] | null; + lyricsSource?: string; + fingerprint?: string; + sources: string[]; +} + +export function cleanFilename(name: string): string { + return name + .replace(/\.[a-z0-9]{2,5}$/i, "") + .replace(/[_]+/g, " ") + .replace(/\s*-\s*/g, " - ") + .replace(/\s+/g, " ") + .trim(); +} + +const FEAT_RE = /\s+(?:feat\.?|ft\.?|featuring|при\s+участии|совместно\s+с)\s+/i; +const SPLIT_RE = /\s*(?:,|;|&|\u00d7|\bx\b|\/\s)\s*/i; + +/** + * Splits a combined artist string like "A, B feat. C" into separate credits. + * Everything before a "feat." marker is `main`, the rest is `featured`. + */ +export function parseArtistList(raw?: string | null): ArtistCredit[] { + if (!raw) return []; + const [mainPart, ...featParts] = raw.split(FEAT_RE); + const result: ArtistCredit[] = []; + const add = (name: string, role: string) => { + const trimmed = name.trim().replace(/^["'\-]+|["'\-]+$/g, "").trim(); + if (!trimmed) return; + if (result.some((a) => a.name.toLowerCase() === trimmed.toLowerCase())) return; + result.push({ name: trimmed, role }); + }; + for (const n of mainPart.split(SPLIT_RE)) add(n, "main"); + for (const fp of featParts) for (const n of fp.split(SPLIT_RE)) add(n, "featured"); + return result; +} + diff --git a/apps/server/src/lib/metadata/wikipedia.ts b/apps/server/src/lib/metadata/wikipedia.ts new file mode 100644 index 0000000..cb3d09a --- /dev/null +++ b/apps/server/src/lib/metadata/wikipedia.ts @@ -0,0 +1,49 @@ +import { logger } from "../logger.js"; + +export interface ArtistInfo { + extract?: string; + imageUrl?: string; + url?: string; +} + +interface WikiSummary { + type?: string; + extract?: string; + content_urls?: { desktop?: { page?: string } }; + thumbnail?: { source?: string }; + originalimage?: { source?: string }; +} + +async function fetchSummary(lang: string, title: string): Promise { + const url = new URL( + `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`, + ); + url.searchParams.set("redirect", "true"); + try { + const res = await fetch(url, { + headers: { "User-Agent": "ADCRetivo/1.0 (https://github.com/adc-retivo)" }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) return null; + return (await res.json()) as WikiSummary; + } catch { + return null; + } +} + +/** Fetches a short artist biography, trying Russian Wikipedia first, then English. */ +export async function fetchArtistInfo(name: string): Promise { + if (!name) return null; + for (const lang of ["ru", "en"]) { + const summary = await fetchSummary(lang, name); + if (summary?.extract && summary.type !== "disambiguation") { + return { + extract: summary.extract, + imageUrl: summary.originalimage?.source ?? summary.thumbnail?.source, + url: summary.content_urls?.desktop?.page, + }; + } + } + logger.debug({ name }, "no wikipedia info found"); + return null; +} diff --git a/apps/server/src/lib/password.ts b/apps/server/src/lib/password.ts new file mode 100644 index 0000000..aedf13d --- /dev/null +++ b/apps/server/src/lib/password.ts @@ -0,0 +1,9 @@ +import bcrypt from "bcryptjs"; + +export function hashPassword(plain: string): Promise { + return bcrypt.hash(plain, 10); +} + +export function verifyPassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash); +} diff --git a/apps/server/src/lib/permissions.ts b/apps/server/src/lib/permissions.ts new file mode 100644 index 0000000..271f10e --- /dev/null +++ b/apps/server/src/lib/permissions.ts @@ -0,0 +1,27 @@ +import type { Permissions, User } from "../db/schema.js"; +import { superAdminPermissions } from "../db/schema.js"; + +export type PermissionKey = keyof Permissions; + +export function resolvePermissions(user: Pick): Permissions { + if (user.isSuperAdmin) return superAdminPermissions; + return { ...user.permissions }; +} + +export function hasPerm( + user: Pick | null | undefined, + key: PermissionKey, +): boolean { + if (!user) return false; + if (user.isSuperAdmin) return true; + return Boolean(user.permissions?.[key]); +} + +export function canAccessAdmin(user: Pick): boolean { + if (user.isSuperAdmin) return true; + const p = user.permissions; + return Boolean( + p && + (p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists), + ); +} diff --git a/apps/server/src/lib/presenters.ts b/apps/server/src/lib/presenters.ts new file mode 100644 index 0000000..7c36a7f --- /dev/null +++ b/apps/server/src/lib/presenters.ts @@ -0,0 +1,181 @@ +import type { + Album, + Artist, + Comment, + Permissions, + Playlist, + Track, + User, +} from "../db/schema.js"; +import { resolvePermissions } from "./permissions.js"; + +export interface PublicUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + bio: string | null; + createdAt: Date; +} + +export function publicUser(u: User | null | undefined): PublicUser | null { + if (!u) return null; + return { + id: u.id, + username: u.username, + displayName: u.displayName, + avatarUrl: u.avatarUrl, + bio: u.bio, + createdAt: u.createdAt, + }; +} + +export interface SelfUser extends PublicUser { + email: string; + isSuperAdmin: boolean; + permissions: Permissions; +} + +export function selfUser(u: User): SelfUser { + return { + ...publicUser(u)!, + email: u.email, + isSuperAdmin: u.isSuperAdmin, + permissions: resolvePermissions(u), + }; +} + +export function adminUser(u: User): SelfUser { + return selfUser(u); +} + +type TrackWithRelations = Track & { + artist?: Artist | null; + album?: Album | null; + uploader?: User | null; + artists?: { position: number; role: string; artist?: Artist | null }[]; +}; + +export function trackDTO(t: TrackWithRelations, opts: { includeUploader?: boolean } = {}) { + const credits = (t.artists ?? []) + .filter((c) => c.artist) + .slice() + .sort((a, b) => a.position - b.position) + .map((c) => ({ + id: c.artist!.id, + name: c.artist!.name, + imageUrl: c.artist!.imageUrl, + role: c.role, + })); + + const primary = + credits[0] ?? + (t.artist ? { id: t.artist.id, name: t.artist.name, imageUrl: t.artist.imageUrl, role: "main" } : null); + + return { + id: t.id, + title: t.title, + durationMs: t.durationMs, + trackNumber: t.trackNumber, + discNumber: t.discNumber, + releaseDate: t.releaseDate, + status: t.status, + rejectionReason: t.rejectionReason, + coverUrl: t.coverUrl, + playCount: t.playCount, + hasLyrics: Boolean(t.lyricsPlain || t.lyricsSynced?.length), + lyricsSource: t.lyricsSource, + createdAt: t.createdAt, + approvedAt: t.approvedAt, + artist: primary, + artists: credits, + album: t.album + ? { id: t.album.id, title: t.album.title, coverUrl: t.album.coverUrl } + : null, + uploadedBy: + opts.includeUploader === false ? undefined : publicUser(t.uploader), + }; +} + +export type TrackDTO = ReturnType; + +export function artistDTO(a: Artist & { followersCount?: number }) { + return { + id: a.id, + name: a.name, + sortName: a.sortName, + description: a.description, + imageUrl: a.imageUrl, + country: a.country, + mbArtistId: a.mbArtistId, + followersCount: a.followersCount ?? 0, + }; +} + +export function albumDTO(a: Album & { artist?: Artist | null }) { + return { + id: a.id, + title: a.title, + releaseDate: a.releaseDate, + coverUrl: a.coverUrl, + artist: a.artist ? { id: a.artist.id, name: a.artist.name, imageUrl: a.artist.imageUrl } : null, + }; +} + +type PlaylistWithRelations = Playlist & { + owner?: User | null; + entries?: { trackId: string }[]; + collaborators?: { userId: string; canEdit: boolean }[]; +}; + +export function playlistDTO( + p: PlaylistWithRelations, + extra: { trackCount?: number; canEdit?: boolean; isOwner?: boolean } = {}, +) { + return { + id: p.id, + title: p.title, + description: p.description, + coverUrl: p.coverUrl, + visibility: p.visibility, + isShareable: p.isShareable, + allowCollaboratorEdit: p.allowCollaboratorEdit, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + owner: publicUser(p.owner), + trackCount: extra.trackCount ?? p.entries?.length ?? 0, + canEdit: extra.canEdit ?? false, + isOwner: extra.isOwner ?? false, + }; +} + +export type PlaylistDTO = ReturnType; + +export interface CommentDTO { + id: string; + trackId: string; + parentId: string | null; + body: string | null; + deleted: boolean; + createdAt: Date; + updatedAt: Date; + user: PublicUser | null; + replies: CommentDTO[]; +} + +export function commentDTO( + c: Comment & { user?: User | null }, + replies: CommentDTO[] = [], +): CommentDTO { + return { + id: c.id, + trackId: c.trackId, + parentId: c.parentId, + body: c.deleted ? null : c.body, + deleted: c.deleted, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + user: publicUser(c.user), + replies, + }; +} diff --git a/apps/server/src/lib/queue.ts b/apps/server/src/lib/queue.ts new file mode 100644 index 0000000..2d408d1 --- /dev/null +++ b/apps/server/src/lib/queue.ts @@ -0,0 +1,33 @@ +import { Queue } from "bullmq"; +import { newRedis } from "./redis.js"; + +export const METADATA_QUEUE = "adc-metadata"; + +export interface MetadataJobData { + trackId: string; + filePath: string; + mimeType?: string; + originalName?: string; +} + +const connection = newRedis(); + +export const metadataQueue = new Queue(METADATA_QUEUE, { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { type: "exponential", delay: 5000 }, + removeOnComplete: 100, + removeOnFail: 500, + }, +}); + +export async function enqueueMetadata( + data: MetadataJobData, + opts: { force?: boolean } = {}, +) { + // `force` (manual retry) bypasses the dedupe jobId so the job actually runs again. + return metadataQueue.add("process", data, { + jobId: opts.force ? undefined : `track-${data.trackId}`, + }); +} diff --git a/apps/server/src/lib/redis.ts b/apps/server/src/lib/redis.ts new file mode 100644 index 0000000..37f9384 --- /dev/null +++ b/apps/server/src/lib/redis.ts @@ -0,0 +1,21 @@ +import { Redis } from "ioredis"; +import { env } from "../env.js"; +import { logger } from "./logger.js"; + +let client: Redis | null = null; + +export function getRedis(): Redis { + if (!client) { + client = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + lazyConnect: false, + }); + client.on("error", (err) => logger.error({ err }, "redis error")); + client.on("connect", () => logger.info("redis connected")); + } + return client; +} + +export function newRedis(): Redis { + return new Redis(env.REDIS_URL, { maxRetriesPerRequest: null }); +} diff --git a/apps/server/src/lib/session.ts b/apps/server/src/lib/session.ts new file mode 100644 index 0000000..7572ccc --- /dev/null +++ b/apps/server/src/lib/session.ts @@ -0,0 +1,70 @@ +import { randomBytes } from "node:crypto"; +import type { CookieOptions } from "express"; +import { env, isProd } from "../env.js"; +import { getRedis } from "./redis.js"; + +export const SESSION_COOKIE = "adc_sid"; +const PREFIX = "adc:sess:"; +const TTL_SECONDS = env.SESSION_TTL_DAYS * 24 * 60 * 60; + +export interface SessionData { + userId: string; + createdAt: number; +} + +export async function createSession(userId: string): Promise { + const id = randomBytes(32).toString("base64url"); + const data: SessionData = { userId, createdAt: Date.now() }; + await getRedis().set(PREFIX + id, JSON.stringify(data), "EX", TTL_SECONDS); + return id; +} + +export async function getSession(id: string | undefined | null): Promise { + if (!id) return null; + const raw = await getRedis().get(PREFIX + id); + if (!raw) return null; + // Sliding expiration + await getRedis().expire(PREFIX + id, TTL_SECONDS); + try { + return JSON.parse(raw) as SessionData; + } catch { + return null; + } +} + +export async function destroySession(id: string | undefined | null): Promise { + if (!id) return; + await getRedis().del(PREFIX + id); +} + +export async function destroyAllUserSessions(userId: string): Promise { + const redis = getRedis(); + let cursor = "0"; + do { + const [next, keys] = await redis.scan(cursor, "MATCH", PREFIX + "*", "COUNT", 100); + cursor = next; + if (keys.length) { + const values = await redis.mget(...keys); + const toDelete = keys.filter((k, i) => { + const v = values[i]; + if (!v) return false; + try { + return (JSON.parse(v) as SessionData).userId === userId; + } catch { + return false; + } + }); + if (toDelete.length) await redis.del(...toDelete); + } + } while (cursor !== "0"); +} + +export function sessionCookieOptions(): CookieOptions { + return { + httpOnly: true, + sameSite: "lax", + secure: isProd, + path: "/", + maxAge: TTL_SECONDS * 1000, + }; +} diff --git a/apps/server/src/lib/storage.ts b/apps/server/src/lib/storage.ts new file mode 100644 index 0000000..678786b --- /dev/null +++ b/apps/server/src/lib/storage.ts @@ -0,0 +1,81 @@ +import { Client as MinioClient } from "minio"; +import fs from "node:fs"; +import { env } from "../env.js"; +import { logger } from "./logger.js"; + +export const minio = new MinioClient({ + endPoint: env.MINIO_ENDPOINT, + port: env.MINIO_PORT, + useSSL: env.MINIO_SSL, + accessKey: env.MINIO_ACCESS_KEY, + secretKey: env.MINIO_SECRET_KEY, +}); + +export const BUCKET = env.MINIO_BUCKET; + +export async function ensureBucket(): Promise { + const exists = await minio.bucketExists(BUCKET).catch(() => false); + if (!exists) { + await minio.makeBucket(BUCKET); + logger.info({ bucket: BUCKET }, "created object storage bucket"); + } +} + +export async function putFile( + key: string, + filePath: string, + contentType?: string, +): Promise { + await minio.fPutObject(BUCKET, key, filePath, { + "Content-Type": contentType ?? "application/octet-stream", + }); +} + +export async function putBuffer( + key: string, + buffer: Buffer, + contentType?: string, +): Promise { + await minio.putObject(BUCKET, key, buffer, buffer.length, { + "Content-Type": contentType ?? "application/octet-stream", + }); +} + +export async function removeObject(key: string): Promise { + await minio.removeObject(BUCKET, key).catch((err) => { + logger.warn({ err, key }, "failed to remove object"); + }); +} + +export interface ObjectStat { + size: number; + contentType: string; + etag: string; +} + +export async function statObject(key: string): Promise { + try { + const stat = await minio.statObject(BUCKET, key); + return { + size: stat.size, + contentType: (stat.metaData?.["content-type"] as string) ?? "application/octet-stream", + etag: stat.etag, + }; + } catch { + return null; + } +} + +export async function streamObject(key: string, start?: number, end?: number) { + const from = start ?? 0; + return minio.getPartialObject( + BUCKET, + key, + from, + end !== undefined ? end - from + 1 : undefined, + ); +} + +export async function putFileFromBufferWithType(key: string, buffer: Buffer, contentType: string) { + await putBuffer(key, buffer, contentType); +} diff --git a/apps/server/src/middleware/auth.ts b/apps/server/src/middleware/auth.ts new file mode 100644 index 0000000..92d0ede --- /dev/null +++ b/apps/server/src/middleware/auth.ts @@ -0,0 +1,43 @@ +import type { NextFunction, Request, Response } from "express"; +import { eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { forbidden, unauthorized } from "../lib/http.js"; +import { canAccessAdmin, hasPerm, type PermissionKey } from "../lib/permissions.js"; +import { SESSION_COOKIE, getSession } from "../lib/session.js"; + +export async function attachUser(req: Request, _res: Response, next: NextFunction) { + try { + const sid = req.cookies?.[SESSION_COOKIE]; + if (!sid) return next(); + const session = await getSession(sid); + if (!session) return next(); + const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); + if (user) { + req.user = user; + req.sessionId = sid; + } + next(); + } catch (err) { + next(err); + } +} + +export function requireAuth(req: Request, _res: Response, next: NextFunction) { + if (!req.user) return next(unauthorized()); + next(); +} + +export function requirePermission(key: PermissionKey) { + return (req: Request, _res: Response, next: NextFunction) => { + if (!req.user) return next(unauthorized()); + if (!hasPerm(req.user, key)) return next(forbidden()); + next(); + }; +} + +export function requireAdmin(req: Request, _res: Response, next: NextFunction) { + if (!req.user) return next(unauthorized()); + if (!canAccessAdmin(req.user)) return next(forbidden()); + next(); +} diff --git a/apps/server/src/routes/admin.ts b/apps/server/src/routes/admin.ts new file mode 100644 index 0000000..b6e0cb4 --- /dev/null +++ b/apps/server/src/routes/admin.ts @@ -0,0 +1,324 @@ +import { Router } from "express"; +import bcrypt from "bcryptjs"; +import { and, desc, eq, ne, sql } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { + artists, + comments, + defaultPermissions, + playlists, + tracks, + users, + superAdminPermissions, + type Permissions, +} from "../db/schema.js"; +import { + HttpError, + asyncHandler, + assertUuid, + badRequest, + conflict, + notFound, + parseBody, + parsePagination, +} from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { adminUser, playlistDTO, trackDTO } from "../lib/presenters.js"; +import { destroyAllUserSessions } from "../lib/session.js"; +import { requireAdmin, requirePermission } from "../middleware/auth.js"; + +export const adminRouter = Router(); + +adminRouter.use("/admin", requireAdmin); + +/* ------------------------------- stats ------------------------------- */ + +adminRouter.get( + "/admin/stats", + asyncHandler(async (_req, res) => { + const [{ tracks: trackCount }] = await db + .select({ tracks: sql`count(*)::int` }) + .from(tracks); + const [{ pending }] = await db + .select({ pending: sql`count(*)::int` }) + .from(tracks) + .where(eq(tracks.status, "pending")); + const [{ approved }] = await db + .select({ approved: sql`count(*)::int` }) + .from(tracks) + .where(eq(tracks.status, "approved")); + const [{ rejected }] = await db + .select({ rejected: sql`count(*)::int` }) + .from(tracks) + .where(eq(tracks.status, "rejected")); + const [{ users: userCount }] = await db.select({ users: sql`count(*)::int` }).from(users); + const [{ playlists: playlistCount }] = await db + .select({ playlists: sql`count(*)::int` }) + .from(playlists); + const [{ artists: artistCount }] = await db + .select({ artists: sql`count(*)::int` }) + .from(artists); + const [{ comments: commentCount }] = await db + .select({ comments: sql`count(*)::int` }) + .from(comments); + + res.json({ + tracks: { total: trackCount, pending, approved, rejected }, + users: userCount, + playlists: playlistCount, + artists: artistCount, + comments: commentCount, + }); + }), +); + +/* ------------------------------- users ------------------------------- */ + +adminRouter.get( + "/admin/users", + requirePermission("canManageUsers"), + asyncHandler(async (_req, res) => { + const rows = await db.select().from(users).orderBy(desc(users.createdAt)); + res.json({ items: rows.map(adminUser) }); + }), +); + +const permissionsSchema = z.object({ + canCreatePlaylists: z.boolean().optional(), + canUploadTracks: z.boolean().optional(), + canAutoApprove: z.boolean().optional(), + canModerate: z.boolean().optional(), + canManageUsers: z.boolean().optional(), + canManageLibrary: z.boolean().optional(), + canViewAllPlaylists: z.boolean().optional(), +}); + +const createUserSchema = z.object({ + email: z.string().email(), + username: z.string().min(3).max(60).regex(/^[a-zA-Z0-9_.-]+$/, "Только латиница, цифры, _. -"), + password: z.string().min(6).max(200), + displayName: z.string().max(120).optional(), + isSuperAdmin: z.boolean().optional(), + permissions: permissionsSchema.optional(), +}); + +adminRouter.post( + "/admin/users", + requirePermission("canManageUsers"), + asyncHandler(async (req, res) => { + const data = parseBody(createUserSchema, req.body); + const email = data.email.toLowerCase(); + + const [existing] = await db + .select() + .from(users) + .where( + sql`lower(${users.email}) = ${email} OR lower(${users.username}) = ${data.username.toLowerCase()}`, + ) + .limit(1); + if (existing) throw conflict("Пользователь с такой почтой или именем уже существует"); + + const isSuper = Boolean(data.isSuperAdmin) && req.user!.isSuperAdmin; + const [created] = await db + .insert(users) + .values({ + email, + username: data.username, + displayName: data.displayName ?? data.username, + passwordHash: await bcrypt.hash(data.password, 10), + isSuperAdmin: isSuper, + permissions: isSuper + ? superAdminPermissions + : { ...defaultPermissions, ...(data.permissions ?? {}) }, + }) + .returning(); + + res.status(201).json({ user: adminUser(created) }); + }), +); + +const updateUserSchema = z.object({ + displayName: z.string().max(120).nullable().optional(), + bio: z.string().max(2000).nullable().optional(), + avatarUrl: z.string().nullable().optional(), + email: z.string().email().optional(), + username: z.string().min(3).max(60).optional(), + isSuperAdmin: z.boolean().optional(), + permissions: permissionsSchema.optional(), +}); + +adminRouter.patch( + "/admin/users/:id", + requirePermission("canManageUsers"), + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1); + if (!target) throw notFound("Пользователь не найден"); + + const data = parseBody(updateUserSchema, req.body); + const patch: Partial = { updatedAt: new Date() }; + + if (data.email) patch.email = data.email.toLowerCase(); + if (data.username) patch.username = data.username; + if (data.displayName !== undefined) patch.displayName = data.displayName; + if (data.bio !== undefined) patch.bio = data.bio; + if (data.avatarUrl !== undefined) patch.avatarUrl = data.avatarUrl; + + if (data.permissions) { + const merged: Permissions = { ...target.permissions, ...data.permissions }; + patch.permissions = target.isSuperAdmin ? superAdminPermissions : merged; + } + + if (data.isSuperAdmin !== undefined) { + if (!req.user!.isSuperAdmin) throw new HttpError(403, "Только супер-админ может менять этот флаг"); + if (target.id === req.user!.id && !data.isSuperAdmin) { + throw badRequest("Нельзя снять права супер-админа с самого себя"); + } + patch.isSuperAdmin = data.isSuperAdmin; + if (data.isSuperAdmin) patch.permissions = superAdminPermissions; + } + + await db.update(users).set(patch).where(eq(users.id, id)); + const [updated] = await db.select().from(users).where(eq(users.id, id)).limit(1); + res.json({ user: adminUser(updated) }); + }), +); + +const resetPasswordSchema = z.object({ password: z.string().min(6).max(200) }); + +adminRouter.post( + "/admin/users/:id/password", + requirePermission("canManageUsers"), + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const { password } = parseBody(resetPasswordSchema, req.body); + await db + .update(users) + .set({ passwordHash: await bcrypt.hash(password, 10), updatedAt: new Date() }) + .where(eq(users.id, id)); + await destroyAllUserSessions(id); + res.json({ ok: true }); + }), +); + +adminRouter.delete( + "/admin/users/:id", + requirePermission("canManageUsers"), + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + if (id === req.user!.id) throw badRequest("Нельзя удалить самого себя"); + const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1); + if (!target) throw notFound("Пользователь не найден"); + if (target.isSuperAdmin && !req.user!.isSuperAdmin) { + throw new HttpError(403, "Только супер-админ может удалять супер-админов"); + } + await db.delete(users).where(eq(users.id, id)); + res.json({ ok: true }); + }), +); + +/* ---------------------------- moderation ---------------------------- */ + +adminRouter.get( + "/admin/moderation", + requirePermission("canModerate"), + asyncHandler(async (req, res) => { + const { limit, offset } = parsePagination(req, 50); + const rows = await db.query.tracks.findMany({ + where: eq(tracks.status, "pending"), + with: trackWith, + orderBy: [desc(tracks.createdAt)], + limit, + offset, + }); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(tracks) + .where(eq(tracks.status, "pending")); + res.json({ items: rows.map((t) => trackDTO(t as never)), total: count }); + }), +); + +adminRouter.post( + "/admin/tracks/:id/approve", + requirePermission("canModerate"), + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + await db + .update(tracks) + .set({ + status: "approved", + approvedAt: new Date(), + rejectionReason: null, + metadata: sql`coalesce(${tracks.metadata}, '{}'::jsonb) - 'processing'`, + updatedAt: new Date(), + }) + .where(eq(tracks.id, id)); + res.json({ ok: true }); + }), +); + +const rejectSchema = z.object({ reason: z.string().max(1000).optional() }); + +adminRouter.post( + "/admin/tracks/:id/reject", + requirePermission("canModerate"), + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const { reason } = parseBody(rejectSchema, req.body); + await db + .update(tracks) + .set({ status: "rejected", rejectionReason: reason ?? null, updatedAt: new Date() }) + .where(eq(tracks.id, id)); + res.json({ ok: true }); + }), +); + +/* ------------------------------ tracks ------------------------------ */ + +adminRouter.get( + "/admin/tracks", + requirePermission("canManageLibrary"), + asyncHandler(async (req, res) => { + const { limit, offset } = parsePagination(req, 100); + const status = typeof req.query.status === "string" ? req.query.status : undefined; + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const filters: any[] = []; + if (status && ["pending", "approved", "rejected"].includes(status)) { + filters.push(eq(tracks.status, status as "pending" | "approved" | "rejected")); + } + if (q) filters.push(sql`${tracks.title} ILIKE ${"%" + q + "%"}`); + + const rows = await db.query.tracks.findMany({ + where: filters.length ? and(...filters) : undefined, + with: trackWith, + orderBy: [desc(tracks.createdAt)], + limit, + offset, + }); + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(tracks) + .where(filters.length ? and(...filters) : undefined); + res.json({ items: rows.map((t) => trackDTO(t as never)), total: count }); + }), +); + +/* ----------------------------- playlists ----------------------------- */ +adminRouter.get( + "/admin/playlists", + requirePermission("canViewAllPlaylists"), + asyncHandler(async (req, res) => { + const { limit, offset } = parsePagination(req, 100); + const rows = await db.query.playlists.findMany({ + with: { owner: true, entries: true }, + orderBy: [desc(playlists.updatedAt)], + limit, + offset, + }); + const [{ count }] = await db.select({ count: sql`count(*)::int` }).from(playlists); + res.json({ items: rows.map((p) => playlistDTO(p as never)), total: count }); + }), +); diff --git a/apps/server/src/routes/albums.ts b/apps/server/src/routes/albums.ts new file mode 100644 index 0000000..793bc00 --- /dev/null +++ b/apps/server/src/routes/albums.ts @@ -0,0 +1,60 @@ +import { Router } from "express"; +import { and, asc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { albums, artists, tracks } from "../db/schema.js"; +import { HttpError, asyncHandler, assertUuid, notFound, parseBody } from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { albumDTO, trackDTO } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const albumsRouter = Router(); + +albumsRouter.get( + "/albums/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [album] = await db.select().from(albums).where(eq(albums.id, id)).limit(1); + if (!album) throw notFound("Альбом не найден"); + + let artist = null; + if (album.artistId) { + [artist] = await db.select().from(artists).where(eq(artists.id, album.artistId)).limit(1); + } + + const rows = await db.query.tracks.findMany({ + where: and(eq(tracks.albumId, id), eq(tracks.status, "approved")), + with: trackWith, + orderBy: [asc(tracks.discNumber), asc(tracks.trackNumber), asc(tracks.title)], + }); + + res.json({ + album: albumDTO({ ...album, artist: artist ?? null }), + tracks: rows.map((t) => trackDTO(t as never)), + canEdit: hasPerm(req.user, "canManageLibrary"), + }); + }), +); + +const patchSchema = z.object({ + title: z.string().min(1).max(300).optional(), + artistId: z.string().uuid().nullable().optional(), + releaseDate: z.string().nullable().optional(), + coverUrl: z.string().nullable().optional(), +}); + +albumsRouter.patch( + "/albums/:id", + requireAuth, + asyncHandler(async (req, res) => { + if (!hasPerm(req.user, "canManageLibrary")) throw new HttpError(403, "Нет прав"); + const id = assertUuid(req.params.id); + const data = parseBody(patchSchema, req.body); + await db.update(albums).set({ ...data, updatedAt: new Date() }).where(eq(albums.id, id)); + const [album] = await db.select().from(albums).where(eq(albums.id, id)).limit(1); + if (!album) throw notFound("Альбом не найден"); + res.json({ album: albumDTO(album) }); + }), +); diff --git a/apps/server/src/routes/artists.ts b/apps/server/src/routes/artists.ts new file mode 100644 index 0000000..aa6a0d9 --- /dev/null +++ b/apps/server/src/routes/artists.ts @@ -0,0 +1,142 @@ +import { Router } from "express"; +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { albums, artistFollows, artists, trackArtists, tracks } from "../db/schema.js"; +import { asyncHandler, assertUuid, notFound, parseBody, parsePagination } from "../lib/http.js"; +import { HttpError } from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { albumDTO, artistDTO, trackDTO } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const artistsRouter = Router(); + +artistsRouter.get( + "/artists/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1); + if (!artist) throw notFound("Артист не найден"); + + const [{ count: followersCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(artistFollows) + .where(eq(artistFollows.artistId, id)); + + const [following] = await db + .select() + .from(artistFollows) + .where(and(eq(artistFollows.artistId, id), eq(artistFollows.userId, req.user!.id))) + .limit(1); + + const albumRows = await db + .select() + .from(albums) + .where(eq(albums.artistId, id)) + .orderBy(desc(albums.releaseDate)) + .limit(50); + + const [{ count: trackCount }] = await db + .select({ count: sql`count(distinct ${trackArtists.trackId})::int` }) + .from(trackArtists) + .innerJoin(tracks, eq(trackArtists.trackId, tracks.id)) + .where(and(eq(trackArtists.artistId, id), eq(tracks.status, "approved"))); + + res.json({ + artist: { ...artistDTO({ ...artist, followersCount }), trackCount }, + following: Boolean(following), + albums: albumRows.map((a) => albumDTO({ ...a, artist })), + canEdit: hasPerm(req.user, "canManageLibrary"), + }); + }), +); + +artistsRouter.get( + "/artists/:id/tracks", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const { limit, offset } = parsePagination(req, 50); + const rows = await db.query.tracks.findMany({ + where: and( + eq(tracks.status, "approved"), + inArray( + tracks.id, + db.select({ id: trackArtists.trackId }).from(trackArtists).where(eq(trackArtists.artistId, id)), + ), + ), + with: trackWith, + orderBy: [desc(tracks.createdAt)], + limit, + offset, + }); + res.json({ items: rows.map((t) => trackDTO(t as never)) }); + }), +); + +artistsRouter.get( + "/artists/:id/albums", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1); + if (!artist) throw notFound("Артист не найден"); + const rows = await db + .select() + .from(albums) + .where(eq(albums.artistId, id)) + .orderBy(desc(albums.releaseDate)); + res.json({ items: rows.map((a) => albumDTO({ ...a, artist })) }); + }), +); + +const patchSchema = z.object({ + name: z.string().min(1).max(300).optional(), + sortName: z.string().nullable().optional(), + description: z.string().nullable().optional(), + imageUrl: z.string().nullable().optional(), + country: z.string().nullable().optional(), +}); + +artistsRouter.patch( + "/artists/:id", + requireAuth, + asyncHandler(async (req, res) => { + if (!hasPerm(req.user, "canManageLibrary")) { + throw new HttpError(403, "Нет прав"); + } + const id = assertUuid(req.params.id); + const data = parseBody(patchSchema, req.body); + await db.update(artists).set({ ...data, updatedAt: new Date() }).where(eq(artists.id, id)); + const [artist] = await db.select().from(artists).where(eq(artists.id, id)).limit(1); + if (!artist) throw notFound("Артист не найден"); + res.json({ artist: artistDTO(artist) }); + }), +); + +artistsRouter.post( + "/artists/:id/follow", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + await db + .insert(artistFollows) + .values({ artistId: id, userId: req.user!.id }) + .onConflictDoNothing(); + res.json({ following: true }); + }), +); + +artistsRouter.delete( + "/artists/:id/follow", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + await db + .delete(artistFollows) + .where(and(eq(artistFollows.artistId, id), eq(artistFollows.userId, req.user!.id))); + res.json({ following: false }); + }), +); diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts new file mode 100644 index 0000000..674c106 --- /dev/null +++ b/apps/server/src/routes/auth.ts @@ -0,0 +1,68 @@ +import { Router } from "express"; +import { eq, or, sql } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { asyncHandler, unauthorized } from "../lib/http.js"; +import { verifyPassword } from "../lib/password.js"; +import { selfUser } from "../lib/presenters.js"; +import { + SESSION_COOKIE, + createSession, + destroySession, + sessionCookieOptions, +} from "../lib/session.js"; +import { parseBody } from "../lib/http.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const authRouter = Router(); + +const loginSchema = z.object({ + login: z.string().min(1), + password: z.string().min(1), +}); + +authRouter.post( + "/login", + asyncHandler(async (req, res) => { + const { login, password } = parseBody(loginSchema, req.body); + const value = login.trim().toLowerCase(); + const [user] = await db + .select() + .from(users) + .where(or(sql`lower(${users.email}) = ${value}`, sql`lower(${users.username}) = ${value}`)) + .limit(1); + + if (!user || !(await verifyPassword(password, user.passwordHash))) { + throw unauthorized("Неверная почта/логин или пароль"); + } + + const sid = await createSession(user.id); + res.cookie(SESSION_COOKIE, sid, sessionCookieOptions()); + res.json({ user: selfUser(user) }); + }), +); + +authRouter.post( + "/logout", + asyncHandler(async (req, res) => { + await destroySession(req.cookies?.[SESSION_COOKIE]); + res.clearCookie(SESSION_COOKIE, { path: "/" }); + res.json({ ok: true }); + }), +); + +authRouter.get( + "/me", + requireAuth, + asyncHandler(async (req, res) => { + res.json({ user: selfUser(req.user!) }); + }), +); + +authRouter.get( + "/session", + asyncHandler(async (req, res) => { + res.json({ user: req.user ? selfUser(req.user) : null }); + }), +); diff --git a/apps/server/src/routes/comments.ts b/apps/server/src/routes/comments.ts new file mode 100644 index 0000000..867e767 --- /dev/null +++ b/apps/server/src/routes/comments.ts @@ -0,0 +1,132 @@ +import { Router } from "express"; +import { and, asc, eq } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { comments } from "../db/schema.js"; +import { HttpError, asyncHandler, assertUuid, notFound, parseBody } from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { commentDTO, type CommentDTO } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const commentsRouter = Router(); + +commentsRouter.get( + "/tracks/:id/comments", + requireAuth, + asyncHandler(async (req, res) => { + const trackId = assertUuid(req.params.id); + const rows = await db.query.comments.findMany({ + where: eq(comments.trackId, trackId), + with: { user: true }, + orderBy: [asc(comments.createdAt)], + }); + + const map = new Map(); + const roots: CommentDTO[] = []; + for (const row of rows) { + map.set(row.id, commentDTO(row as never)); + } + for (const row of rows) { + const dto = map.get(row.id)!; + if (row.parentId && map.has(row.parentId)) { + map.get(row.parentId)!.replies.push(dto); + } else { + roots.push(dto); + } + } + + res.json({ comments: roots, total: rows.length }); + }), +); + +const createSchema = z.object({ + body: z.string().min(1).max(4000), + parentId: z.string().uuid().nullable().optional(), +}); + +commentsRouter.post( + "/tracks/:id/comments", + requireAuth, + asyncHandler(async (req, res) => { + const trackId = assertUuid(req.params.id); + const data = parseBody(createSchema, req.body); + + if (data.parentId) { + const [parent] = await db + .select() + .from(comments) + .where(and(eq(comments.id, data.parentId), eq(comments.trackId, trackId))) + .limit(1); + if (!parent) throw notFound("Комментарий, на который вы отвечаете, не найден"); + } + + const [created] = await db + .insert(comments) + .values({ + trackId, + userId: req.user!.id, + parentId: data.parentId ?? null, + body: data.body, + }) + .returning(); + + const full = await db.query.comments.findFirst({ + where: eq(comments.id, created.id), + with: { user: true }, + }); + res.status(201).json({ comment: commentDTO(full as never) }); + }), +); + +const patchSchema = z.object({ body: z.string().min(1).max(4000) }); + +commentsRouter.patch( + "/comments/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [comment] = await db.select().from(comments).where(eq(comments.id, id)).limit(1); + if (!comment) throw notFound("Комментарий не найден"); + const canEdit = comment.userId === req.user!.id || hasPerm(req.user, "canManageLibrary"); + if (!canEdit) throw new HttpError(403, "Нет прав"); + + const { body } = parseBody(patchSchema, req.body); + await db + .update(comments) + .set({ body, updatedAt: new Date() }) + .where(eq(comments.id, id)); + const full = await db.query.comments.findFirst({ + where: eq(comments.id, id), + with: { user: true }, + }); + res.json({ comment: commentDTO(full as never) }); + }), +); + +commentsRouter.delete( + "/comments/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [comment] = await db.select().from(comments).where(eq(comments.id, id)).limit(1); + if (!comment) throw notFound("Комментарий не найден"); + const canDelete = + comment.userId === req.user!.id || + hasPerm(req.user, "canManageLibrary") || + hasPerm(req.user, "canModerate"); + if (!canDelete) throw new HttpError(403, "Нет прав"); + + // Soft-delete when it has replies so the thread stays readable + const replies = await db + .select({ id: comments.id }) + .from(comments) + .where(eq(comments.parentId, id)) + .limit(1); + if (replies.length) { + await db.update(comments).set({ deleted: true, body: "" }).where(eq(comments.id, id)); + } else { + await db.delete(comments).where(eq(comments.id, id)); + } + res.json({ ok: true }); + }), +); diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts new file mode 100644 index 0000000..09d2248 --- /dev/null +++ b/apps/server/src/routes/index.ts @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { adminRouter } from "./admin.js"; +import { albumsRouter } from "./albums.js"; +import { artistsRouter } from "./artists.js"; +import { authRouter } from "./auth.js"; +import { commentsRouter } from "./comments.js"; +import { libraryRouter } from "./library.js"; +import { mediaRouter } from "./media.js"; +import { meRouter } from "./me.js"; +import { playlistsRouter } from "./playlists.js"; +import { searchRouter } from "./search.js"; +import { tracksRouter } from "./tracks.js"; +import { uploadsRouter } from "./uploads.js"; +import { usersRouter } from "./users.js"; + +export const apiRouter = Router(); + +apiRouter.use("/auth", authRouter); +apiRouter.use(meRouter); +apiRouter.use(mediaRouter); +apiRouter.use(tracksRouter); +apiRouter.use(commentsRouter); +apiRouter.use(uploadsRouter); +apiRouter.use(artistsRouter); +apiRouter.use(albumsRouter); +apiRouter.use(playlistsRouter); +apiRouter.use(libraryRouter); +apiRouter.use(searchRouter); +apiRouter.use(usersRouter); +apiRouter.use(adminRouter); diff --git a/apps/server/src/routes/library.ts b/apps/server/src/routes/library.ts new file mode 100644 index 0000000..4cba7a5 --- /dev/null +++ b/apps/server/src/routes/library.ts @@ -0,0 +1,80 @@ +import { Router } from "express"; +import { desc, eq, inArray, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { + albums, + artistFollows, + artists, + libraryPlaylists, + playlists, + trackLikes, + tracks, +} from "../db/schema.js"; +import { asyncHandler } from "../lib/http.js"; +import { albumDTO, artistDTO, playlistDTO, trackDTO } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const libraryRouter = Router(); + +libraryRouter.get( + "/library", + requireAuth, + asyncHandler(async (req, res) => { + const userId = req.user!.id; + + // Owned playlists + const ownedRows = await db.query.playlists.findMany({ + where: eq(playlists.ownerId, userId), + with: { owner: true, entries: true }, + orderBy: [sql`${playlists.updatedAt} DESC`], + }); + + // Library (added) playlists + const libRows = await db + .select({ + mode: libraryPlaylists.mode, + canEdit: libraryPlaylists.canEdit, + copiedPlaylistId: libraryPlaylists.copiedPlaylistId, + playlist: playlists, + }) + .from(libraryPlaylists) + .innerJoin(playlists, eq(libraryPlaylists.playlistId, playlists.id)) + .where(eq(libraryPlaylists.userId, userId)) + .orderBy(desc(libraryPlaylists.createdAt)); + + // Followed artists + const followRows = await db + .select({ artist: artists }) + .from(artistFollows) + .innerJoin(artists, eq(artistFollows.artistId, artists.id)) + .where(eq(artistFollows.userId, userId)) + .orderBy(desc(artistFollows.createdAt)); + + // Liked tracks + const likedRows = await db.query.tracks.findMany({ + where: inArray( + tracks.id, + db.select({ id: trackLikes.trackId }).from(trackLikes).where(eq(trackLikes.userId, userId)), + ), + with: trackWith, + orderBy: [desc(tracks.createdAt)], + limit: 200, + }); + + res.json({ + ownedPlaylists: ownedRows.map((p) => playlistDTO(p as never, { isOwner: true, canEdit: true })), + savedPlaylists: libRows.map((r) => ({ + mode: r.mode, + canEdit: r.canEdit, + originalPlaylistId: r.playlist.id, + playlist: playlistDTO(r.playlist as never, { + canEdit: r.canEdit || r.playlist.ownerId === userId, + isOwner: r.playlist.ownerId === userId, + }), + })), + followedArtists: followRows.map((r) => artistDTO(r.artist)), + likedTracks: likedRows.map((t) => trackDTO(t as never)), + }); + }), +); diff --git a/apps/server/src/routes/me.ts b/apps/server/src/routes/me.ts new file mode 100644 index 0000000..36ed08e --- /dev/null +++ b/apps/server/src/routes/me.ts @@ -0,0 +1,89 @@ +import { randomUUID } from "node:crypto"; +import { Router } from "express"; +import { eq } from "drizzle-orm"; +import multer from "multer"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { users } from "../db/schema.js"; +import { asyncHandler, badRequest, parseBody } from "../lib/http.js"; +import { hashPassword, verifyPassword } from "../lib/password.js"; +import { selfUser } from "../lib/presenters.js"; +import { destroyAllUserSessions, SESSION_COOKIE, createSession, sessionCookieOptions } from "../lib/session.js"; +import { putBuffer } from "../lib/storage.js"; +import { mediaUrl } from "../services/trackIngest.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const meRouter = Router(); + +const patchSchema = z.object({ + displayName: z.string().max(120).nullable().optional(), + bio: z.string().max(2000).nullable().optional(), + avatarUrl: z.string().nullable().optional(), +}); + +meRouter.patch( + "/me", + requireAuth, + asyncHandler(async (req, res) => { + const data = parseBody(patchSchema, req.body); + await db + .update(users) + .set({ ...data, updatedAt: new Date() }) + .where(eq(users.id, req.user!.id)); + const [user] = await db.select().from(users).where(eq(users.id, req.user!.id)).limit(1); + res.json({ user: selfUser(user) }); + }), +); + +const passwordSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().min(6).max(200), +}); + +meRouter.post( + "/me/password", + requireAuth, + asyncHandler(async (req, res) => { + const { currentPassword, newPassword } = parseBody(passwordSchema, req.body); + const ok = await verifyPassword(currentPassword, req.user!.passwordHash); + if (!ok) throw badRequest("Текущий пароль неверен"); + + const passwordHash = await hashPassword(newPassword); + await db + .update(users) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(users.id, req.user!.id)); + + // invalidate all sessions, then re-issue one for the current client + await destroyAllUserSessions(req.user!.id); + const sid = await createSession(req.user!.id); + res.cookie(SESSION_COOKIE, sid, sessionCookieOptions()); + + res.json({ ok: true }); + }), +); + +const avatarUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith("image/")) cb(null, true); + else cb(new Error("Только изображения")); + }, +}); + +meRouter.post( + "/me/avatar", + requireAuth, + avatarUpload.single("avatar"), + asyncHandler(async (req, res) => { + if (!req.file) throw badRequest("Файл не загружен"); + const ext = (req.file.mimetype.split("/")[1] ?? "png").replace("jpeg", "jpg"); + const key = `avatars/${req.user!.id}/${randomUUID()}.${ext}`; + await putBuffer(key, req.file.buffer, req.file.mimetype); + const avatarUrl = mediaUrl(key); + await db.update(users).set({ avatarUrl, updatedAt: new Date() }).where(eq(users.id, req.user!.id)); + const [user] = await db.select().from(users).where(eq(users.id, req.user!.id)).limit(1); + res.json({ user: selfUser(user) }); + }), +); diff --git a/apps/server/src/routes/media.ts b/apps/server/src/routes/media.ts new file mode 100644 index 0000000..d64c5cd --- /dev/null +++ b/apps/server/src/routes/media.ts @@ -0,0 +1,28 @@ +import { Router } from "express"; +import { BUCKET, minio, statObject } from "../lib/storage.js"; +import { asyncHandler, notFound } from "../lib/http.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const mediaRouter = Router(); + +mediaRouter.get( + "/media/*", + requireAuth, + asyncHandler(async (req, res) => { + const key = decodeURIComponent((req.params as Record)[0] ?? ""); + if (!key || key.includes("..")) throw notFound("Файл не найден"); + const stat = await statObject(key); + if (!stat) throw notFound("Файл не найден"); + + res.setHeader("Content-Type", stat.contentType); + res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); + res.setHeader("Content-Length", stat.size); + + const stream = await minio.getObject(BUCKET, key); + stream.on("error", () => { + if (!res.headersSent) res.status(500).end(); + else res.end(); + }); + stream.pipe(res); + }), +); diff --git a/apps/server/src/routes/playlists.ts b/apps/server/src/routes/playlists.ts new file mode 100644 index 0000000..82f16d6 --- /dev/null +++ b/apps/server/src/routes/playlists.ts @@ -0,0 +1,450 @@ +import { Router } from "express"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { + libraryPlaylists, + playlistCollaborators, + playlistTracks, + playlists, + tracks, + users, +} from "../db/schema.js"; +import { + HttpError, + asyncHandler, + assertUuid, + conflict, + forbidden, + notFound, + parseBody, + parsePagination, + badRequest, +} from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { playlistDTO, publicUser, trackDTO } from "../lib/presenters.js"; +import { canEditPlaylist, canViewPlaylist, getPlaylist } from "../services/access.js"; +import { requireAuth, requirePermission } from "../middleware/auth.js"; + +export const playlistsRouter = Router(); + +async function loadPlaylistFull(id: string) { + const playlist = await db.query.playlists.findFirst({ + where: eq(playlists.id, id), + with: { owner: true, collaborators: true }, + }); + if (!playlist) throw notFound("Плейлист не найден"); + return playlist; +} + +async function entriesFor(playlistId: string) { + const rows = await db.query.playlistTracks.findMany({ + where: eq(playlistTracks.playlistId, playlistId), + with: { track: { with: trackWith }, addedBy: true }, + orderBy: [asc(playlistTracks.position), asc(playlistTracks.createdAt)], + }); + return rows.map((r) => ({ + entryId: r.id, + position: r.position, + addedBy: publicUser(r.addedBy), + track: r.track ? trackDTO(r.track as never) : null, + })); +} + +/* --------------------------- create / list --------------------------- */ + +const createSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().max(2000).nullable().optional(), + visibility: z.enum(["public", "unlisted", "private"]).optional(), + isShareable: z.boolean().optional(), + allowCollaboratorEdit: z.boolean().optional(), +}); + +playlistsRouter.post( + "/playlists", + requirePermission("canCreatePlaylists"), + asyncHandler(async (req, res) => { + const data = parseBody(createSchema, req.body); + const [playlist] = await db + .insert(playlists) + .values({ + ownerId: req.user!.id, + title: data.title, + description: data.description ?? null, + visibility: data.visibility ?? "private", + isShareable: data.isShareable ?? false, + allowCollaboratorEdit: data.allowCollaboratorEdit ?? true, + }) + .returning(); + res.status(201).json({ playlist: playlistDTO({ ...playlist, owner: req.user }, { isOwner: true, canEdit: true, trackCount: 0 }) }); + }), +); + +playlistsRouter.get( + "/me/playlists", + requireAuth, + asyncHandler(async (req, res) => { + const owned = await db.query.playlists.findMany({ + where: eq(playlists.ownerId, req.user!.id), + with: { owner: true, entries: true }, + orderBy: [sql`${playlists.updatedAt} DESC`], + }); + res.json({ + owned: owned.map((p) => playlistDTO(p as never, { isOwner: true, canEdit: true })), + }); + }), +); + +/* ------------------------------- single ------------------------------- */ + +playlistsRouter.get( + "/playlists/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await loadPlaylistFull(id); + if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден"); + + const [libEntry] = await db + .select() + .from(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id))) + .limit(1); + + const entries = await entriesFor(id); + const canEdit = await canEditPlaylist(req.user, playlist); + + res.json({ + playlist: playlistDTO(playlist as never, { + canEdit, + isOwner: playlist.ownerId === req.user!.id, + trackCount: entries.length, + }), + entries, + collaborators: playlist.collaborators.map((c) => ({ userId: c.userId, canEdit: c.canEdit })), + inLibrary: Boolean(libEntry), + libraryMode: libEntry?.mode ?? null, + libraryCanEdit: libEntry?.canEdit ?? false, + }); + }), +); + +const patchSchema = z.object({ + title: z.string().min(1).max(200).optional(), + description: z.string().max(2000).nullable().optional(), + coverUrl: z.string().nullable().optional(), + visibility: z.enum(["public", "unlisted", "private"]).optional(), + isShareable: z.boolean().optional(), + allowCollaboratorEdit: z.boolean().optional(), +}); + +playlistsRouter.patch( + "/playlists/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование"); + + const data = parseBody(patchSchema, req.body); + await db.update(playlists).set({ ...data, updatedAt: new Date() }).where(eq(playlists.id, id)); + const updated = await loadPlaylistFull(id); + res.json({ + playlist: playlistDTO(updated as never, { + canEdit: true, + isOwner: updated.ownerId === req.user!.id, + }), + }); + }), +); + +playlistsRouter.delete( + "/playlists/:id", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) { + throw forbidden("Нет прав на удаление"); + } + await db.delete(playlists).where(eq(playlists.id, id)); + res.json({ ok: true }); + }), +); + +/* ------------------------------- tracks ------------------------------- */ + +const addTrackSchema = z.object({ + trackId: z.string().uuid(), + position: z.number().int().min(0).optional(), +}); + +playlistsRouter.post( + "/playlists/:id/tracks", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование"); + + const { trackId, position } = parseBody(addTrackSchema, req.body); + const [track] = await db.select().from(tracks).where(eq(tracks.id, trackId)).limit(1); + if (!track || (track.status !== "approved" && track.uploadedById !== req.user!.id)) { + throw notFound("Трек не найден"); + } + + let pos = position; + if (pos === undefined) { + const [{ max }] = await db + .select({ max: sql`coalesce(max(${playlistTracks.position}), -1)::int` }) + .from(playlistTracks) + .where(eq(playlistTracks.playlistId, id)); + pos = max + 1; + } else { + await db + .update(playlistTracks) + .set({ position: sql`${playlistTracks.position} + 1` }) + .where(and(eq(playlistTracks.playlistId, id), sql`${playlistTracks.position} >= ${pos}`)); + } + + const [entry] = await db + .insert(playlistTracks) + .values({ playlistId: id, trackId, position: pos, addedById: req.user!.id }) + .returning(); + + await db.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id)); + res.status(201).json({ entryId: entry.id, position: entry.position }); + }), +); + +playlistsRouter.delete( + "/playlists/:id/tracks/:entryId", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование"); + + await db + .delete(playlistTracks) + .where(and(eq(playlistTracks.id, assertUuid(req.params.entryId)), eq(playlistTracks.playlistId, id))); + await db.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id)); + res.json({ ok: true }); + }), +); + +const orderSchema = z.object({ entryIds: z.array(z.string().uuid()) }); + +playlistsRouter.put( + "/playlists/:id/order", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (!(await canEditPlaylist(req.user, playlist))) throw forbidden("Нет прав на редактирование"); + + const { entryIds } = parseBody(orderSchema, req.body); + await db.transaction(async (tx) => { + for (let i = 0; i < entryIds.length; i++) { + await tx + .update(playlistTracks) + .set({ position: i }) + .where(and(eq(playlistTracks.id, entryIds[i]), eq(playlistTracks.playlistId, id))); + } + await tx.update(playlists).set({ updatedAt: new Date() }).where(eq(playlists.id, id)); + }); + res.json({ ok: true }); + }), +); + +/* ------------------------------ library ------------------------------ */ + +const librarySchema = z.object({ + mode: z.enum(["reference", "copy"]).default("reference"), +}); + +playlistsRouter.post( + "/playlists/:id/library", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден"); + + const [existing] = await db + .select() + .from(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id))) + .limit(1); + if (existing) throw conflict("Плейлист уже в библиотеке"); + + const { mode } = parseBody(librarySchema, req.body); + + if (mode === "copy") { + const sourceEntries = await db + .select() + .from(playlistTracks) + .where(eq(playlistTracks.playlistId, id)) + .orderBy(asc(playlistTracks.position)); + + const copy = await db.transaction(async (tx) => { + const [created] = await tx + .insert(playlists) + .values({ + ownerId: req.user!.id, + title: playlist.title, + description: playlist.description, + coverUrl: playlist.coverUrl, + visibility: "private", + isShareable: false, + allowCollaboratorEdit: false, + }) + .returning(); + if (sourceEntries.length) { + await tx.insert(playlistTracks).values( + sourceEntries.map((e, i) => ({ + playlistId: created.id, + trackId: e.trackId, + position: i, + addedById: req.user!.id, + })), + ); + } + await tx.insert(libraryPlaylists).values({ + userId: req.user!.id, + playlistId: id, + mode: "copy", + copiedPlaylistId: created.id, + canEdit: true, + }); + return created; + }); + + return res.status(201).json({ + mode: "copy", + copiedPlaylistId: copy.id, + playlist: playlistDTO({ ...copy, owner: req.user }, { isOwner: true, canEdit: true }), + }); + } + + await db.insert(libraryPlaylists).values({ + userId: req.user!.id, + playlistId: id, + mode: "reference", + canEdit: playlist.allowCollaboratorEdit, + }); + res.status(201).json({ mode: "reference", canEdit: playlist.allowCollaboratorEdit }); + }), +); + +playlistsRouter.delete( + "/playlists/:id/library", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const [entry] = await db + .select() + .from(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id))) + .limit(1); + if (entry?.mode === "copy" && entry.copiedPlaylistId) { + // remove the personal copy too + await db + .delete(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id))); + await db.delete(playlists).where(eq(playlists.id, entry.copiedPlaylistId)); + } else { + await db + .delete(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, id), eq(libraryPlaylists.userId, req.user!.id))); + } + res.json({ ok: true }); + }), +); + +/* ---------------------------- collaborators ---------------------------- */ + +playlistsRouter.get( + "/playlists/:id/collaborators", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await loadPlaylistFull(id); + if (!(await canViewPlaylist(req.user, playlist))) throw notFound("Плейлист не найден"); + const ids = playlist.collaborators.map((c) => c.userId); + const usersRows = ids.length + ? await db.select().from(users).where(inArray(users.id, ids)) + : []; + const byId = new Map(usersRows.map((u) => [u.id, u])); + res.json({ + collaborators: playlist.collaborators.map((c) => ({ + canEdit: c.canEdit, + user: publicUser(byId.get(c.userId)), + })), + }); + }), +); + +const collabSchema = z.object({ + username: z.string().min(1), + canEdit: z.boolean().default(true), +}); + +playlistsRouter.post( + "/playlists/:id/collaborators", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) { + throw forbidden("Только владелец может добавлять соавторов"); + } + const { username, canEdit } = parseBody(collabSchema, req.body); + const [user] = await db + .select() + .from(users) + .where(sql`lower(${users.username}) = ${username.toLowerCase()}`) + .limit(1); + if (!user) throw notFound("Пользователь не найден"); + + await db + .insert(playlistCollaborators) + .values({ playlistId: id, userId: user.id, canEdit }) + .onConflictDoUpdate({ + target: [playlistCollaborators.playlistId, playlistCollaborators.userId], + set: { canEdit }, + }); + res.status(201).json({ user: publicUser(user), canEdit }); + }), +); + +playlistsRouter.delete( + "/playlists/:id/collaborators/:userId", + requireAuth, + asyncHandler(async (req, res) => { + const id = assertUuid(req.params.id); + const playlist = await getPlaylist(id); + if (!playlist) throw notFound("Плейлист не найден"); + if (playlist.ownerId !== req.user!.id && !hasPerm(req.user, "canManageLibrary")) { + throw forbidden("Только владелец может удалять соавторов"); + } + await db + .delete(playlistCollaborators) + .where( + and( + eq(playlistCollaborators.playlistId, id), + eq(playlistCollaborators.userId, assertUuid(req.params.userId)), + ), + ); + res.json({ ok: true }); + }), +); diff --git a/apps/server/src/routes/search.ts b/apps/server/src/routes/search.ts new file mode 100644 index 0000000..e2743d2 --- /dev/null +++ b/apps/server/src/routes/search.ts @@ -0,0 +1,68 @@ +import { Router } from "express"; +import { and, eq, ne, or, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { albums, artists, playlists, tracks, users } from "../db/schema.js"; +import { asyncHandler } from "../lib/http.js"; +import { albumDTO, artistDTO, playlistDTO, publicUser, trackDTO } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const searchRouter = Router(); + +searchRouter.get( + "/search", + requireAuth, + asyncHandler(async (req, res) => { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const type = typeof req.query.type === "string" ? req.query.type : "all"; + if (q.length < 1) { + return res.json({ tracks: [], artists: [], albums: [], playlists: [], users: [] }); + } + const like = `%${q}%`; + const want = (t: string) => type === "all" || type === t; + + const [trackRows, artistRows, albumRows, playlistRows, userRows] = await Promise.all([ + want("tracks") + ? db.query.tracks.findMany({ + where: and( + eq(tracks.status, "approved"), + or(sql`${tracks.title} ILIKE ${like}`), + ), + with: trackWith, + limit: 20, + }) + : Promise.resolve([]), + want("artists") + ? db.select().from(artists).where(sql`${artists.name} ILIKE ${like}`).limit(20) + : Promise.resolve([]), + want("albums") + ? db.select().from(albums).where(sql`${albums.title} ILIKE ${like}`).limit(20) + : Promise.resolve([]), + want("playlists") + ? db.query.playlists.findMany({ + where: and( + ne(playlists.visibility, "private"), + sql`${playlists.title} ILIKE ${like}`, + ), + with: { owner: true, entries: true }, + limit: 20, + }) + : Promise.resolve([]), + want("users") + ? db + .select() + .from(users) + .where(or(sql`${users.username} ILIKE ${like}`, sql`${users.displayName} ILIKE ${like}`)) + .limit(20) + : Promise.resolve([]), + ]); + + res.json({ + tracks: trackRows.map((t) => trackDTO(t as never)), + artists: artistRows.map((a) => artistDTO(a)), + albums: albumRows.map((a) => albumDTO(a)), + playlists: playlistRows.map((p) => playlistDTO(p as never)), + users: userRows.map((u) => publicUser(u)), + }); + }), +); diff --git a/apps/server/src/routes/tracks.ts b/apps/server/src/routes/tracks.ts new file mode 100644 index 0000000..4be063b --- /dev/null +++ b/apps/server/src/routes/tracks.ts @@ -0,0 +1,311 @@ +import { Router } from "express"; +import { randomUUID } from "node:crypto"; +import { and, desc, eq, sql } from "drizzle-orm"; +import multer from "multer"; +import { z } from "zod"; +import { db } from "../db/index.js"; +import { albums, trackArtists, trackLikes, tracks } from "../db/schema.js"; +import { canViewTrack } from "../services/access.js"; +import { + HttpError, + asyncHandler, + assertUuid, + notFound, + parseBody, + parsePagination, +} from "../lib/http.js"; +import { hasPerm } from "../lib/permissions.js"; +import { trackDTO } from "../lib/presenters.js"; +import { trackWith } from "../db/queries.js"; +import { removeObject, BUCKET, minio, putBuffer, statObject } from "../lib/storage.js"; +import { mediaUrl } from "../services/trackIngest.js"; +import { findOrCreateArtist } from "../services/trackIngest.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const tracksRouter = Router(); + +const trackInclude = trackWith; + +async function loadTrack(id: string) { + const track = await db.query.tracks.findFirst({ + where: eq(tracks.id, id), + with: trackInclude, + }); + if (!track) throw notFound("Трек не найден"); + return track; +} + +/* ----------------------------- list ----------------------------- */ + +tracksRouter.get( + "/tracks", + requireAuth, + asyncHandler(async (req, res) => { + const { limit, offset } = parsePagination(req); + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const artistId = typeof req.query.artistId === "string" ? req.query.artistId : undefined; + const albumId = typeof req.query.albumId === "string" ? req.query.albumId : undefined; + + const filters = [eq(tracks.status, "approved")]; + if (q) filters.push(sql`${tracks.title} ILIKE ${"%" + q + "%"}`); + if (artistId) filters.push(eq(tracks.artistId, assertUuid(artistId))); + if (albumId) filters.push(eq(tracks.albumId, assertUuid(albumId))); + + const rows = await db.query.tracks.findMany({ + where: and(...filters), + with: trackInclude, + orderBy: [desc(tracks.createdAt)], + limit, + offset, + }); + + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(tracks) + .where(and(...filters)); + + res.json({ items: rows.map((t) => trackDTO(t as never)), total: count }); + }), +); + +tracksRouter.get( + "/tracks/random", + requireAuth, + asyncHandler(async (req, res) => { + const limit = Math.min(Number(req.query.limit) || 20, 60); + const rows = await db.query.tracks.findMany({ + where: eq(tracks.status, "approved"), + with: trackInclude, + orderBy: sql`random()`, + limit, + }); + res.json({ items: rows.map((t) => trackDTO(t as never)) }); + }), +); + +/* ----------------------------- single ----------------------------- */ + +tracksRouter.get( + "/tracks/:id", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + if (!canViewTrack(req.user, track)) throw notFound("Трек не найден"); + + const [liked] = await db + .select() + .from(trackLikes) + .where(and(eq(trackLikes.trackId, track.id), eq(trackLikes.userId, req.user!.id))) + .limit(1); + + res.json({ + track: trackDTO(track as never), + lyrics: { + plain: track.lyricsPlain, + synced: track.lyricsSynced, + source: track.lyricsSource, + }, + liked: Boolean(liked), + canEdit: track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary"), + }); + }), +); + +tracksRouter.get( + "/tracks/:id/lyrics", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + if (!canViewTrack(req.user, track)) throw notFound("Трек не найден"); + res.json({ + plain: track.lyricsPlain, + synced: track.lyricsSynced, + source: track.lyricsSource, + }); + }), +); + +const patchSchema = z.object({ + title: z.string().min(1).max(300).optional(), + artistId: z.string().uuid().nullable().optional(), + albumId: z.string().uuid().nullable().optional(), + trackNumber: z.number().int().nullable().optional(), + discNumber: z.number().int().nullable().optional(), + releaseDate: z.string().nullable().optional(), + coverUrl: z.string().nullable().optional(), + durationMs: z.number().int().nullable().optional(), + lyricsPlain: z.string().nullable().optional(), + lyricsSynced: z + .array(z.object({ timeMs: z.number(), text: z.string() })) + .nullable() + .optional(), + artists: z.array(z.string().min(1).max(300)).optional(), +}); + +tracksRouter.patch( + "/tracks/:id", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary"); + if (!canEdit) throw new HttpError(403, "Нет прав на редактирование трека"); + + const { artists: artistNames, ...data } = parseBody(patchSchema, req.body); + await db + .update(tracks) + .set({ ...data, updatedAt: new Date() }) + .where(eq(tracks.id, track.id)); + + if (artistNames) { + const resolved: { trackId: string; artistId: string; position: number; role: string }[] = []; + for (let i = 0; i < artistNames.length; i++) { + const id = await findOrCreateArtist(artistNames[i], {}); + resolved.push({ trackId: track.id, artistId: id, position: i, role: i === 0 ? "main" : "featured" }); + } + await db.delete(trackArtists).where(eq(trackArtists.trackId, track.id)); + if (resolved.length) { + await db.insert(trackArtists).values(resolved); + await db + .update(tracks) + .set({ artistId: resolved[0].artistId, updatedAt: new Date() }) + .where(eq(tracks.id, track.id)); + } + } + + res.json({ track: trackDTO((await loadTrack(track.id)) as never) }); + }), +); + +tracksRouter.delete( + "/tracks/:id", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary"); + if (!canEdit) throw new HttpError(403, "Нет прав на удаление трека"); + + await db.delete(tracks).where(eq(tracks.id, track.id)); + await removeObject(track.fileKey); + res.json({ ok: true }); + }), +); + +/* ----------------------------- like ----------------------------- */ + +tracksRouter.post( + "/tracks/:id/like", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + if (!canViewTrack(req.user, track)) throw notFound("Трек не найден"); + await db + .insert(trackLikes) + .values({ userId: req.user!.id, trackId: track.id }) + .onConflictDoNothing(); + res.json({ liked: true }); + }), +); + +tracksRouter.delete( + "/tracks/:id/like", + requireAuth, + asyncHandler(async (req, res) => { + const trackId = assertUuid(req.params.id); + await db + .delete(trackLikes) + .where(and(eq(trackLikes.trackId, trackId), eq(trackLikes.userId, req.user!.id))); + res.json({ liked: false }); + }), +); + +/* ----------------------------- stream ----------------------------- */ + +tracksRouter.get( + "/tracks/:id/stream", + requireAuth, + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + if (!canViewTrack(req.user, track)) throw notFound("Трек не найден"); + + const stat = await statObject(track.fileKey); + if (!stat) throw notFound("Файл трека не найден"); + + const range = req.headers.range; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Type", track.mimeType || stat.contentType); + res.setHeader("Cache-Control", "no-store"); + + if (req.query.download === "1") { + const ext = (track.fileKey.match(/\.[a-z0-9]+$/i)?.[0] ?? ""); + const base = track.artist?.name ? `${track.artist.name} - ${track.title}` : track.title; + res.setHeader( + "Content-Disposition", + `attachment; filename*=UTF-8''${encodeURIComponent(base + ext)}`, + ); + } + + if (range) { + const match = /bytes=(\d*)-(\d*)/.exec(range); + const start = match?.[1] ? Number(match[1]) : 0; + const end = match?.[2] ? Number(match[2]) : stat.size - 1; + if (start >= stat.size) { + res.status(416).setHeader("Content-Range", `bytes */${stat.size}`); + return res.end(); + } + res.status(206); + res.setHeader("Content-Range", `bytes ${start}-${end}/${stat.size}`); + res.setHeader("Content-Length", end - start + 1); + const stream = await minio.getPartialObject(BUCKET, track.fileKey, start, end - start + 1); + stream.on("error", () => res.end()); + stream.pipe(res); + return; + } + + res.setHeader("Content-Length", stat.size); + const stream = await minio.getObject(BUCKET, track.fileKey); + stream.on("error", () => res.end()); + stream.pipe(res); + }), +); + +/* --------------------------- cover upload --------------------------- */ + +const coverUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith("image/")) cb(null, true); + else cb(new Error("Только изображения")); + }, +}); + +tracksRouter.post( + "/tracks/:id/cover", + requireAuth, + coverUpload.single("cover"), + asyncHandler(async (req, res) => { + const track = await loadTrack(assertUuid(req.params.id)); + const canEdit = track.uploadedById === req.user!.id || hasPerm(req.user, "canManageLibrary"); + if (!canEdit) throw new HttpError(403, "Нет прав на редактирование трека"); + if (!req.file) throw new HttpError(400, "Файл обложки не загружен"); + + const ext = (req.file.mimetype.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); + const key = `covers/${track.id}/${randomUUID()}.${ext}`; + await putBuffer(key, req.file.buffer, req.file.mimetype); + const coverUrl = mediaUrl(key); + + await db + .update(tracks) + .set({ coverUrl, updatedAt: new Date() }) + .where(eq(tracks.id, track.id)); + + if (req.query.album === "1" && track.albumId) { + await db + .update(albums) + .set({ coverUrl, updatedAt: new Date() }) + .where(eq(albums.id, track.albumId)); + } + + res.json({ track: trackDTO((await loadTrack(track.id)) as never) }); + }), +); diff --git a/apps/server/src/routes/uploads.ts b/apps/server/src/routes/uploads.ts new file mode 100644 index 0000000..85a44ca --- /dev/null +++ b/apps/server/src/routes/uploads.ts @@ -0,0 +1,207 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, unlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { Router } from "express"; +import { and, desc, eq, sql } from "drizzle-orm"; +import multer from "multer"; +import { randomUUID } from "node:crypto"; +import { db } from "../db/index.js"; +import { trackWith } from "../db/queries.js"; +import { tracks } from "../db/schema.js"; +import { asyncHandler, badRequest, HttpError, notFound } from "../lib/http.js"; +import { cleanFilename } from "../lib/metadata/types.js"; +import { hasPerm } from "../lib/permissions.js"; +import { trackDTO } from "../lib/presenters.js"; +import { enqueueMetadata } from "../lib/queue.js"; +import { BUCKET, minio, putFile, removeObject } from "../lib/storage.js"; +import { requirePermission } from "../middleware/auth.js"; +import { logger } from "../lib/logger.js"; + +const uploadDir = path.join(os.tmpdir(), "adc-retivo-uploads"); + +const storage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + await mkdir(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname).slice(0, 10) || ".bin"; + cb(null, `${randomUUID()}${ext}`); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 150 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (file.mimetype.startsWith("audio/") || /\.(mp3|flac|wav|ogg|m4a|aac|opus|wma)$/i.test(file.originalname)) { + cb(null, true); + } else { + cb(new Error("Поддерживаются только аудиофайлы")); + } + }, +}); + +export const uploadsRouter = Router(); + +function sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + createReadStream(filePath) + .on("data", (chunk) => hash.update(chunk)) + .on("end", () => resolve(hash.digest("hex"))) + .on("error", reject); + }); +} + +/** + * Multipart clients (browsers) send filenames as UTF-8, but busboy/multer decode + * them as latin1, which turns Cyrillic into mojibake ("дора"). Re-decode as UTF-8. + */ +function decodeOriginalName(name: string): string { + if (!/[\u0080-\u00ff]/.test(name)) return name; + const decoded = Buffer.from(name, "latin1").toString("utf8"); + return decoded.includes("\uFFFD") ? name : decoded; +} + +uploadsRouter.post( + "/uploads", + requirePermission("canUploadTracks"), + upload.single("file"), + asyncHandler(async (req, res) => { + if (!req.file) throw badRequest("Файл не загружен"); + const file = req.file; + const originalName = decodeOriginalName(file.originalname); + + try { + const hash = await sha256File(file.path); + const [duplicate] = await db + .select({ id: tracks.id, title: tracks.title }) + .from(tracks) + .where(and(sql`${tracks.metadata}->>'hash' = ${hash}`)) + .limit(1); + + if (duplicate) { + throw new HttpError(409, `Дубликат: «${duplicate.title}» уже есть в библиотеке`, { + trackId: duplicate.id, + title: duplicate.title, + }); + } + + const autoApprove = hasPerm(req.user, "canAutoApprove"); + const ext = path.extname(originalName).slice(0, 10) || ".bin"; + const fileKey = `tracks/${randomUUID()}${ext}`; + + await putFile(fileKey, file.path, file.mimetype); + + const [track] = await db + .insert(tracks) + .values({ + title: cleanFilename(originalName) || "Без названия", + fileKey, + fileSize: file.size, + mimeType: file.mimetype, + uploadedById: req.user!.id, + status: autoApprove ? "approved" : "pending", + approvedAt: autoApprove ? new Date() : null, + metadata: { hash, originalName, processing: true }, + }) + .returning(); + + await enqueueMetadata({ + trackId: track.id, + filePath: file.path, + mimeType: file.mimetype, + originalName, + }); + + logger.info({ trackId: track.id, autoApprove }, "track uploaded"); + + res.status(201).json({ + track: trackDTO({ ...track, artist: null, album: null, uploader: req.user }), + }); + } catch (err) { + await unlink(file.path).catch(() => {}); + throw err; + } + }), +); + +/* My uploads: pending/processing + rejected (approved disappear) */ +uploadsRouter.get( + "/uploads/mine", + requirePermission("canUploadTracks"), + asyncHandler(async (req, res) => { + const rows = await db.query.tracks.findMany({ + where: eq(tracks.uploadedById, req.user!.id), + with: trackWith, + orderBy: [desc(tracks.createdAt)], + limit: 200, + }); + + const pending: ReturnType[] = []; + const rejected: ReturnType[] = []; + + for (const t of rows) { + const processing = Boolean((t.metadata as Record | null)?.processing); + if (t.status === "rejected") { + rejected.push(trackDTO(t as never)); + } else if (t.status === "pending" || processing) { + pending.push(trackDTO(t as never)); + } + } + + res.json({ pending, rejected }); + }), +); + +uploadsRouter.post( + "/uploads/:id/retry", + requirePermission("canUploadTracks"), + asyncHandler(async (req, res) => { + const [track] = await db + .select() + .from(tracks) + .where(and(eq(tracks.id, req.params.id), eq(tracks.uploadedById, req.user!.id))) + .limit(1); + if (!track) throw notFound("Трек не найден"); + await db + .update(tracks) + .set({ + status: track.status === "rejected" ? "pending" : track.status, + rejectionReason: track.status === "rejected" ? null : track.rejectionReason, + metadata: { ...(track.metadata ?? {}), processing: true, requeuedAt: new Date().toISOString() }, + updatedAt: new Date(), + }) + .where(eq(tracks.id, track.id)); + + // Download the stored object back to a temp file and re-run metadata + await mkdir(uploadDir, { recursive: true }); + const ext = path.extname(track.fileKey).slice(0, 10) || ".bin"; + const tmpPath = path.join(uploadDir, `${randomUUID()}${ext}`); + await minio.fGetObject(BUCKET, track.fileKey, tmpPath); + await enqueueMetadata( + { trackId: track.id, filePath: tmpPath, originalName: track.title }, + { force: true }, + ); + res.json({ ok: true }); + }), +); + +uploadsRouter.delete( + "/uploads/:id", + requirePermission("canUploadTracks"), + asyncHandler(async (req, res) => { + const [track] = await db + .select() + .from(tracks) + .where(and(eq(tracks.id, req.params.id), eq(tracks.uploadedById, req.user!.id))) + .limit(1); + if (!track) throw notFound("Трек не найден"); + await db.delete(tracks).where(eq(tracks.id, track.id)); + await removeObject(track.fileKey); + res.json({ ok: true }); + }), +); diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts new file mode 100644 index 0000000..457e1f4 --- /dev/null +++ b/apps/server/src/routes/users.ts @@ -0,0 +1,72 @@ +import { Router } from "express"; +import { and, desc, eq, ne, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { libraryPlaylists, playlists, tracks, users } from "../db/schema.js"; +import { asyncHandler, notFound } from "../lib/http.js"; +import { playlistDTO, publicUser, selfUser } from "../lib/presenters.js"; +import { requireAuth } from "../middleware/auth.js"; + +export const usersRouter = Router(); + +usersRouter.get( + "/users/:username", + requireAuth, + asyncHandler(async (req, res) => { + const [user] = await db + .select() + .from(users) + .where(sql`lower(${users.username}) = ${req.params.username.toLowerCase()}`) + .limit(1); + if (!user) throw notFound("Пользователь не найден"); + + const publicPlaylists = await db.query.playlists.findMany({ + where: and(eq(playlists.ownerId, user.id), ne(playlists.visibility, "private")), + with: { owner: true, entries: true }, + orderBy: [desc(playlists.updatedAt)], + limit: 50, + }); + + const [{ count: trackCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(tracks) + .where(and(eq(tracks.uploadedById, user.id), eq(tracks.status, "approved"))); + + res.json({ + user: publicUser(user), + playlists: publicPlaylists.map((p) => playlistDTO(p as never)), + trackCount, + }); + }), +); + +usersRouter.get( + "/users/:username/library", + requireAuth, + asyncHandler(async (req, res) => { + const [user] = await db + .select() + .from(users) + .where(sql`lower(${users.username}) = ${req.params.username.toLowerCase()}`) + .limit(1); + if (!user) throw notFound("Пользователь не найден"); + if (user.id !== req.user!.id && !req.user!.isSuperAdmin) { + throw notFound("Пользователь не найден"); + } + const libRows = await db + .select({ playlist: playlists, mode: libraryPlaylists.mode }) + .from(libraryPlaylists) + .innerJoin(playlists, eq(libraryPlaylists.playlistId, playlists.id)) + .where(eq(libraryPlaylists.userId, user.id)); + res.json({ + saved: libRows.map((r) => ({ mode: r.mode, playlist: playlistDTO(r.playlist as never) })), + }); + }), +); + +usersRouter.get( + "/me/self", + requireAuth, + asyncHandler(async (req, res) => { + res.json({ user: selfUser(req.user!) }); + }), +); diff --git a/apps/server/src/services/access.ts b/apps/server/src/services/access.ts new file mode 100644 index 0000000..7a158fb --- /dev/null +++ b/apps/server/src/services/access.ts @@ -0,0 +1,67 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { libraryPlaylists, playlistCollaborators, playlists, type Playlist, type User, type Track } from "../db/schema.js"; +import { hasPerm } from "../lib/permissions.js"; + +export async function getPlaylist(id: string): Promise { + const [p] = await db.select().from(playlists).where(eq(playlists.id, id)).limit(1); + return p ?? null; +} + +export async function canViewPlaylist(user: User | undefined, playlist: Playlist): Promise { + if (playlist.visibility !== "private") return true; + if (!user) return false; + if (playlist.ownerId === user.id) return true; + if (hasPerm(user, "canViewAllPlaylists")) return true; + const [collab] = await db + .select() + .from(playlistCollaborators) + .where( + and( + eq(playlistCollaborators.playlistId, playlist.id), + eq(playlistCollaborators.userId, user.id), + ), + ) + .limit(1); + if (collab) return true; + const [lib] = await db + .select() + .from(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, playlist.id), eq(libraryPlaylists.userId, user.id))) + .limit(1); + return Boolean(lib); +} + +export async function canEditPlaylist(user: User | undefined, playlist: Playlist): Promise { + if (!user) return false; + if (playlist.ownerId === user.id) return true; + if (hasPerm(user, "canManageLibrary")) return true; + const [collab] = await db + .select() + .from(playlistCollaborators) + .where( + and( + eq(playlistCollaborators.playlistId, playlist.id), + eq(playlistCollaborators.userId, user.id), + ), + ) + .limit(1); + if (collab?.canEdit) return true; + const [lib] = await db + .select() + .from(libraryPlaylists) + .where(and(eq(libraryPlaylists.playlistId, playlist.id), eq(libraryPlaylists.userId, user.id))) + .limit(1); + return Boolean(lib?.canEdit); +} + +export function canViewTrack(user: User | undefined, track: Track): boolean { + if (track.status === "approved") return true; + if (!user) return false; + if (track.uploadedById === user.id) return true; + return hasPerm(user, "canManageLibrary") || hasPerm(user, "canModerate"); +} + +export async function playlistsForUser(userId: string): Promise { + return db.select().from(playlists).where(eq(playlists.ownerId, userId)); +} diff --git a/apps/server/src/services/trackIngest.ts b/apps/server/src/services/trackIngest.ts new file mode 100644 index 0000000..03485c5 --- /dev/null +++ b/apps/server/src/services/trackIngest.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, sql } from "drizzle-orm"; +import { db } from "../db/index.js"; +import { albums, artists, trackArtists, tracks } from "../db/schema.js"; +import { env } from "../env.js"; +import { enrichTrack } from "../lib/metadata/index.js"; +import { putBuffer } from "../lib/storage.js"; +import { logger } from "../lib/logger.js"; + +export function mediaUrl(key: string): string { + return `/api/media/${key}`; +} + +/** + * Downloads an external image (Cover Art Archive / Wikipedia) into object storage + * and returns a local /api/media URL, so we stop depending on third-party services. + */ +export async function localizeExternalImage( + url: string | undefined, + folder: string, +): Promise { + if (!url) return undefined; + if (url.startsWith("/api/media/")) return url; + try { + const res = await fetch(url, { + headers: { "User-Agent": env.MB_USER_AGENT, Accept: "image/*" }, + signal: AbortSignal.timeout(15000), + }); + if (!res.ok) return url; + const contentType = (res.headers.get("content-type") ?? "image/jpeg").split(";")[0]; + if (!contentType.startsWith("image/")) return url; + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.length === 0 || buffer.length > 12 * 1024 * 1024) return url; + const ext = (contentType.split("/")[1] ?? "jpg").replace("jpeg", "jpg").replace("svg+xml", "svg"); + const key = `${folder}/${randomUUID()}.${ext}`; + await putBuffer(key, buffer, contentType); + return mediaUrl(key); + } catch (err) { + logger.debug({ err, url }, "failed to localize image, keeping remote url"); + return url; + } +} + +export async function findOrCreateArtist( + name: string, + opts: { mbArtistId?: string; description?: string; imageUrl?: string }, +): Promise { + let existing; + if (opts.mbArtistId) { + [existing] = await db.select().from(artists).where(eq(artists.mbArtistId, opts.mbArtistId)).limit(1); + } + if (!existing) { + [existing] = await db + .select() + .from(artists) + .where(sql`lower(${artists.name}) = ${name.toLowerCase()}`) + .limit(1); + } + + if (existing) { + const patch: Partial = { updatedAt: new Date() }; + if (!existing.mbArtistId && opts.mbArtistId) patch.mbArtistId = opts.mbArtistId; + if (!existing.description && opts.description) patch.description = opts.description; + if (!existing.imageUrl && opts.imageUrl) patch.imageUrl = opts.imageUrl; + if (Object.keys(patch).length > 1) { + await db.update(artists).set(patch).where(eq(artists.id, existing.id)); + } + return existing.id; + } + + const [created] = await db + .insert(artists) + .values({ + name, + mbArtistId: opts.mbArtistId, + description: opts.description, + imageUrl: opts.imageUrl, + metadata: { provenance: "auto" }, + }) + .returning(); + return created.id; +} + +async function findOrCreateAlbum( + title: string, + artistId: string | null, + opts: { mbReleaseGroupId?: string; mbReleaseId?: string; releaseDate?: string; coverUrl?: string }, +): Promise { + let existing; + if (opts.mbReleaseGroupId) { + [existing] = await db + .select() + .from(albums) + .where(eq(albums.mbReleaseGroupId, opts.mbReleaseGroupId)) + .limit(1); + } + if (!existing) { + [existing] = await db + .select() + .from(albums) + .where( + and(sql`lower(${albums.title}) = ${title.toLowerCase()}`, artistId ? eq(albums.artistId, artistId) : sql`true`), + ) + .limit(1); + } + + if (existing) { + const patch: Partial = { updatedAt: new Date() }; + if (!existing.coverUrl && opts.coverUrl) patch.coverUrl = opts.coverUrl; + if (!existing.releaseDate && opts.releaseDate) patch.releaseDate = opts.releaseDate; + if (!existing.mbReleaseGroupId && opts.mbReleaseGroupId) patch.mbReleaseGroupId = opts.mbReleaseGroupId; + if (!existing.mbReleaseId && opts.mbReleaseId) patch.mbReleaseId = opts.mbReleaseId; + if (Object.keys(patch).length > 1) { + await db.update(albums).set(patch).where(eq(albums.id, existing.id)); + } + return existing.id; + } + + const [created] = await db + .insert(albums) + .values({ + title, + artistId, + mbReleaseGroupId: opts.mbReleaseGroupId, + mbReleaseId: opts.mbReleaseId, + releaseDate: opts.releaseDate, + coverUrl: opts.coverUrl, + metadata: { provenance: "auto" }, + }) + .returning(); + return created.id; +} + +export async function ingestTrack(input: { + trackId: string; + filePath: string; + originalName?: string; + mimeType?: string; +}): Promise { + const [track] = await db.select().from(tracks).where(eq(tracks.id, input.trackId)).limit(1); + if (!track) { + logger.warn({ trackId: input.trackId }, "ingest: track not found"); + return; + } + + const { tags, data } = await enrichTrack({ + filePath: input.filePath, + originalName: input.originalName, + }); + + // Localize external cover art so we don't keep hitting third-party services. + let coverUrl = await localizeExternalImage(data.coverUrl, "covers"); + if (!coverUrl && tags.pictureBuffer) { + const ext = (tags.pictureMime?.split("/")[1] ?? "jpg").replace("jpeg", "jpg"); + const key = `covers/${randomUUID()}.${ext}`; + await putBuffer(key, tags.pictureBuffer, tags.pictureMime ?? "image/jpeg"); + coverUrl = mediaUrl(key); + } + + const credits = + data.artists?.length + ? data.artists + : [{ name: data.artistName ?? "Неизвестный исполнитель", role: "main" as const }]; + const artistImageUrl = await localizeExternalImage(data.artistImageUrl, "artists"); + + const resolvedArtists: { artistId: string; position: number; role: string }[] = []; + for (let i = 0; i < credits.length; i++) { + const credit = credits[i]; + const artistId = await findOrCreateArtist(credit.name, { + mbArtistId: credit.mbArtistId, + description: i === 0 ? data.artistDescription : undefined, + imageUrl: i === 0 ? artistImageUrl : undefined, + }); + resolvedArtists.push({ artistId, position: i, role: credit.role ?? "main" }); + } + const artistId = resolvedArtists[0].artistId; + + let albumId: string | null = null; + if (data.albumTitle) { + albumId = await findOrCreateAlbum(data.albumTitle, artistId, { + mbReleaseGroupId: data.mbReleaseGroupId, + mbReleaseId: data.mbReleaseId, + releaseDate: data.releaseDate, + coverUrl, + }); + } + + await db + .update(tracks) + .set({ + title: data.title ?? track.title, + artistId, + albumId, + durationMs: data.durationMs ?? track.durationMs, + trackNumber: data.trackNumber ?? track.trackNumber, + discNumber: data.discNumber ?? track.discNumber, + releaseDate: data.releaseDate ?? track.releaseDate, + lyricsPlain: data.lyricsPlain ?? track.lyricsPlain, + lyricsSynced: data.lyricsSynced ?? track.lyricsSynced, + lyricsSource: data.lyricsSource ?? track.lyricsSource, + fingerprint: data.fingerprint ?? track.fingerprint, + mbRecordingId: data.mbRecordingId ?? track.mbRecordingId, + coverUrl: coverUrl ?? track.coverUrl, + mimeType: input.mimeType ?? track.mimeType, + metadata: { + ...(track.metadata ?? {}), + processing: false, + enrichment: data, + sources: data.sources, + }, + updatedAt: new Date(), + }) + .where(eq(tracks.id, track.id)); + + // Replace the artist credits for this track (idempotent on re-ingest) + await db.delete(trackArtists).where(eq(trackArtists.trackId, track.id)); + await db.insert(trackArtists).values( + resolvedArtists.map((a) => ({ + trackId: track.id, + artistId: a.artistId, + position: a.position, + role: a.role, + })), + ); + + logger.info( + { + trackId: track.id, + title: data.title, + artists: credits.map((c) => c.name), + sources: data.sources, + }, + "track ingested", + ); +} diff --git a/apps/server/src/workers/metadata.worker.ts b/apps/server/src/workers/metadata.worker.ts new file mode 100644 index 0000000..7f0b646 --- /dev/null +++ b/apps/server/src/workers/metadata.worker.ts @@ -0,0 +1,34 @@ +import { Worker } from "bullmq"; +import { unlink } from "node:fs/promises"; +import { logger } from "../lib/logger.js"; +import { METADATA_QUEUE, type MetadataJobData } from "../lib/queue.js"; +import { newRedis } from "../lib/redis.js"; +import { ingestTrack } from "../services/trackIngest.js"; + +export function startMetadataWorker() { + const worker = new Worker( + METADATA_QUEUE, + async (job) => { + const { trackId, filePath, originalName, mimeType } = job.data; + const maxAttempts = job.opts.attempts ?? 1; + try { + await ingestTrack({ trackId, filePath, originalName, mimeType }); + await unlink(filePath).catch(() => {}); + } catch (err) { + // Keep the temp file for retries; only clean up after the last attempt. + const isFinalAttempt = job.attemptsMade + 1 >= maxAttempts; + if (isFinalAttempt) await unlink(filePath).catch(() => {}); + throw err; + } + }, + { connection: newRedis(), concurrency: 2 }, + ); + + worker.on("completed", (job) => logger.info({ jobId: job.id }, "metadata job done")); + worker.on("failed", (job, err) => + logger.error({ jobId: job?.id, err: err.message }, "metadata job failed"), + ); + + logger.info("metadata worker started"); + return worker; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..c4618e0 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "outDir": "dist", + "rootDir": "src", + "noEmit": false + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/web/Dockerfile.dev b/apps/web/Dockerfile.dev new file mode 100644 index 0000000..b9f20f9 --- /dev/null +++ b/apps/web/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM node:22-bookworm-slim +WORKDIR /app +COPY package.json package-lock.json* ./ +COPY apps/server/package.json apps/server/ +COPY apps/web/package.json apps/web/ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi +EXPOSE 5173 +CMD ["npm", "run", "dev", "-w", "@adc-retivo/web", "--", "--host", "0.0.0.0"] diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..8c40dd9 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,19 @@ + + + + + + + ADC Retivo + + + + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..8572ece --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,32 @@ +{ + "name": "@adc-retivo/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.62.7", + "axios": "^1.7.9", + "clsx": "^2.1.1", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.2", + "vite": "^6.0.7" + } +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..d21f98e --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,66 @@ +import { Navigate, Route, Routes } from "react-router-dom"; +import { Layout } from "./components/Layout"; +import { Account } from "./pages/Account"; +import { AddTrack } from "./pages/AddTrack"; +import { Admin } from "./pages/Admin"; +import { AlbumPage } from "./pages/AlbumPage"; +import { ArtistPage } from "./pages/ArtistPage"; +import { Home } from "./pages/Home"; +import { Library } from "./pages/Library"; +import { Login } from "./pages/Login"; +import { PlaylistPage } from "./pages/PlaylistPage"; +import { SearchPage } from "./pages/SearchPage"; +import { TrackPage } from "./pages/TrackPage"; +import { UserPage } from "./pages/UserPage"; +import { useAuth } from "./store/auth"; + +function Splash() { + return ( +
+
+ ADC RETIVO +
+
+ ); +} + +function RequireAuth({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + if (loading) return ; + if (!user) return ; + return <>{children}; +} + +export function App() { + const { user, loading } = useAuth(); + + return ( + + : user ? : } + /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + + ); +} diff --git a/apps/web/src/components/AddToPlaylistModal.tsx b/apps/web/src/components/AddToPlaylistModal.tsx new file mode 100644 index 0000000..e47ff18 --- /dev/null +++ b/apps/web/src/components/AddToPlaylistModal.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Plus, X } from "lucide-react"; +import { playlistsApi } from "../lib/api"; +import type { Track } from "../lib/types"; +import { Modal } from "./Modal"; + +export function AddToPlaylistModal({ + track, + open, + onClose, +}: { + track: Track | null; + open: boolean; + onClose: () => void; +}) { + const qc = useQueryClient(); + const [createdId, setCreatedId] = useState(null); + const { data: playlists, isLoading } = useQuery({ + queryKey: ["my-playlists"], + queryFn: playlistsApi.mine, + enabled: open, + }); + + const add = useMutation({ + mutationFn: (playlistId: string) => playlistsApi.addTrack(playlistId, track!.id), + onSuccess: (_d, playlistId) => { + setCreatedId(playlistId); + void qc.invalidateQueries({ queryKey: ["playlist", playlistId] }); + void qc.invalidateQueries({ queryKey: ["my-playlists"] }); + }, + }); + + const createAndAdd = useMutation({ + mutationFn: async () => { + const pl = await playlistsApi.create({ title: `Плейлист · ${track?.title ?? ""}`.slice(0, 80) }); + await playlistsApi.addTrack(pl.id, track!.id); + return pl; + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["my-playlists"] }); + onClose(); + }, + }); + + return ( + +
+ + +
+ {isLoading &&

Загрузка...

} + {playlists?.map((pl) => ( + + ))} + {playlists && playlists.length === 0 && ( +

У вас пока нет плейлистов.

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/ArtistLinks.tsx b/apps/web/src/components/ArtistLinks.tsx new file mode 100644 index 0000000..e63cfb5 --- /dev/null +++ b/apps/web/src/components/ArtistLinks.tsx @@ -0,0 +1,37 @@ +import { Link } from "react-router-dom"; +import type { TrackArtistCredit } from "../lib/types"; + +/** + * Renders one or more artist credits as individual profile links, + * e.g. "Дора, Мукка feat. Мэйби Бэйби". + */ +export function ArtistLinks({ + artists, + fallback, + className, + linkClassName = "hover:underline", + onNavigate, +}: { + artists?: TrackArtistCredit[] | null; + fallback?: string | null; + className?: string; + linkClassName?: string; + onNavigate?: () => void; +}) { + const list = artists?.length ? artists : []; + if (!list.length) { + return {fallback ?? "Неизвестный исполнитель"}; + } + return ( + + {list.map((a, i) => ( + + {i > 0 && , } + + {a.name} + + + ))} + + ); +} diff --git a/apps/web/src/components/Comments.tsx b/apps/web/src/components/Comments.tsx new file mode 100644 index 0000000..21904e6 --- /dev/null +++ b/apps/web/src/components/Comments.tsx @@ -0,0 +1,168 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CornerDownRight, Send, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { tracksApi } from "../lib/api"; +import { formatDate } from "../lib/format"; +import type { Comment } from "../lib/types"; +import { useAuth } from "../store/auth"; +import { Cover } from "./Cover"; + +export function Comments({ trackId, compact = false }: { trackId: string; compact?: boolean }) { + const { user } = useAuth(); + const qc = useQueryClient(); + const [body, setBody] = useState(""); + + const { data } = useQuery({ + queryKey: ["comments", trackId], + queryFn: () => tracksApi.comments(trackId), + }); + + const invalidate = () => void qc.invalidateQueries({ queryKey: ["comments", trackId] }); + + const add = useMutation({ + mutationFn: ({ text, parentId }: { text: string; parentId?: string }) => + tracksApi.addComment(trackId, text, parentId ?? null), + onSuccess: () => { + setBody(""); + invalidate(); + }, + }); + + const remove = useMutation({ + mutationFn: (commentId: string) => tracksApi.deleteComment(commentId), + onSuccess: invalidate, + }); + + return ( +
+

Комментарии ({data?.total ?? 0})

+ +
{ + e.preventDefault(); + if (body.trim()) add.mutate({ text: body.trim() }); + }} + > + setBody(e.target.value)} + /> + +
+ +
+ {data?.comments.map((c) => ( + add.mutate({ text, parentId })} + onDelete={(cid) => remove.mutate(cid)} + /> + ))} + {data && data.comments.length === 0 && ( +

Комментариев пока нет. Будьте первым!

+ )} +
+
+ ); +} + +function CommentNode({ + comment, + currentUserId, + onReply, + onDelete, +}: { + comment: Comment; + currentUserId: string; + onReply: (text: string, parentId: string) => void; + onDelete: (id: string) => void; +}) { + const [replyOpen, setReplyOpen] = useState(false); + const [reply, setReply] = useState(""); + + return ( +
+
+ + + +
+
+ {comment.user ? ( + + {comment.user.displayName || comment.user.username} + + ) : ( + удалён + )} + {formatDate(comment.createdAt)} +
+

+ {comment.deleted ? "Комментарий удалён" : comment.body} +

+
+ + {comment.user?.id === currentUserId && !comment.deleted && ( + + )} +
+ + {replyOpen && ( +
{ + e.preventDefault(); + if (reply.trim()) { + onReply(reply.trim(), comment.id); + setReply(""); + setReplyOpen(false); + } + }} + > + setReply(e.target.value)} + /> + +
+ )} + + {comment.replies.length > 0 && ( +
+ {comment.replies.map((r) => ( + + ))} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/Cover.tsx b/apps/web/src/components/Cover.tsx new file mode 100644 index 0000000..4df3d72 --- /dev/null +++ b/apps/web/src/components/Cover.tsx @@ -0,0 +1,25 @@ +import { cx, initials } from "../lib/format"; + +export function Cover({ + src, + name, + className, + rounded = "rounded-xl", +}: { + src?: string | null; + name?: string | null; + className?: string; + rounded?: string; +}) { + return ( +
+ {src ? ( + {name + ) : ( +
+ {initials(name) || "AR"} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/CreatePlaylistModal.tsx b/apps/web/src/components/CreatePlaylistModal.tsx new file mode 100644 index 0000000..640c4e4 --- /dev/null +++ b/apps/web/src/components/CreatePlaylistModal.tsx @@ -0,0 +1,83 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { playlistsApi } from "../lib/api"; +import { Modal } from "./Modal"; + +export function CreatePlaylistModal({ + open, + onClose, + onCreated, +}: { + open: boolean; + onClose: () => void; + onCreated?: (id: string) => void; +}) { + const qc = useQueryClient(); + const [form, setForm] = useState<{ + title: string; + description: string; + visibility: "public" | "unlisted" | "private"; + }>({ title: "", description: "", visibility: "private" }); + const [error, setError] = useState(""); + + const create = useMutation({ + mutationFn: () => + playlistsApi.create({ + title: form.title, + description: form.description || null, + visibility: form.visibility, + }), + onSuccess: (pl) => { + void qc.invalidateQueries({ queryKey: ["library"] }); + void qc.invalidateQueries({ queryKey: ["my-playlists"] }); + setForm({ title: "", description: "", visibility: "private" }); + onCreated?.(pl.id); + onClose(); + }, + onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"), + }); + + return ( + +
+
+ + setForm({ ...form, title: e.target.value })} + placeholder="Мой плейлист" + /> +
+
+ +