Initial commit

This commit is contained in:
2026-10-05 20:38:28 +02:00
commit 60a68b420e
107 changed files with 21716 additions and 0 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
**/node_modules
**/dist
**/build
**/.git
**/.env
**/.env.*
!**/.env.example
!**/.env.dev.example
**/*.log
**/.DS_Store
**/coverage

43
.env.dev.example Normal file
View File

@@ -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

56
.env.example Normal file
View File

@@ -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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
build
coverage
.env
.env.dev
.env.local
*.log
.DS_Store
apps/web/dist

198
README.md Normal file
View File

@@ -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` и пароль первого админа перед публикацией.

38
apps/server/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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"));

View File

@@ -0,0 +1 @@
DROP INDEX "tracks_fingerprint_idx";

View File

@@ -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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

49
apps/server/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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 <server>/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);
});

View File

@@ -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;

View File

@@ -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<Permissions>().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<Record<string, unknown>>(),
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<Record<string, unknown>>(),
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<LyricsLine[]>(),
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<Record<string, unknown>>(),
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;

View File

@@ -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<number>`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();
});

56
apps/server/src/env.ts Normal file
View File

@@ -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);

13
apps/server/src/express.d.ts vendored Normal file
View File

@@ -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 {};

93
apps/server/src/index.ts Normal file
View File

@@ -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);
});

View File

@@ -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> | unknown;
export function asyncHandler(fn: Handler) {
return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
export function parseBody<T>(schema: ZodSchema<T>, 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;
}

View File

@@ -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 },
});

View File

@@ -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<ChromaprintResult | null> {
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<AcoustIdMatch | null> {
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;
}
}

View File

@@ -0,0 +1,35 @@
import { logger } from "../logger.js";
async function exists(url: string): Promise<boolean> {
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<string | null> {
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;
}

View File

@@ -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<EnrichResult> {
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 };
}

View File

@@ -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<LrclibTrack | LrclibTrack[] | null> {
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<LyricsResult | null> {
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",
};
}

View File

@@ -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<T>(
path: string,
params: Record<string, string> = {},
): Promise<T | null> {
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<Enrichment | null> {
const rec = await mbFetch<MBRecording>(`/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<Enrichment | null> {
if (!title) return null;
const parts = [`recording:"${title.replace(/"/g, "")}"`];
if (artist) parts.push(`artist:"${artist.replace(/"/g, "")}"`);
const data = await mbFetch<MBSearchResponse>("/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;
}

View File

@@ -0,0 +1,27 @@
import { parseFile, selectCover } from "music-metadata";
import type { TagData } from "./types.js";
export async function readTags(filePath: string): Promise<TagData> {
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 {};
}
}

View File

@@ -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;
}

View File

@@ -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<WikiSummary | null> {
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<ArtistInfo | null> {
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;
}

View File

@@ -0,0 +1,9 @@
import bcrypt from "bcryptjs";
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 10);
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

View File

@@ -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<User, "isSuperAdmin" | "permissions">): Permissions {
if (user.isSuperAdmin) return superAdminPermissions;
return { ...user.permissions };
}
export function hasPerm(
user: Pick<User, "isSuperAdmin" | "permissions"> | null | undefined,
key: PermissionKey,
): boolean {
if (!user) return false;
if (user.isSuperAdmin) return true;
return Boolean(user.permissions?.[key]);
}
export function canAccessAdmin(user: Pick<User, "isSuperAdmin" | "permissions">): boolean {
if (user.isSuperAdmin) return true;
const p = user.permissions;
return Boolean(
p &&
(p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists),
);
}

View File

@@ -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<typeof trackDTO>;
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<typeof playlistDTO>;
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,
};
}

View File

@@ -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<MetadataJobData>(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}`,
});
}

View File

@@ -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 });
}

View File

@@ -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<string> {
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<SessionData | null> {
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<void> {
if (!id) return;
await getRedis().del(PREFIX + id);
}
export async function destroyAllUserSessions(userId: string): Promise<void> {
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,
};
}

View File

@@ -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<void> {
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<void> {
await minio.fPutObject(BUCKET, key, filePath, {
"Content-Type": contentType ?? "application/octet-stream",
});
}
export async function putBuffer(
key: string,
buffer: Buffer,
contentType?: string,
): Promise<void> {
await minio.putObject(BUCKET, key, buffer, buffer.length, {
"Content-Type": contentType ?? "application/octet-stream",
});
}
export async function removeObject(key: string): Promise<void> {
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<ObjectStat | null> {
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);
}

View File

@@ -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();
}

View File

@@ -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<number>`count(*)::int` })
.from(tracks);
const [{ pending }] = await db
.select({ pending: sql<number>`count(*)::int` })
.from(tracks)
.where(eq(tracks.status, "pending"));
const [{ approved }] = await db
.select({ approved: sql<number>`count(*)::int` })
.from(tracks)
.where(eq(tracks.status, "approved"));
const [{ rejected }] = await db
.select({ rejected: sql<number>`count(*)::int` })
.from(tracks)
.where(eq(tracks.status, "rejected"));
const [{ users: userCount }] = await db.select({ users: sql<number>`count(*)::int` }).from(users);
const [{ playlists: playlistCount }] = await db
.select({ playlists: sql<number>`count(*)::int` })
.from(playlists);
const [{ artists: artistCount }] = await db
.select({ artists: sql<number>`count(*)::int` })
.from(artists);
const [{ comments: commentCount }] = await db
.select({ comments: sql<number>`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<typeof users.$inferInsert> = { 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<number>`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<number>`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<number>`count(*)::int` }).from(playlists);
res.json({ items: rows.map((p) => playlistDTO(p as never)), total: count });
}),
);

View File

@@ -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) });
}),
);

View File

@@ -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<number>`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<number>`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 });
}),
);

View File

@@ -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 });
}),
);

View File

@@ -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<string, CommentDTO>();
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 });
}),
);

View File

@@ -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);

View File

@@ -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)),
});
}),
);

View File

@@ -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) });
}),
);

View File

@@ -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<string, string>)[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);
}),
);

View File

@@ -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<number>`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 });
}),
);

View File

@@ -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)),
});
}),
);

View File

@@ -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<number>`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) });
}),
);

View File

@@ -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<string> {
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<typeof trackDTO>[] = [];
const rejected: ReturnType<typeof trackDTO>[] = [];
for (const t of rows) {
const processing = Boolean((t.metadata as Record<string, unknown> | 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 });
}),
);

View File

@@ -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<number>`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!) });
}),
);

View File

@@ -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<Playlist | null> {
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<boolean> {
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<boolean> {
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<Playlist[]> {
return db.select().from(playlists).where(eq(playlists.ownerId, userId));
}

View File

@@ -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<string | undefined> {
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<string> {
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<typeof artists.$inferInsert> = { 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<string> {
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<typeof albums.$inferInsert> = { 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<void> {
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",
);
}

View File

@@ -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<MetadataJobData>(
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;
}

13
apps/server/tsconfig.json Normal file
View File

@@ -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"]
}

8
apps/web/Dockerfile.dev Normal file
View File

@@ -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"]

19
apps/web/index.html Normal file
View File

@@ -0,0 +1,19 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#07080c" />
<title>ADC Retivo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

32
apps/web/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

66
apps/web/src/App.tsx Normal file
View File

@@ -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 (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse-slow text-lg font-semibold tracking-widest text-white/70">
ADC RETIVO
</div>
</div>
);
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) return <Splash />;
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export function App() {
const { user, loading } = useAuth();
return (
<Routes>
<Route
path="/login"
element={loading ? <Splash /> : user ? <Navigate to="/" replace /> : <Login />}
/>
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/" element={<Home />} />
<Route path="/library" element={<Library />} />
<Route path="/add" element={<AddTrack />} />
<Route path="/account" element={<Account />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/playlist/:id" element={<PlaylistPage />} />
<Route path="/artist/:id" element={<ArtistPage />} />
<Route path="/album/:id" element={<AlbumPage />} />
<Route path="/track/:id" element={<TrackPage />} />
<Route path="/u/:username" element={<UserPage />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/:section" element={<Admin />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -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<string | null>(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 (
<Modal open={open} onClose={onClose} title="Добавить в плейлист">
<div className="space-y-2">
<button
className="btn-primary w-full"
onClick={() => createAndAdd.mutate()}
disabled={createAndAdd.isPending}
>
<Plus size={15} /> Новый плейлист
</button>
<div className="max-h-72 space-y-1 overflow-y-auto pt-2">
{isLoading && <p className="text-sm text-zinc-500">Загрузка...</p>}
{playlists?.map((pl) => (
<button
key={pl.id}
onClick={() => add.mutate(pl.id)}
className="flex w-full items-center justify-between rounded-xl px-3 py-2 text-left text-sm hover:bg-white/5"
>
<span className="truncate">{pl.title}</span>
{createdId === pl.id ? (
<Check size={15} className="text-emerald-400" />
) : (
<Plus size={15} className="text-zinc-500" />
)}
</button>
))}
{playlists && playlists.length === 0 && (
<p className="px-3 text-sm text-zinc-500">У вас пока нет плейлистов.</p>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -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 <span className={className}>{fallback ?? "Неизвестный исполнитель"}</span>;
}
return (
<span className={className}>
{list.map((a, i) => (
<span key={a.id}>
{i > 0 && <span className="text-zinc-600">, </span>}
<Link to={`/artist/${a.id}`} className={linkClassName} onClick={onNavigate}>
{a.name}
</Link>
</span>
))}
</span>
);
}

View File

@@ -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 (
<div className={compact ? "" : "card p-4"}>
<h2 className="section-title mb-3">Комментарии ({data?.total ?? 0})</h2>
<form
className="mb-4 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (body.trim()) add.mutate({ text: body.trim() });
}}
>
<input
className="input"
placeholder="Оставить комментарий..."
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<button className="btn-primary px-3" disabled={!body.trim() || add.isPending}>
<Send size={15} />
</button>
</form>
<div className="space-y-4">
{data?.comments.map((c) => (
<CommentNode
key={c.id}
comment={c}
currentUserId={user?.id ?? ""}
onReply={(text, parentId) => add.mutate({ text, parentId })}
onDelete={(cid) => remove.mutate(cid)}
/>
))}
{data && data.comments.length === 0 && (
<p className="text-sm text-zinc-500">Комментариев пока нет. Будьте первым!</p>
)}
</div>
</div>
);
}
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 (
<div className="text-sm">
<div className="flex items-start gap-2">
<Link to={comment.user ? `/u/${comment.user.username}` : "#"}>
<Cover
src={comment.user?.avatarUrl}
name={comment.user?.displayName || comment.user?.username}
rounded="rounded-full"
className="h-7 w-7 shrink-0"
/>
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
{comment.user ? (
<Link to={`/u/${comment.user.username}`} className="link text-xs font-medium">
{comment.user.displayName || comment.user.username}
</Link>
) : (
<span className="text-xs text-zinc-500">удалён</span>
)}
<span className="text-[10px] text-zinc-600">{formatDate(comment.createdAt)}</span>
</div>
<p className={comment.deleted ? "italic text-zinc-600" : "text-zinc-200"}>
{comment.deleted ? "Комментарий удалён" : comment.body}
</p>
<div className="mt-1 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-white" onClick={() => setReplyOpen((v) => !v)}>
<CornerDownRight size={11} className="mr-1 inline" /> Ответить
</button>
{comment.user?.id === currentUserId && !comment.deleted && (
<button className="hover:text-red-300" onClick={() => onDelete(comment.id)}>
<Trash2 size={11} className="mr-1 inline" /> Удалить
</button>
)}
</div>
{replyOpen && (
<form
className="mt-2 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
if (reply.trim()) {
onReply(reply.trim(), comment.id);
setReply("");
setReplyOpen(false);
}
}}
>
<input
className="input py-1.5 text-xs"
placeholder="Ответ..."
value={reply}
onChange={(e) => setReply(e.target.value)}
/>
<button className="btn-primary px-3 py-1.5">
<Send size={13} />
</button>
</form>
)}
{comment.replies.length > 0 && (
<div className="mt-3 space-y-3 border-l border-white/10 pl-3">
{comment.replies.map((r) => (
<CommentNode
key={r.id}
comment={r}
currentUserId={currentUserId}
onReply={onReply}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className={cx("relative overflow-hidden bg-white/5", rounded, className)}>
{src ? (
<img src={src} alt={name ?? ""} className="h-full w-full object-cover" loading="lazy" />
) : (
<div className="grid h-full w-full place-items-center text-xs font-semibold text-zinc-500">
{initials(name) || "AR"}
</div>
)}
</div>
);
}

View File

@@ -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 (
<Modal open={open} onClose={onClose} title="Новый плейлист">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input
className="input"
autoFocus
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder="Мой плейлист"
/>
</div>
<div>
<label className="label">Описание</label>
<textarea
className="input min-h-20"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<label className="label">Доступ</label>
<select
className="input"
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as typeof form.visibility })} >
<option value="private">Приватный</option>
<option value="unlisted">По ссылке</option>
<option value="public">Публичный</option>
</select>
</div>
{error && <p className="text-sm text-red-300">{error}</p>}
<button
className="btn-primary w-full"
onClick={() => create.mutate()}
disabled={!form.title.trim() || create.isPending}
>
Создать
</button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,176 @@
import {
Compass,
Library as LibraryIcon,
PlusCircle,
Search as SearchIcon,
Settings,
Shield,
User as UserIcon,
} from "lucide-react";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useState } from "react";
import { cx } from "../lib/format";
import { canAccessAdmin } from "../lib/permissions";
import { useAuth } from "../store/auth";
import { Player } from "./Player";
const navItems = [
{ to: "/", label: "Главная", icon: Compass, end: true },
{ to: "/library", label: "Библиотека", icon: LibraryIcon },
{ to: "/add", label: "Добавить", icon: PlusCircle },
{ to: "/account", label: "Аккаунт", icon: UserIcon },
];
export function Layout() {
const { user } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [query, setQuery] = useState("");
const isAdmin = user ? canAccessAdmin(user) : false;
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = query.trim();
if (q) navigate(`/search?q=${encodeURIComponent(q)}`);
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1">
{/* Sidebar (desktop) */}
<aside className="hidden w-64 shrink-0 flex-col gap-2 p-4 md:flex">
<NavLink to="/" className="mb-4 flex items-center gap-3 px-2">
<span className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-accent to-accent2 text-sm font-black text-black">
AR
</span>
<span className="text-sm font-semibold tracking-widest text-white">
ADC RETIVO
</span>
</NavLink>
<nav className="flex flex-col gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cx(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
isActive
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cx(
"flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition",
isActive || location.pathname.startsWith("/admin")
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)
}
>
<Shield size={18} />
Админ-панель
</NavLink>
)}
</nav>
</aside>
{/* Main */}
<main className="flex min-h-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/5 bg-black/30 px-4 py-3 backdrop-blur-xl md:px-6">
<div className="flex items-center gap-2 md:hidden">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-accent to-accent2 text-[10px] font-black text-black">
AR
</span>
</div>
<form onSubmit={submitSearch} className="relative flex-1">
<SearchIcon
size={16}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500"
/>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Поиск треков, артистов, альбомов, плейлистов, людей..."
className="input pl-9"
/>
</form>
{user && (
<NavLink
to={`/u/${user.username}`}
className="hidden items-center gap-2 rounded-full border border-white/10 bg-white/5 py-1.5 pl-1.5 pr-3 text-xs text-zinc-300 hover:bg-white/10 sm:flex"
>
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt=""
className="h-6 w-6 rounded-full object-cover"
/>
) : (
<span className="grid h-6 w-6 place-items-center rounded-full bg-white/10 text-[10px]">
{user.username[0]?.toUpperCase()}
</span>
)}
<span className="max-w-[120px] truncate">
{user.displayName || user.username}
</span>
</NavLink>
)}
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-40 pt-4 md:px-6 md:pb-32">
<Outlet />
</div>
</main>
</div>
{/* Mobile bottom nav */}
<nav className="fixed inset-x-0 bottom-0 z-30 flex items-stretch justify-around border-t border-white/10 bg-black/60 backdrop-blur-xl md:hidden">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
cx(
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
isActive ? "text-white" : "text-zinc-500",
)
}
>
<item.icon size={18} />
{item.label}
</NavLink>
))}
{isAdmin && (
<NavLink
to="/admin"
className={({ isActive }) =>
cx(
"flex flex-1 flex-col items-center gap-1 py-2 text-[10px] transition",
isActive ? "text-white" : "text-zinc-500",
)
}
>
<Shield size={18} />
Админ
</NavLink>
)}
</nav>
<Player />
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { tracksApi } from "../lib/api";
import { selectCurrentTrack, usePlayer } from "../store/player";
export function LyricsView({ trackId, compact = false }: { trackId: string; compact?: boolean }) {
const currentTime = usePlayer((s) => s.currentTime);
const seek = usePlayer((s) => s.seek);
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLParagraphElement>(null);
const { data, isLoading } = useQuery({
queryKey: ["lyrics", trackId],
queryFn: () => tracksApi.get(trackId),
enabled: Boolean(trackId),
staleTime: 5 * 60_000,
});
const synced = data?.lyrics.synced ?? null;
const plain = data?.lyrics.plain ?? null;
const activeIndex =
synced && synced.length
? synced.findIndex((line, i) => {
const next = synced[i + 1];
return currentTime * 1000 >= line.timeMs && (!next || currentTime * 1000 < next.timeMs);
})
: -1;
useEffect(() => {
if (activeRef.current && containerRef.current) {
activeRef.current.scrollIntoView({ block: "center", behavior: "smooth" });
}
}, [activeIndex]);
if (isLoading) {
return <p className="p-6 text-sm text-zinc-500">Загрузка текста...</p>;
}
if (!synced?.length && !plain) {
return (
<p className="p-6 text-sm text-zinc-500">
Текст песни не найден. Его можно добавить вручную в админ-панели.
</p>
);
}
if (synced?.length) {
return (
<div
ref={containerRef}
className={compact ? "max-h-64 overflow-y-auto px-2" : "max-h-full overflow-y-auto px-2 py-4"}
>
{synced.map((line, i) => (
<p
key={`${line.timeMs}-${i}`}
ref={i === activeIndex ? activeRef : undefined}
onClick={() => seek(line.timeMs / 1000)}
className={
"cursor-pointer rounded-lg px-3 py-1.5 text-lg font-semibold leading-snug transition " +
(i === activeIndex
? "bg-white/10 text-white"
: "text-zinc-500 hover:text-zinc-300")
}
>
{line.text || "♪"}
</p>
))}
</div>
);
}
return (
<pre className="whitespace-pre-wrap px-4 py-4 font-sans text-sm leading-relaxed text-zinc-300">
{plain}
</pre>
);
}

View File

@@ -0,0 +1,39 @@
import { X } from "lucide-react";
export function Modal({
open,
onClose,
title,
children,
wide = false,
}: {
open: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
wide?: boolean;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 grid place-items-center bg-black/60 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className={
"w-full animate-fade-in rounded-2xl border border-white/10 bg-[#0d0f16]/95 p-5 shadow-2xl " +
(wide ? "max-w-3xl" : "max-w-lg")
}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-base font-semibold text-white">{title}</h3>
<button className="text-zinc-400 hover:text-white" onClick={onClose}>
<X size={18} />
</button>
</div>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,655 @@
import {
ChevronDown,
Download,
ListMusic,
Maximize2,
MessageSquare,
Mic2,
Pause,
Play,
PlusCircle,
Repeat,
Repeat1,
Shuffle,
SkipBack,
SkipForward,
Volume2,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { cx, formatTime } from "../lib/format";
import { selectCurrentTrack, usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Comments } from "./Comments";
import { Cover } from "./Cover";
import { LyricsView } from "./LyricsView";
import { QueuePanel } from "./QueuePanel";
type Tab = "queue" | "lyrics" | "comments";
export function Player() {
const audioRef = useRef<HTMLAudioElement>(null);
const current = usePlayer(selectCurrentTrack);
const isPlaying = usePlayer((s) => s.isPlaying);
const shuffle = usePlayer((s) => s.shuffle);
const repeat = usePlayer((s) => s.repeat);
const expanded = usePlayer((s) => s.expanded);
const currentTime = usePlayer((s) => s.currentTime);
const duration = usePlayer((s) => s.duration);
const volume = usePlayer((s) => s.volume);
const toggle = usePlayer((s) => s.toggle);
const next = usePlayer((s) => s.next);
const prev = usePlayer((s) => s.prev);
const setExpanded = usePlayer((s) => s.setExpanded);
const setCurrentTime = usePlayer((s) => s.setCurrentTime);
const setDuration = usePlayer((s) => s.setDuration);
const setVolume = usePlayer((s) => s.setVolume);
const toggleShuffle = usePlayer((s) => s.toggleShuffle);
const cycleRepeat = usePlayer((s) => s.cycleRepeat);
const [queueOpen, setQueueOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [tab, setTab] = useState<Tab>("queue");
// Load a new source when the track changes
useEffect(() => {
const audio = audioRef.current;
if (!audio || !current) return;
const src = `/api/tracks/${current.id}/stream`;
if (!audio.src.endsWith(src)) {
audio.src = src;
audio.load();
}
if (usePlayer.getState().isPlaying) void audio.play().catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current?.id]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
if (isPlaying) void audio.play().catch(() => {});
else audio.pause();
}, [isPlaying]);
useEffect(() => {
const audio = audioRef.current;
if (audio) audio.volume = volume;
}, [volume]);
// System media session (lock screen / OS media controls)
useEffect(() => {
if (!("mediaSession" in navigator) || !current) return;
const artwork = current.coverUrl
? [
{
src: new URL(current.coverUrl, window.location.origin).href,
sizes: "500x500",
type: "image/jpeg",
},
]
: [];
navigator.mediaSession.metadata = new MediaMetadata({
title: current.title,
artist:
(current.artists?.length
? current.artists.map((a) => a.name)
: [current.artist?.name]
)
.filter(Boolean)
.join(", ") || "Неизвестный исполнитель",
album: current.album?.title ?? "",
artwork,
});
}, [current?.id]);
useEffect(() => {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused";
}, [isPlaying]);
// (Re)register Media Session handlers. iOS Safari (and some browsers) drop the
// action handlers when the audio source changes, so we register them on mount,
// on every track change and again when playback starts.
const registerMediaSession = useCallback(() => {
if (!("mediaSession" in navigator)) return;
const ms = navigator.mediaSession;
const s = () => usePlayer.getState();
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => s().toggle()],
["pause", () => s().toggle()],
["stop", () => s().setPlaying(false)],
["previoustrack", () => s().prev()],
["nexttrack", () => s().next()],
["seekbackward", (d) => s().seek(Math.max(0, s().currentTime - (d.seekOffset ?? 10)))],
["seekforward", (d) => s().seek(s().currentTime + (d.seekOffset ?? 10))],
[
"seekto",
(d) => {
if (typeof d.seekTime === "number") s().seek(d.seekTime);
},
],
];
for (const [action, handler] of handlers) {
try {
ms.setActionHandler(action, handler);
} catch {
/* unsupported action */
}
}
}, []);
useEffect(() => {
registerMediaSession();
return () => {
if (!("mediaSession" in navigator)) return;
const actions: MediaSessionAction[] = [
"play",
"pause",
"stop",
"previoustrack",
"nexttrack",
"seekbackward",
"seekforward",
"seekto",
];
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, null);
} catch {
/* ignore */
}
}
};
}, [registerMediaSession]);
useEffect(() => {
registerMediaSession();
}, [current?.id, registerMediaSession]);
useEffect(() => {
if (!("mediaSession" in navigator) || !duration) return;
try {
navigator.mediaSession.setPositionState({
duration,
playbackRate: 1,
position: Math.min(currentTime, duration),
});
} catch {
/* ignore */
}
}, [currentTime, duration]);
const seek = (time: number) => {
const audio = audioRef.current;
if (audio) audio.currentTime = time;
setCurrentTime(time);
};
const uploader = current?.uploadedBy;
const progressPct = duration ? (currentTime / duration) * 100 : 0;
return (
<>
<audio
ref={audioRef}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) setDuration(d);
}}
onEnded={() => next()}
onPlay={registerMediaSession}
preload="metadata"
/>
{current && (
<>
{/* Mini player */}
<div className="fixed inset-x-0 bottom-14 z-30 border-t border-white/10 bg-black/50 backdrop-blur-2xl md:bottom-0">
<div className="flex items-center gap-3 px-3 py-2 md:px-4 md:py-3">
<button
onClick={() => setExpanded(true)}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
title="Развернуть плеер"
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-lg bg-white/5">
<Cover
src={current.coverUrl}
name={current.title}
className="h-11 w-11"
/>
<span className="absolute inset-0 hidden place-items-center bg-black/40 group-hover:grid">
<Maximize2 size={14} />
</span>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-medium text-white">
{current.title}
</div>
<div className="truncate text-xs text-zinc-400">
{(current.artists?.length
? current.artists.map((a) => a.name)
: [current.artist?.name]
)
.filter(Boolean)
.join(", ") || "Неизвестный исполнитель"}
</div>
</div>
</button>
<div className="hidden flex-1 items-center gap-2 md:flex">
<button
className="text-zinc-300 hover:text-white"
onClick={prev}
title="Назад"
>
<SkipBack size={18} />
</button>
<button
className="grid h-9 w-9 place-items-center rounded-full bg-white text-black transition hover:scale-105"
onClick={toggle}
>
{isPlaying ? (
<Pause size={16} />
) : (
<Play size={16} className="translate-x-[1px]" />
)}
</button>
<button
className="text-zinc-300 hover:text-white"
onClick={next}
title="Вперёд"
>
<SkipForward size={18} />
</button>
<span className="ml-1 w-10 text-right text-[11px] tabular-nums text-zinc-500">
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="range-track flex-1"
/>
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
{formatTime(duration)}
</span>
</div>
<div className="flex items-center gap-1 md:gap-2">
<button
className={cx(
"hidden p-2 sm:inline-flex",
shuffle ? "text-accent" : "text-zinc-400 hover:text-white",
)}
onClick={toggleShuffle}
title="Перемешать"
>
<Shuffle size={16} />
</button>
<button
className={cx(
"hidden p-2 sm:inline-flex",
repeat !== "off"
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={cycleRepeat}
title="Повтор"
>
{repeat === "one" ? (
<Repeat1 size={16} />
) : (
<Repeat size={16} />
)}
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => setAddOpen(true)}
title="Добавить в плейлист"
>
<PlusCircle size={16} />
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => {
setTab("comments");
setExpanded(true);
}}
title="Комментарии"
>
<MessageSquare size={16} />
</button>
<button
className="p-2 text-zinc-400 hover:text-white"
onClick={() => setQueueOpen((v) => !v)}
title="Очередь"
>
<ListMusic size={16} />
</button>
<button
className="hidden p-2 text-zinc-400 hover:text-white md:inline-flex"
onClick={toggle}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
</div>
</div>
<div className="h-0.5 w-full bg-white/10 md:hidden">
<div
className="h-full bg-gradient-to-r from-accent to-accent2"
style={{ width: `${progressPct}%` }}
/>
</div>
{queueOpen && (
<div className="absolute bottom-full right-2 mb-2 flex h-80 w-80 max-w-[92vw] flex-col overflow-hidden rounded-2xl border border-white/10 bg-black/85 p-2 backdrop-blur-2xl">
<QueuePanel onClose={() => setQueueOpen(false)} />
</div>
)}
</div>
{/* Expanded player */}
{expanded && (
<div className="fixed inset-0 z-40 flex flex-col animate-fade-in">
{/* Background: blurred cover, or a gradient when there's no cover */}
<div className="absolute inset-0 -z-10 overflow-hidden bg-gradient-to-br from-accent via-[#0b0d14] to-accent2">
{current.coverUrl && (
<img
src={current.coverUrl}
alt=""
className="h-full w-full scale-125 object-cover opacity-50 blur-3xl"
/>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black via-black to-black" />
</div>
<header className="flex items-center justify-between px-5 py-4">
<button
className="btn-ghost"
onClick={() => setExpanded(false)}
>
<ChevronDown size={16} /> Свернуть
</button>
<div className="text-xs uppercase tracking-widest text-zinc-400">
Сейчас играет
</div>
<a
className="btn-ghost"
href={`/api/tracks/${current.id}/stream?download=1`}
>
<Download size={16} /> Скачать
</a>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-hidden px-5 pb-8 md:flex-row md:items-stretch md:justify-center md:gap-12">
{/* Left: artwork + info */}
<div className="mx-auto w-full max-w-sm shrink-0 md:mx-0 md:my-auto">
<div className="aspect-square overflow-hidden rounded-3xl border border-white/10 bg-white/5 shadow-2xl">
{current.coverUrl ? (
<img
src={current.coverUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="grid h-full w-full place-items-center bg-gradient-to-br from-accent/30 to-accent2/30 text-5xl text-white/40">
AR
</div>
)}
</div>
<div className="mt-5 text-center md:text-left flex flex-col">
<Link
to={`/track/${current.id}`}
onClick={() => setExpanded(false)}
className="text-2xl font-bold tracking-tight text-white hover:underline"
>
{current.title}
</Link>
{current.album && (
<Link
to={`/album/${current.album.id}`}
onClick={() => setExpanded(false)}
className="block text-sm text-zinc-500 hover:text-zinc-300 hover:underline"
>
{current.album.title}
</Link>
)}
<div className="mt-1 flex items-center justify-center gap-2 md:justify-start">
{current.artist && (
<Cover
src={current.artist.imageUrl}
name={current.artist.name}
rounded="rounded-full"
className="h-6 w-6"
/>
)}
<ArtistLinks
artists={current.artists}
fallback={current.artist?.name}
className="text-zinc-300"
onNavigate={() => setExpanded(false)}
/>
</div>
<div className="mt-3 flex items-center justify-center gap-2 md:justify-start border border-white/10 bg-white/5 shadow-2xl rounded-3xl p-1">
{uploader ? (
<>
<Link
to={`/u/${uploader.username}`}
onClick={() => setExpanded(false)}
>
<Cover
src={uploader.avatarUrl}
name={uploader.displayName || uploader.username}
rounded="rounded-full"
className="h-7 w-7"
/>
</Link>
<span className="text-xs text-zinc-500">
Добавил:{" "}
<Link
to={`/u/${uploader.username}`}
onClick={() => setExpanded(false)}
className="link"
>
{uploader.displayName || uploader.username}
</Link>
</span>
</>
) : (
<span className="text-xs text-zinc-500">
Добавил: неизвестно
</span>
)}
</div>
</div>
</div>
{/* Right: controls + tabbed panel */}
<div className="flex min-h-0 w-full max-w-xl flex-1 flex-col">
<div className="card shrink-0 p-3">
<div className="flex items-center gap-3">
<span className="w-10 text-right text-[11px] tabular-nums text-zinc-500">
{formatTime(currentTime)}
</span>
<input
type="range"
min={0}
max={duration || 0}
step={1}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="range-track flex-1"
/>
<span className="w-10 text-[11px] tabular-nums text-zinc-500">
{formatTime(duration)}
</span>
</div>
<div className="mt-3 flex items-center justify-center gap-4">
<button
className={cx(
shuffle
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={toggleShuffle}
title="Перемешать"
>
<Shuffle size={20} />
</button>
<button
className="text-zinc-200 hover:text-white"
onClick={prev}
title="Назад"
>
<SkipBack size={26} />
</button>
<button
className="grid h-14 w-14 place-items-center rounded-full bg-white text-black transition hover:scale-105"
onClick={toggle}
>
{isPlaying ? (
<Pause size={22} />
) : (
<Play size={22} className="translate-x-[1px]" />
)}
</button>
<button
className="text-zinc-200 hover:text-white"
onClick={next}
title="Вперёд"
>
<SkipForward size={26} />
</button>
<button
className={cx(
repeat !== "off"
? "text-accent"
: "text-zinc-400 hover:text-white",
)}
onClick={cycleRepeat}
title="Повтор"
>
{repeat === "one" ? (
<Repeat1 size={20} />
) : (
<Repeat size={20} />
)}
</button>
</div>
<div className="mt-3 flex items-center gap-2 px-2">
<Volume2 size={16} className="text-zinc-500" />
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="range-track w-32"
/>
<button
className="btn-ghost ml-auto"
onClick={() => setAddOpen(true)}
>
<PlusCircle size={15} /> В плейлист
</button>
</div>
</div>
<div className="card mt-4 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="flex shrink-0 gap-1 border-b border-white/10 p-2">
<TabButton
active={tab === "queue"}
onClick={() => setTab("queue")}
icon={<ListMusic size={14} />}
>
Очередь
</TabButton>
<TabButton
active={tab === "lyrics"}
onClick={() => setTab("lyrics")}
icon={<Mic2 size={14} />}
>
Текст
</TabButton>
<TabButton
active={tab === "comments"}
onClick={() => setTab("comments")}
icon={<MessageSquare size={14} />}
>
Комментарии
</TabButton>
</div>
<div className="min-h-0 flex-1">
{tab === "queue" && (
<div className="h-full min-h-0 overflow-hidden">
<QueuePanel />
</div>
)}
{tab === "lyrics" && (
<div className="h-full min-h-0 overflow-y-auto">
<LyricsView trackId={current.id} />
</div>
)}
{tab === "comments" && (
<div className="h-full min-h-0 overflow-y-auto p-3">
<Comments trackId={current.id} compact />
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
<AddToPlaylistModal
track={current}
open={addOpen}
onClose={() => setAddOpen(false)}
/>
</>
)}
</>
);
}
function TabButton({
active,
onClick,
icon,
children,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={cx(
"flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition",
active
? "bg-white/10 text-white"
: "text-zinc-400 hover:bg-white/5 hover:text-white",
)}
>
{icon}
{children}
</button>
);
}

View File

@@ -0,0 +1,21 @@
import { Link } from "react-router-dom";
import type { Playlist } from "../lib/types";
import { Cover } from "./Cover";
export function PlaylistCard({ playlist }: { playlist: Playlist }) {
return (
<Link
to={`/playlist/${playlist.id}`}
className="card group p-3 transition hover:bg-white/[0.07]"
>
<Cover src={playlist.coverUrl} name={playlist.title} className="aspect-square w-full" />
<div className="mt-3">
<div className="truncate text-sm font-medium text-white">{playlist.title}</div>
<div className="truncate text-xs text-zinc-500">
{playlist.owner?.displayName || playlist.owner?.username || "Плейлист"} ·{" "}
{playlist.trackCount} треков
</div>
</div>
</Link>
);
}

View File

@@ -0,0 +1,121 @@
import { ChevronDown, ChevronUp, Play, PlusCircle, Trash2, X } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { formatDuration } from "../lib/format";
import type { Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Cover } from "./Cover";
export function QueuePanel({ onClose }: { onClose?: () => void }) {
const queue = usePlayer((s) => s.queue);
const index = usePlayer((s) => s.index);
const isPlaying = usePlayer((s) => s.isPlaying);
const removeAt = usePlayer((s) => s.removeAt);
const move = usePlayer((s) => s.move);
const goTo = usePlayer((s) => s.goTo);
const toggle = usePlayer((s) => s.toggle);
const [addTrack, setAddTrack] = useState<Track | null>(null);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center justify-between px-3 py-2">
<span className="text-xs uppercase tracking-wide text-zinc-400">Очередь · {queue.length}</span>
{onClose && (
<button onClick={onClose} className="text-zinc-400 hover:text-white">
<X size={15} />
</button>
)}
</div>
{queue.length === 0 ? (
<p className="px-3 py-6 text-sm text-zinc-500">Очередь пуста.</p>
) : (
<div className="min-h-0 flex-1 space-y-1 overflow-y-auto px-1 pb-2">
{queue.map((track, i) => {
const active = i === index;
return (
<div
key={`${track.id}-${i}`}
className={
"group flex items-center gap-2 rounded-xl px-2 py-1.5 text-sm transition " +
(active ? "bg-white/10" : "hover:bg-white/5")
}
>
<button
className="relative h-9 w-9 shrink-0 overflow-hidden rounded-lg"
onClick={() => (active ? toggle() : goTo(i))}
title="Играть"
>
<Cover src={track.coverUrl} name={track.title} className="h-9 w-9" />
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
<Play size={13} className="text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
<Link
to={`/track/${track.id}`}
onClick={onClose}
className={"block truncate hover:underline " + (active ? "text-white" : "text-zinc-200")}
>
{track.title}
</Link>
<div className="truncate text-[11px] text-zinc-500">
<ArtistLinks
artists={track.artists}
fallback={track.artist?.name}
linkClassName="hover:text-zinc-300"
onNavigate={onClose}
/>
</div>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-zinc-500">
{formatDuration(track.durationMs)}
</span>
<div className="flex shrink-0 items-center opacity-0 transition group-hover:opacity-100">
<button
className="p-1 text-zinc-400 hover:text-white"
onClick={() => setAddTrack(track)}
title="Добавить в плейлист"
>
<PlusCircle size={13} />
</button>
<button
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
disabled={i === 0}
onClick={() => move(i, i - 1)}
title="Выше"
>
<ChevronUp size={14} />
</button>
<button
className="p-1 text-zinc-400 hover:text-white disabled:opacity-30"
disabled={i === queue.length - 1}
onClick={() => move(i, i + 1)}
title="Ниже"
>
<ChevronDown size={14} />
</button>
<button
className="p-1 text-zinc-400 hover:text-red-300"
onClick={() => removeAt(i)}
title="Убрать из очереди"
>
<Trash2 size={13} />
</button>
</div>
</div>
);
})}
</div>
)}
<AddToPlaylistModal track={addTrack} open={Boolean(addTrack)} onClose={() => setAddTrack(null)} />
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Check, Share2 } from "lucide-react";
import { useState } from "react";
export function ShareButton({ path, label = "Поделиться" }: { path: string; label?: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
const url = `${window.location.origin}${path}`;
try {
await navigator.clipboard.writeText(url);
} catch {
window.prompt("Скопируйте ссылку:", url);
}
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<button className="btn-ghost" onClick={copy}>
{copied ? <Check size={15} /> : <Share2 size={15} />}
{copied ? "Ссылка скопирована" : label}
</button>
);
}

View File

@@ -0,0 +1,165 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Heart, ListPlus, MoreHorizontal, Play, PlusCircle } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import type { Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "./AddToPlaylistModal";
import { ArtistLinks } from "./ArtistLinks";
import { Cover } from "./Cover";
export function TrackRow({
track,
list,
index,
showCover = true,
showAlbum = true,
showUploader = false,
initialLiked = false,
right,
}: {
track: Track;
list?: Track[];
index?: number;
showCover?: boolean;
showAlbum?: boolean;
showUploader?: boolean;
initialLiked?: boolean;
right?: React.ReactNode;
}) {
const playNow = usePlayer((s) => s.playNow);
const playNext = usePlayer((s) => s.playNext);
const addToQueue = usePlayer((s) => s.addToQueue);
const current = usePlayer((s) => s.queue[s.index] ?? null);
const isPlaying = usePlayer((s) => s.isPlaying);
const qc = useQueryClient();
const [liked, setLiked] = useState(initialLiked);
const [modalOpen, setModalOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const like = useMutation({
mutationFn: () => (liked ? tracksApi.unlike(track.id) : tracksApi.like(track.id)),
onSuccess: () => {
setLiked((v) => !v);
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
const active = current?.id === track.id;
return (
<div
className={cx(
"group flex items-center gap-3 rounded-xl px-2 py-2 transition hover:bg-white/[0.06]",
active && "bg-white/[0.08]",
)}
>
{index !== undefined && (
<span className="w-5 shrink-0 text-right text-xs tabular-nums text-zinc-500">{index + 1}</span>
)}
<button
className="relative grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg"
onClick={() => playNow(track, list)}
title="Играть"
>
{showCover ? (
<Cover src={track.coverUrl} name={track.title} className="h-10 w-10" />
) : (
<span className="h-10 w-10 rounded-lg bg-white/5" />
)}
<span className="absolute inset-0 grid place-items-center bg-black/45 opacity-0 transition group-hover:opacity-100">
<Play size={15} className="text-white" />
</span>
</button>
<div className="min-w-0 flex-1">
<Link to={`/track/${track.id}`} className="block truncate text-sm font-medium text-white hover:underline">
{track.title}
</Link>
<div className="truncate text-xs text-zinc-500">
<ArtistLinks
artists={track.artists}
fallback={track.artist?.name}
linkClassName="hover:text-zinc-300"
/>
{showUploader && track.uploadedBy && (
<span className="text-zinc-600"> · загрузил {track.uploadedBy.username}</span>
)}
</div>
</div>
{showAlbum && track.album && (
<Link
to={`/album/${track.album.id}`}
className="hidden max-w-[180px] truncate text-xs text-zinc-500 hover:text-zinc-300 lg:block"
>
{track.album.title}
</Link>
)}
{right}
<span className="w-10 shrink-0 text-right text-xs tabular-nums text-zinc-500">
{formatDuration(track.durationMs)}
</span>
<button
className={cx("shrink-0 p-1.5", liked ? "text-rose-400" : "text-zinc-500 hover:text-white")}
onClick={() => like.mutate()}
title="В избранное"
>
<Heart size={15} fill={liked ? "currentColor" : "none"} />
</button>
<div className="relative shrink-0">
<button
className="p-1.5 text-zinc-500 hover:text-white"
onClick={() => setMenuOpen((v) => !v)}
title="Ещё"
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
<div className="absolute right-0 top-8 z-20 w-52 rounded-xl border border-white/10 bg-[#0d0f16]/95 p-1 shadow-2xl backdrop-blur-xl">
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
addToQueue(track);
setMenuOpen(false);
}}
>
<ListPlus size={14} /> В очередь
</button>
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
playNext(track);
setMenuOpen(false);
}}
>
<Play size={14} /> Играть следующим
</button>
<button
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm hover:bg-white/5"
onClick={() => {
setModalOpen(true);
setMenuOpen(false);
}}
>
<PlusCircle size={14} /> Добавить в плейлист
</button>
</div>
</>
)}
</div>
<AddToPlaylistModal track={track} open={modalOpen} onClose={() => setModalOpen(false)} />
</div>
);
}

82
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,82 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
@apply bg-bg text-zinc-100 font-sans antialiased;
background-image:
radial-gradient(1200px 600px at 80% -10%, rgba(139, 92, 246, 0.18), transparent 60%),
radial-gradient(900px 500px at -10% 20%, rgba(34, 211, 238, 0.12), transparent 55%);
background-attachment: fixed;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.14);
border-radius: 999px;
}
@layer components {
.glass {
@apply bg-white/[0.045] border border-white/10 backdrop-blur-xl;
}
.glass-strong {
@apply bg-black/40 border border-white/10 backdrop-blur-2xl;
}
.card {
@apply glass rounded-2xl;
}
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition
disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70;
}
.btn-primary {
@apply btn bg-gradient-to-r from-accent to-accent2 text-black hover:brightness-110;
}
.btn-ghost {
@apply btn bg-white/5 hover:bg-white/10 text-zinc-100 border border-white/10;
}
.btn-danger {
@apply btn bg-red-500/15 hover:bg-red-500/25 text-red-200 border border-red-400/20;
}
.input {
@apply w-full rounded-xl bg-white/5 border border-white/10 px-4 py-2.5 text-sm text-zinc-100
placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent/60;
}
.label {
@apply mb-1.5 block text-xs font-medium uppercase tracking-wide text-zinc-400;
}
.chip {
@apply inline-flex items-center rounded-full border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-zinc-300;
}
.link {
@apply text-zinc-300 hover:text-white transition-colors;
}
.section-title {
@apply text-lg font-semibold tracking-tight text-white;
}
}
.range-track input[type="range"] {
@apply h-1 w-full cursor-pointer appearance-none rounded-full bg-white/15;
}
input[type="range"]::-webkit-slider-thumb {
@apply h-3 w-3 appearance-none rounded-full bg-white;
}

209
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,209 @@
import axios from "axios";
import type {
Album,
Artist,
Comment,
Lyrics,
Playlist,
PlaylistEntry,
PublicUser,
SelfUser,
Track,
UploadStatus,
} from "./types";
export const api = axios.create({
baseURL: "/api",
withCredentials: true,
});
export interface SearchResults {
tracks: Track[];
artists: Artist[];
albums: Album[];
playlists: Playlist[];
users: PublicUser[];
}
export const authApi = {
session: () => api.get<{ user: SelfUser | null }>("/auth/session").then((r) => r.data.user),
login: (login: string, password: string) =>
api.post<{ user: SelfUser }>("/auth/login", { login, password }).then((r) => r.data.user),
logout: () => api.post("/auth/logout").then((r) => r.data),
};
export const meApi = {
update: (data: Partial<Pick<SelfUser, "displayName" | "bio" | "avatarUrl">>) =>
api.patch<{ user: SelfUser }>("/me", data).then((r) => r.data.user),
changePassword: (currentPassword: string, newPassword: string) =>
api.post("/me/password", { currentPassword, newPassword }).then((r) => r.data),
uploadAvatar: (file: File) => {
const form = new FormData();
form.append("avatar", file);
return api.post<{ user: SelfUser }>("/me/avatar", form).then((r) => r.data.user);
},
};
export const tracksApi = {
random: (limit = 20) =>
api.get<{ items: Track[] }>("/tracks/random", { params: { limit } }).then((r) => r.data.items),
list: (params: { q?: string; artistId?: string; albumId?: string; limit?: number; offset?: number } = {}) =>
api.get<{ items: Track[]; total: number }>("/tracks", { params }).then((r) => r.data),
get: (id: string) =>
api
.get<{ track: Track; lyrics: Lyrics; liked: boolean; canEdit: boolean }>(`/tracks/${id}`)
.then((r) => r.data),
update: (
id: string,
data: {
title?: string;
artistId?: string | null;
albumId?: string | null;
trackNumber?: number | null;
discNumber?: number | null;
releaseDate?: string | null;
coverUrl?: string | null;
durationMs?: number | null;
lyricsPlain?: string | null;
artists?: string[];
},
) => api.patch<{ track: Track }>(`/tracks/${id}`, data).then((r) => r.data.track),
uploadCover: (id: string, file: File, applyToAlbum = false) => {
const form = new FormData();
form.append("cover", file);
return api
.post<{ track: Track }>(`/tracks/${id}/cover`, form, {
params: applyToAlbum ? { album: "1" } : {},
})
.then((r) => r.data.track);
},
remove: (id: string) => api.delete(`/tracks/${id}`).then((r) => r.data),
like: (id: string) => api.post(`/tracks/${id}/like`).then((r) => r.data),
unlike: (id: string) => api.delete(`/tracks/${id}/like`).then((r) => r.data),
comments: (id: string) =>
api.get<{ comments: Comment[]; total: number }>(`/tracks/${id}/comments`).then((r) => r.data),
addComment: (id: string, body: string, parentId?: string | null) =>
api.post<{ comment: Comment }>(`/tracks/${id}/comments`, { body, parentId }).then((r) => r.data),
deleteComment: (commentId: string) => api.delete(`/comments/${commentId}`).then((r) => r.data),
};
export const uploadsApi = {
mine: () => api.get<UploadStatus>("/uploads/mine").then((r) => r.data),
upload: (file: File, onProgress?: (p: number) => void) => {
const form = new FormData();
form.append("file", file);
return api
.post<{ track: Track }>("/uploads", form, {
onUploadProgress: (e) => {
if (e.total && onProgress) onProgress(Math.round((e.loaded / e.total) * 100));
},
})
.then((r) => r.data.track);
},
remove: (id: string) => api.delete(`/uploads/${id}`).then((r) => r.data),
retry: (id: string) => api.post(`/uploads/${id}/retry`).then((r) => r.data),
};
export const artistsApi = {
get: (id: string) =>
api
.get<{
artist: Artist;
following: boolean;
albums: Album[];
canEdit: boolean;
}>(`/artists/${id}`)
.then((r) => r.data),
tracks: (id: string) => api.get<{ items: Track[] }>(`/artists/${id}/tracks`).then((r) => r.data.items),
follow: (id: string) => api.post(`/artists/${id}/follow`).then((r) => r.data),
unfollow: (id: string) => api.delete(`/artists/${id}/follow`).then((r) => r.data),
};
export const albumsApi = {
get: (id: string) =>
api
.get<{ album: Album; tracks: Track[]; canEdit: boolean }>(`/albums/${id}`)
.then((r) => r.data),
};
export const playlistsApi = {
mine: () => api.get<{ owned: Playlist[] }>("/me/playlists").then((r) => r.data.owned),
get: (id: string) =>
api
.get<{
playlist: Playlist;
entries: PlaylistEntry[];
collaborators: { userId: string; canEdit: boolean }[];
inLibrary: boolean;
libraryMode: "reference" | "copy" | null;
}>(`/playlists/${id}`)
.then((r) => r.data),
create: (data: Partial<Playlist> & { title: string }) =>
api.post<{ playlist: Playlist }>("/playlists", data).then((r) => r.data.playlist),
update: (id: string, data: Partial<Playlist>) =>
api.patch<{ playlist: Playlist }>(`/playlists/${id}`, data).then((r) => r.data.playlist),
remove: (id: string) => api.delete(`/playlists/${id}`).then((r) => r.data),
addTrack: (id: string, trackId: string, position?: number) =>
api.post(`/playlists/${id}/tracks`, { trackId, position }).then((r) => r.data),
removeTrack: (id: string, entryId: string) =>
api.delete(`/playlists/${id}/tracks/${entryId}`).then((r) => r.data),
reorder: (id: string, entryIds: string[]) =>
api.put(`/playlists/${id}/order`, { entryIds }).then((r) => r.data),
addToLibrary: (id: string, mode: "reference" | "copy") =>
api.post(`/playlists/${id}/library`, { mode }).then((r) => r.data),
removeFromLibrary: (id: string) => api.delete(`/playlists/${id}/library`).then((r) => r.data),
};
export const libraryApi = {
get: () =>
api
.get<{
ownedPlaylists: Playlist[];
savedPlaylists: {
mode: string;
canEdit: boolean;
originalPlaylistId: string;
playlist: Playlist;
}[];
followedArtists: Artist[];
likedTracks: Track[];
}>("/library")
.then((r) => r.data),
};
export const searchApi = {
query: (q: string, type = "all") =>
api.get<SearchResults>("/search", { params: { q, type } }).then((r) => r.data),
};
export const usersApi = {
get: (username: string) =>
api
.get<{ user: PublicUser; playlists: Playlist[]; trackCount: number }>(`/users/${username}`)
.then((r) => r.data),
};
export const adminApi = {
stats: () => api.get("/admin/stats").then((r) => r.data),
users: () => api.get<{ items: SelfUser[] }>("/admin/users").then((r) => r.data.items),
createUser: (data: {
email: string;
username: string;
password: string;
displayName?: string;
permissions?: Partial<SelfUser["permissions"]>;
}) => api.post<{ user: SelfUser }>("/admin/users", data).then((r) => r.data.user),
updateUser: (id: string, data: Record<string, unknown>) =>
api.patch<{ user: SelfUser }>(`/admin/users/${id}`, data).then((r) => r.data.user),
resetPassword: (id: string, password: string) =>
api.post(`/admin/users/${id}/password`, { password }).then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
moderation: () =>
api.get<{ items: Track[]; total: number }>("/admin/moderation").then((r) => r.data),
approve: (id: string) => api.post(`/admin/tracks/${id}/approve`).then((r) => r.data),
reject: (id: string, reason?: string) =>
api.post(`/admin/tracks/${id}/reject`, { reason }).then((r) => r.data),
playlists: () => api.get<{ items: Playlist[]; total: number }>("/admin/playlists").then((r) => r.data),
tracks: (params: { status?: string; q?: string } = {}) =>
api.get<{ items: Track[]; total: number }>("/admin/tracks", { params }).then((r) => r.data),
};

View File

@@ -0,0 +1,35 @@
export function formatDuration(ms: number | null | undefined): string {
if (!ms || ms <= 0) return "--:--";
const total = Math.floor(ms / 1000);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function formatTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function formatDate(value: string | null | undefined): string {
if (!value) return "";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "long", day: "numeric" });
}
export function initials(name: string | null | undefined): string {
if (!name) return "?";
return name
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export function cx(...parts: (string | false | null | undefined)[]): string {
return parts.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,16 @@
import type { Permissions, SelfUser } from "./types";
export function hasPerm(
user: Pick<SelfUser, "isSuperAdmin" | "permissions"> | null | undefined,
key: keyof Permissions,
): boolean {
if (!user) return false;
if (user.isSuperAdmin) return true;
return Boolean(user.permissions?.[key]);
}
export function canAccessAdmin(user: Pick<SelfUser, "isSuperAdmin" | "permissions">): boolean {
if (user.isSuperAdmin) return true;
const p = user.permissions;
return Boolean(p && (p.canModerate || p.canManageUsers || p.canManageLibrary || p.canViewAllPlaylists));
}

130
apps/web/src/lib/types.ts Normal file
View File

@@ -0,0 +1,130 @@
export interface Permissions {
canCreatePlaylists: boolean;
canUploadTracks: boolean;
canAutoApprove: boolean;
canModerate: boolean;
canManageUsers: boolean;
canManageLibrary: boolean;
canViewAllPlaylists: boolean;
}
export interface PublicUser {
id: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
bio: string | null;
createdAt: string;
}
export interface SelfUser extends PublicUser {
email: string;
isSuperAdmin: boolean;
permissions: Permissions;
}
export interface ArtistLite {
id: string;
name: string;
imageUrl: string | null;
}
export interface TrackArtistCredit {
id: string;
name: string;
imageUrl: string | null;
role: string;
}
export interface AlbumLite {
id: string;
title: string;
coverUrl: string | null;
}
export interface Track {
id: string;
title: string;
durationMs: number | null;
trackNumber: number | null;
discNumber: number | null;
releaseDate: string | null;
status: "pending" | "approved" | "rejected";
rejectionReason: string | null;
coverUrl: string | null;
playCount: number;
hasLyrics: boolean;
lyricsSource: string | null;
createdAt: string;
approvedAt: string | null;
artist: ArtistLite | null;
artists: TrackArtistCredit[];
album: AlbumLite | null;
uploadedBy?: PublicUser | null;
}
export interface Artist {
id: string;
name: string;
sortName: string | null;
description: string | null;
imageUrl: string | null;
country: string | null;
mbArtistId: string | null;
followersCount: number;
trackCount?: number;
}
export interface Album {
id: string;
title: string;
releaseDate: string | null;
coverUrl: string | null;
artist: ArtistLite | null;
}
export interface Playlist {
id: string;
title: string;
description: string | null;
coverUrl: string | null;
visibility: "public" | "unlisted" | "private";
isShareable: boolean;
allowCollaboratorEdit: boolean;
createdAt: string;
updatedAt: string;
owner: PublicUser | null;
trackCount: number;
canEdit: boolean;
isOwner: boolean;
}
export interface PlaylistEntry {
entryId: string;
position: number;
addedBy: PublicUser | null;
track: Track | null;
}
export interface Lyrics {
plain: string | null;
synced: { timeMs: number; text: string }[] | null;
source: string | null;
}
export interface Comment {
id: string;
trackId: string;
parentId: string | null;
body: string | null;
deleted: boolean;
createdAt: string;
updatedAt: string;
user: PublicUser | null;
replies: Comment[];
}
export interface UploadStatus {
pending: Track[];
rejected: Track[];
}

29
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import "./index.css";
import { AuthProvider } from "./store/auth";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 15_000,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,190 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { LogOut } from "lucide-react";
import { useRef, useState } from "react";
import { meApi } from "../lib/api";
import { hasPerm } from "../lib/permissions";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
export function Account() {
const { user, setUser, logout } = useAuth();
const qc = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null);
const [profile, setProfile] = useState({
displayName: user?.displayName ?? "",
bio: user?.bio ?? "",
});
const [pw, setPw] = useState({ current: "", next: "" });
const [msg, setMsg] = useState("");
const [err, setErr] = useState("");
const saveProfile = useMutation({
mutationFn: () => meApi.update({ displayName: profile.displayName, bio: profile.bio }),
onSuccess: (u) => {
setUser(u);
setMsg("Профиль сохранён");
setErr("");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
const changePw = useMutation({
mutationFn: () => meApi.changePassword(pw.current, pw.next),
onSuccess: () => {
setPw({ current: "", next: "" });
setMsg("Пароль изменён");
setErr("");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
const uploadAvatar = useMutation({
mutationFn: (file: File) => meApi.uploadAvatar(file),
onSuccess: (u) => {
setUser(u);
setMsg("Аватар обновлён");
},
onError: (e: any) => setErr(e?.response?.data?.error ?? "Ошибка"),
});
if (!user) return null;
const permList: { key: keyof typeof user.permissions; label: string }[] = [
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
{ key: "canUploadTracks", label: "Загрузка треков" },
{ key: "canAutoApprove", label: "Автопринятие без модерации" },
{ key: "canModerate", label: "Модерация" },
{ key: "canManageUsers", label: "Управление пользователями" },
{ key: "canManageLibrary", label: "Управление библиотекой" },
{ key: "canViewAllPlaylists", label: "Просмотр всех плейлистов" },
];
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<h1 className="mb-5 text-2xl font-bold tracking-tight text-white">Аккаунт</h1>
{(msg || err) && (
<div
className={
"mb-4 rounded-xl px-3 py-2 text-sm " +
(err
? "border border-red-400/20 bg-red-500/10 text-red-200"
: "border border-emerald-400/20 bg-emerald-500/10 text-emerald-200")
}
>
{err || msg}
</div>
)}
<div className="card mb-5 flex flex-col items-center gap-4 p-6 sm:flex-row sm:items-start">
<button className="relative" onClick={() => fileRef.current?.click()} title="Сменить аватар">
<Cover src={user.avatarUrl} name={user.displayName || user.username} rounded="rounded-full" className="h-24 w-24" />
<span className="absolute -bottom-1 right-0 chip bg-black/70">изменить</span>
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) uploadAvatar.mutate(f);
e.target.value = "";
}}
/>
<div className="flex-1 text-center sm:text-left">
<div className="text-xl font-semibold text-white">{user.displayName || user.username}</div>
<div className="text-sm text-zinc-500">@{user.username}</div>
<div className="mt-2 flex flex-wrap justify-center gap-1 sm:justify-start">
{user.isSuperAdmin && <span className="chip text-accent">супер-админ</span>}
{permList
.filter((p) => hasPerm(user, p.key))
.map((p) => (
<span key={p.key} className="chip">
{p.label}
</span>
))}
</div>
</div>
<button className="btn-danger" onClick={logout}>
<LogOut size={15} /> Выйти
</button>
</div>
<div className="grid gap-5 md:grid-cols-2">
<section className="card p-5">
<h2 className="section-title mb-4">Профиль</h2>
<div className="space-y-3">
<div>
<label className="label">Отображаемое имя</label>
<input
className="input"
value={profile.displayName}
onChange={(e) => setProfile({ ...profile, displayName: e.target.value })}
/>
</div>
<div>
<label className="label">О себе</label>
<textarea
className="input min-h-24"
value={profile.bio}
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
/>
</div>
<div>
<label className="label">Почта</label>
<input className="input opacity-60" value={user.email} disabled />
</div>
<button className="btn-primary" onClick={() => saveProfile.mutate()} disabled={saveProfile.isPending}>
Сохранить
</button>
</div>
</section>
<section className="card p-5">
<h2 className="section-title mb-4">Смена пароля</h2>
<div className="space-y-3">
<div>
<label className="label">Текущий пароль</label>
<input
className="input"
type="password"
value={pw.current}
onChange={(e) => setPw({ ...pw, current: e.target.value })}
/>
</div>
<div>
<label className="label">Новый пароль</label>
<input
className="input"
type="password"
value={pw.next}
onChange={(e) => setPw({ ...pw, next: e.target.value })}
/>
</div>
<button
className="btn-primary"
onClick={() => changePw.mutate()}
disabled={changePw.isPending || !pw.current || pw.next.length < 6}
>
Изменить пароль
</button>
<p className="text-xs text-zinc-600">
При смене пароля все другие сессии завершаются автоматически.
</p>
</div>
</section>
</div>
<div className="mt-5 card p-5">
<h2 className="section-title mb-3">Ссылки</h2>
<div className="flex flex-wrap gap-2">
<a className="btn-ghost" href={`/u/${user.username}`}>
Мой публичный профиль
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, CheckCircle2, Clock, RefreshCw, Trash2, UploadCloud } from "lucide-react";
import { useRef, useState } from "react";
import { Link } from "react-router-dom";
import { uploadsApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import { hasPerm } from "../lib/permissions";
import type { Track } from "../lib/types";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
export function AddTrack() {
const { user } = useAuth();
const qc = useQueryClient();
const inputRef = useRef<HTMLInputElement>(null);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState("");
const [duplicate, setDuplicate] = useState<{ id: string; title: string } | null>(null);
const [success, setSuccess] = useState("");
const canUpload = hasPerm(user, "canUploadTracks");
const autoApprove = hasPerm(user, "canAutoApprove");
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["uploads-mine"],
queryFn: uploadsApi.mine,
enabled: canUpload,
refetchInterval: 8000,
});
const upload = useMutation({
mutationFn: (files: File[]) => {
const uploadNext = (i: number): Promise<void> => {
if (i >= files.length) return Promise.resolve();
return uploadsApi
.upload(files[i], setProgress)
.then(() => {
setSuccess(`«${files[i].name}» загружен и отправлен на обработку`);
return uploadNext(i + 1);
});
};
return uploadNext(0);
},
onSuccess: () => {
setProgress(null);
void qc.invalidateQueries({ queryKey: ["uploads-mine"] });
},
onError: (err: any) => {
setProgress(null);
const details = err?.response?.data?.details;
if (details?.trackId) {
setDuplicate({ id: details.trackId, title: details.title });
setError("");
} else {
setDuplicate(null);
setError(err?.response?.data?.error ?? "Ошибка загрузки");
}
},
});
const remove = useMutation({
mutationFn: (id: string) => uploadsApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
});
const retry = useMutation({
mutationFn: (id: string) => uploadsApi.retry(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["uploads-mine"] }),
});
if (!canUpload) {
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<div className="card p-8 text-center">
<AlertTriangle className="mx-auto mb-3 text-amber-400" />
<h2 className="text-lg font-semibold text-white">Загрузка недоступна</h2>
<p className="mt-2 text-sm text-zinc-500">
У вашей учётной записи нет разрешения на загрузку треков. Обратитесь к администратору.
</p>
</div>
</div>
);
}
const pending = data?.pending ?? [];
const rejected = data?.rejected ?? [];
const handleFiles = (files: FileList | null) => {
if (!files?.length) return;
setError("");
setSuccess("");
setDuplicate(null);
upload.mutate(Array.from(files));
};
return (
<div className="mx-auto max-w-3xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Добавить музыку</h1>
<p className="mb-5 text-sm text-zinc-500">
Файл загрузится, определится дубликат, затем из интернета подтянутся название, исполнитель,
обложка, альбом и текст песни. После этого трек уйдёт на модерацию.
{autoApprove && " Вам разрешено автопринятие — треки попадут в библиотеку сразу."}
</p>
<div
className="card mb-6 grid cursor-pointer place-items-center border-dashed p-8 text-center transition hover:bg-white/[0.06]"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
}}
>
<UploadCloud className="mb-3 text-accent" size={32} />
<p className="text-sm text-zinc-300">Перетащите аудиофайлы сюда или нажмите для выбора</p>
<p className="mt-1 text-xs text-zinc-600">MP3, FLAC, WAV, OGG, M4A, AAC, OPUS · до 150 МБ</p>
<input
ref={inputRef}
type="file"
accept="audio/*"
multiple
className="hidden"
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
</div>
{progress !== null && (
<div className="mb-4">
<div className="mb-1 flex justify-between text-xs text-zinc-400">
<span>Загрузка...</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-gradient-to-r from-accent to-accent2 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{error && (
<div className="mb-4 rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{duplicate && (
<div className="mb-4 rounded-xl border border-amber-400/20 bg-amber-500/10 px-3 py-2 text-sm text-amber-100">
Дубликат: «{duplicate.title}» уже есть в библиотеке.{" "}
<Link to={`/track/${duplicate.id}`} className="underline hover:text-white">
Открыть трек →
</Link>
</div>
)}
{success && (
<div className="mb-4 rounded-xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
{success}
</div>
)}
<section className="mb-8">
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title flex items-center gap-2">
<Clock size={16} /> На обработке / модерации
</h2>
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw size={14} className={isFetching ? "animate-spin" : ""} /> Обновить
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : pending.length ? (
<div className="space-y-2">
{pending.map((t) => (
<StatusRow key={t.id} track={t} kind="pending" onRemove={() => remove.mutate(t.id)} />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Нет треков в обработке.</p>
)}
</section>
<section>
<h2 className="section-title mb-3 flex items-center gap-2 text-red-200">
<AlertTriangle size={16} /> Отклонённые
</h2>
{rejected.length ? (
<div className="space-y-2">
{rejected.map((t) => (
<StatusRow
key={t.id}
track={t}
kind="rejected"
onRemove={() => remove.mutate(t.id)}
onRetry={() => retry.mutate(t.id)}
/>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Отклонённых треков нет.</p>
)}
</section>
</div>
);
}
function StatusRow({
track,
kind,
onRemove,
onRetry,
}: {
track: Track;
kind: "pending" | "rejected";
onRemove: () => void;
onRetry?: () => void;
}) {
const processing = (track as any).status === "pending";
return (
<div className="card flex items-center gap-3 p-3">
<Cover src={track.coverUrl} name={track.title} className="h-12 w-12 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{track.title}</div>
<div className="truncate text-xs text-zinc-500">
{track.artist?.name ?? "Исполнитель определяется..."}
{track.durationMs ? ` · ${formatDuration(track.durationMs)}` : ""}
</div>
{kind === "rejected" && track.rejectionReason && (
<div className="mt-1 text-xs text-red-300">Причина: {track.rejectionReason}</div>
)}
</div>
<span
className={cx(
"chip hidden sm:inline-flex",
kind === "rejected" ? "text-red-200" : "text-amber-200",
)}
>
{kind === "rejected" ? (
<>
<AlertTriangle size={12} className="mr-1" /> Отклонён
</>
) : processing ? (
<>
<Clock size={12} className="mr-1" /> Обработка
</>
) : (
<>
<CheckCircle2 size={12} className="mr-1" /> На модерации
</>
)}
</span>
{onRetry && (
<button className="btn-ghost" onClick={onRetry} title="Повторить обработку">
<RefreshCw size={14} />
</button>
)}
<button className="btn-danger" onClick={onRemove} title="Удалить">
<Trash2 size={14} />
</button>
</div>
);
}

View File

@@ -0,0 +1,652 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BarChart3,
Check,
Library,
ListMusic,
Pencil,
Plus,
RefreshCw,
Shield,
Trash2,
Upload,
Users,
X,
} from "lucide-react";
import { useState, useRef } from "react";
import { Navigate, useNavigate, useParams } from "react-router-dom";
import { adminApi, playlistsApi, tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import { canAccessAdmin, hasPerm } from "../lib/permissions";
import type { SelfUser, Track } from "../lib/types";
import { useAuth } from "../store/auth";
import { Cover } from "../components/Cover";
import { Modal } from "../components/Modal";
import { TrackRow } from "../components/TrackRow";
type Section = "overview" | "moderation" | "users" | "playlists" | "library";
export function Admin() {
const { user } = useAuth();
const { section } = useParams();
const navigate = useNavigate();
if (!user || !canAccessAdmin(user)) return <Navigate to="/" replace />;
const tabs: { id: Section; label: string; icon: any; show: boolean }[] = [
{ id: "overview", label: "Обзор", icon: BarChart3, show: true },
{ id: "moderation", label: "Модерация", icon: Shield, show: hasPerm(user, "canModerate") },
{ id: "library", label: "Библиотека", icon: Library, show: hasPerm(user, "canManageLibrary") },
{ id: "users", label: "Пользователи", icon: Users, show: hasPerm(user, "canManageUsers") },
{ id: "playlists", label: "Плейлисты", icon: ListMusic, show: hasPerm(user, "canViewAllPlaylists") },
];
const visible = tabs.filter((t) => t.show);
const current = (visible.find((t) => t.id === section)?.id ?? "overview") as Section;
return (
<div className="mx-auto max-w-6xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Админ-панель</h1>
<p className="mb-5 text-sm text-zinc-500">
Управление модерацией, пользователями, разрешениями и всей библиотекой сервиса.
</p>
<div className="mb-6 flex flex-wrap gap-2">
{visible.map((t) => (
<button
key={t.id}
onClick={() => navigate(`/admin/${t.id}`)}
className={cx(
"flex items-center gap-2 rounded-full px-4 py-1.5 text-sm transition",
current === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
<t.icon size={15} /> {t.label}
</button>
))}
</div>
{current === "overview" && <Overview />}
{current === "moderation" && <Moderation />}
{current === "library" && <LibraryAdmin />}
{current === "users" && <UsersAdmin currentUser={user} />}
{current === "playlists" && <PlaylistsAdmin />}
</div>
);
}
function Overview() {
const { data } = useQuery({ queryKey: ["admin-stats"], queryFn: adminApi.stats });
const cards = [
{ label: "Всего треков", value: data?.tracks.total, sub: `${data?.tracks.approved ?? 0} одобрено` },
{ label: "На модерации", value: data?.tracks.pending, sub: "ожидают решения" },
{ label: "Отклонено", value: data?.tracks.rejected, sub: "не в библиотеке" },
{ label: "Пользователи", value: data?.users, sub: "аккаунтов" },
{ label: "Плейлисты", value: data?.playlists, sub: "всего" },
{ label: "Артисты", value: data?.artists, sub: "в базе" },
{ label: "Комментарии", value: data?.comments, sub: "всего" },
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{cards.map((c) => (
<div key={c.label} className="card p-4">
<div className="text-xs uppercase tracking-wide text-zinc-500">{c.label}</div>
<div className="mt-2 text-3xl font-bold text-white">{c.value ?? "—"}</div>
<div className="text-xs text-zinc-500">{c.sub}</div>
</div>
))}
</div>
);
}
function Moderation() {
const qc = useQueryClient();
const [editing, setEditing] = useState<Track | null>(null);
const { data, isLoading, refetch } = useQuery({ queryKey: ["admin-moderation"], queryFn: adminApi.moderation });
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["admin-moderation"] });
void qc.invalidateQueries({ queryKey: ["admin-stats"] });
};
const approve = useMutation({ mutationFn: (id: string) => adminApi.approve(id), onSuccess: invalidate });
const reject = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => adminApi.reject(id, reason),
onSuccess: invalidate,
});
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title">Очередь модерации ({data?.total ?? 0})</h2>
<button className="btn-ghost" onClick={() => refetch()}>
<RefreshCw size={14} /> Обновить
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : data?.items.length ? (
<div className="space-y-2">
{data.items.map((t) => (
<div key={t.id} className="card flex flex-wrap items-center gap-3 p-3">
<Cover src={t.coverUrl} name={t.title} className="h-14 w-14 shrink-0" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{t.title}</div>
<div className="truncate text-xs text-zinc-500">
{t.artist?.name ?? "—"}
{t.album ? ` · ${t.album.title}` : ""}
{t.durationMs ? ` · ${formatDuration(t.durationMs)}` : ""}
</div>
<div className="truncate text-xs text-zinc-600">
загрузил {t.uploadedBy?.username ?? "—"}
</div>
</div>
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => setEditing(t)}>
<Pencil size={14} /> Правки
</button>
<button className="btn-primary" onClick={() => approve.mutate(t.id)}>
<Check size={14} /> Принять
</button>
<button
className="btn-danger"
onClick={() => {
const reason = prompt("Причина отклонения:", "Не соответствует качеству") ?? "";
reject.mutate({ id: t.id, reason });
}}
>
<X size={14} /> Отклонить
</button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Очередь пуста.</p>
)}
<TrackEditModal track={editing} onClose={() => setEditing(null)} onSaved={invalidate} />
</div>
);
}
function LibraryAdmin() {
const qc = useQueryClient();
const [status, setStatus] = useState("approved");
const [q, setQ] = useState("");
const [editing, setEditing] = useState<Track | null>(null);
const { data, isLoading } = useQuery({
queryKey: ["admin-tracks", status, q],
queryFn: () => adminApi.tracks({ status: status || undefined, q }),
});
const remove = useMutation({
mutationFn: (id: string) => tracksApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-tracks"] }),
});
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
<select className="input max-w-40" value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Все статусы</option>
<option value="approved">Одобренные</option>
<option value="pending">На модерации</option>
<option value="rejected">Отклонённые</option>
</select>
<input
className="input max-w-xs"
placeholder="Название..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : (
<div className="card p-2">
{data?.items.map((t, i) => (
<TrackRow
key={t.id}
track={t}
index={i}
showUploader
right={
<div className="flex items-center">
<button className="p-1 text-zinc-500 hover:text-white" onClick={() => setEditing(t)} title="Правки">
<Pencil size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-red-300"
onClick={() => confirm("Удалить трек?") && remove.mutate(t.id)}
title="Удалить"
>
<Trash2 size={14} />
</button>
</div>
}
/>
))}
{data?.items.length === 0 && <p className="p-4 text-sm text-zinc-500">Нет треков.</p>}
</div>
)}
<TrackEditModal
track={editing}
onClose={() => setEditing(null)}
onSaved={() => void qc.invalidateQueries({ queryKey: ["admin-tracks"] })}
/>
</div>
);
}
function TrackEditModal({
track,
onClose,
onSaved,
}: {
track: Track | null;
onClose: () => void;
onSaved: () => void;
}) {
const [form, setForm] = useState({
title: track?.title ?? "",
releaseDate: track?.releaseDate ?? "",
coverUrl: track?.coverUrl ?? "",
trackNumber: track?.trackNumber ?? 0,
durationMs: track?.durationMs ?? 0,
artistsText: track
? track.artists?.length
? track.artists.map((a) => a.name).join(", ")
: track.artist?.name ?? ""
: "",
});
// reset when track changes
const [lastId, setLastId] = useState(track?.id);
if (track && track.id !== lastId) {
setLastId(track.id);
setForm({
title: track.title,
releaseDate: track.releaseDate ?? "",
coverUrl: track.coverUrl ?? "",
trackNumber: track.trackNumber ?? 0,
durationMs: track.durationMs ?? 0,
artistsText: track.artists?.length
? track.artists.map((a) => a.name).join(", ")
: track.artist?.name ?? "",
});
}
const save = useMutation({
mutationFn: () =>
tracksApi.update(track!.id, {
title: form.title,
releaseDate: form.releaseDate || null,
coverUrl: form.coverUrl || null,
trackNumber: Number(form.trackNumber) || null,
durationMs: Number(form.durationMs) || null,
artists: form.artistsText
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}),
onSuccess: () => {
onSaved();
onClose();
},
});
const coverInputRef = useRef<HTMLInputElement>(null);
const [applyToAlbum, setApplyToAlbum] = useState(false);
const uploadCover = useMutation({
mutationFn: (file: File) => tracksApi.uploadCover(track!.id, file, applyToAlbum),
onSuccess: (updated) => {
setForm((f) => ({ ...f, coverUrl: updated.coverUrl ?? "" }));
onSaved();
},
});
return (
<Modal open={Boolean(track)} onClose={onClose} title="Редактирование метаданных">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="label">Исполнители (через запятую)</label>
<input
className="input"
placeholder="Исполнитель 1, Исполнитель 2"
value={form.artistsText}
onChange={(e) => setForm({ ...form, artistsText: e.target.value })}
/>
<p className="mt-1 text-xs text-zinc-600">
Каждое имя станет отдельным артистом со своей страницей.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Дата выхода</label>
<input
className="input"
placeholder="YYYY-MM-DD"
value={form.releaseDate}
onChange={(e) => setForm({ ...form, releaseDate: e.target.value })}
/>
</div>
<div>
<label className="label">Номер трека</label>
<input
className="input"
type="number"
value={form.trackNumber}
onChange={(e) => setForm({ ...form, trackNumber: Number(e.target.value) })}
/>
</div>
</div>
<div>
<label className="label">Обложка</label>
<div className="flex items-center gap-3">
<Cover src={form.coverUrl} name={form.title} className="h-16 w-16 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<input
className="input"
placeholder="URL обложки"
value={form.coverUrl}
onChange={(e) => setForm({ ...form, coverUrl: e.target.value })}
/>
<div className="flex items-center gap-2">
<button className="btn-ghost" onClick={() => coverInputRef.current?.click()}>
<Upload size={14} /> Загрузить с устройства
</button>
<label className="flex items-center gap-1.5 text-xs text-zinc-400">
<input
type="checkbox"
checked={applyToAlbum}
onChange={(e) => setApplyToAlbum(e.target.checked)}
/>
на весь альбом
</label>
</div>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f && track) uploadCover.mutate(f);
e.target.value = "";
}}
/>
</div>
</div>
</div>
<div>
<label className="label">Длительность (мс)</label>
<input
className="input"
type="number"
value={form.durationMs}
onChange={(e) => setForm({ ...form, durationMs: Number(e.target.value) })}
/>
</div>
<button className="btn-primary w-full" onClick={() => save.mutate()}>
Сохранить
</button>
</div>
</Modal>
);
}
const PERMS: { key: keyof SelfUser["permissions"]; label: string }[] = [
{ key: "canCreatePlaylists", label: "Создание плейлистов" },
{ key: "canUploadTracks", label: "Загрузка треков" },
{ key: "canAutoApprove", label: "Автопринятие" },
{ key: "canModerate", label: "Модерация" },
{ key: "canManageUsers", label: "Пользователи" },
{ key: "canManageLibrary", label: "Библиотека" },
{ key: "canViewAllPlaylists", label: "Все плейлисты" },
];
function UsersAdmin({ currentUser }: { currentUser: SelfUser }) {
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const { data, isLoading } = useQuery({ queryKey: ["admin-users"], queryFn: adminApi.users });
const invalidate = () => void qc.invalidateQueries({ queryKey: ["admin-users"] });
const update = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => adminApi.updateUser(id, data),
onSuccess: invalidate,
});
const remove = useMutation({ mutationFn: (id: string) => adminApi.deleteUser(id), onSuccess: invalidate });
const resetPw = useMutation({
mutationFn: ({ id, password }: { id: string; password: string }) => adminApi.resetPassword(id, password),
});
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="section-title">Пользователи ({data?.length ?? 0})</h2>
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> Создать пользователя
</button>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : (
<div className="space-y-2">
{data?.map((u) => (
<div key={u.id} className="card p-3">
<div className="flex flex-wrap items-center gap-3">
<Cover src={u.avatarUrl} name={u.displayName || u.username} rounded="rounded-full" className="h-10 w-10" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
{u.displayName || u.username}
{u.isSuperAdmin && <span className="ml-2 chip text-accent">супер</span>}
{u.id === currentUser.id && <span className="ml-2 chip">это вы</span>}
</div>
<div className="truncate text-xs text-zinc-500">
@{u.username} · {u.email}
</div>
</div>
<button className="btn-ghost" onClick={() => setExpanded(expanded === u.id ? null : u.id)}>
<Shield size={14} /> Права
</button>
<button
className="btn-ghost"
onClick={() => {
const password = prompt("Новый пароль (мин. 6):");
if (password && password.length >= 6) resetPw.mutate({ id: u.id, password });
}}
>
Сбросить пароль
</button>
{u.id !== currentUser.id && (
<button
className="btn-danger"
onClick={() => confirm(`Удалить ${u.username}?`) && remove.mutate(u.id)}
>
<Trash2 size={14} />
</button>
)}
</div>
{expanded === u.id && (
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-white/10 pt-3 sm:grid-cols-4">
{PERMS.map((p) => {
const active = u.isSuperAdmin || u.permissions[p.key];
return (
<button
key={p.key}
disabled={u.isSuperAdmin}
onClick={() =>
update.mutate({ id: u.id, data: { permissions: { [p.key]: !active } } })
}
className={cx(
"rounded-xl border px-3 py-2 text-left text-xs transition",
active
? "border-accent/40 bg-accent/15 text-white"
: "border-white/10 bg-white/5 text-zinc-400 hover:bg-white/10",
u.isSuperAdmin && "opacity-60",
)}
>
{p.label}
</button>
);
})}
{currentUser.isSuperAdmin && (
<button
onClick={() => update.mutate({ id: u.id, data: { isSuperAdmin: !u.isSuperAdmin } })}
className="rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs text-zinc-300 hover:bg-white/10"
>
Супер-админ: {u.isSuperAdmin ? "да" : "нет"}
</button>
)}
</div>
)}
</div>
))}
</div>
)}
<CreateUserModal open={createOpen} onClose={() => setCreateOpen(false)} onCreated={invalidate} />
</div>
);
}
function CreateUserModal({
open,
onClose,
onCreated,
}: {
open: boolean;
onClose: () => void;
onCreated: () => void;
}) {
const [form, setForm] = useState({
email: "",
username: "",
password: "",
displayName: "",
});
const [perms, setPerms] = useState<Record<string, boolean>>({
canCreatePlaylists: true,
canUploadTracks: true,
});
const [error, setError] = useState("");
const create = useMutation({
mutationFn: () =>
adminApi.createUser({
...form,
permissions: perms as Partial<SelfUser["permissions"]>,
}),
onSuccess: () => {
onCreated();
onClose();
setForm({ email: "", username: "", password: "", displayName: "" });
},
onError: (e: any) => setError(e?.response?.data?.error ?? "Ошибка"),
});
return (
<Modal open={open} onClose={onClose} title="Создать пользователя">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Имя пользователя</label>
<input className="input" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} />
</div>
<div>
<label className="label">Отображаемое имя</label>
<input
className="input"
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
/>
</div>
</div>
<div>
<label className="label">Почта</label>
<input className="input" type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div>
<label className="label">Пароль</label>
<input
className="input"
type="text"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
<div>
<label className="label">Разрешения</label>
<div className="grid grid-cols-2 gap-2">
{PERMS.map((p) => (
<label key={p.key} className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={Boolean(perms[p.key])}
onChange={(e) => setPerms({ ...perms, [p.key]: e.target.checked })}
/>
{p.label}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-red-300">{error}</p>}
<button
className="btn-primary w-full"
onClick={() => create.mutate()}
disabled={!form.email || !form.username || form.password.length < 6}
>
Создать
</button>
</div>
</Modal>
);
}
function PlaylistsAdmin() {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["admin-playlists"], queryFn: adminApi.playlists });
const remove = useMutation({
mutationFn: (id: string) => playlistsApi.remove(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: ["admin-playlists"] }),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
return (
<div className="space-y-2">
{data?.items.map((p) => (
<div key={p.id} className="card flex flex-wrap items-center gap-3 p-3">
<Cover src={p.coverUrl} name={p.title} className="h-12 w-12 shrink-0" />
<div className="min-w-0 flex-1">
<a href={`/playlist/${p.id}`} className="truncate text-sm font-medium text-white hover:underline">
{p.title}
</a>
<div className="truncate text-xs text-zinc-500">
{p.owner?.username ?? "—"} · {p.trackCount} треков ·{" "}
{p.visibility === "public" ? "публичный" : p.visibility === "unlisted" ? "по ссылке" : "приватный"}
</div>
</div>
<button className="btn-ghost" onClick={() => (window.location.href = `/playlist/${p.id}`)}>
Открыть
</button>
<button
className="btn-danger"
onClick={() => confirm("Удалить плейлист?") && remove.mutate(p.id)}
>
<Trash2 size={14} />
</button>
</div>
))}
{data?.items.length === 0 && <p className="text-sm text-zinc-500">Плейлистов нет.</p>}
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useQuery } from "@tanstack/react-query";
import { Play } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { albumsApi } from "../lib/api";
import { formatDate } from "../lib/format";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function AlbumPage() {
const { id = "" } = useParams();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({
queryKey: ["album", id],
queryFn: () => albumsApi.get(id),
enabled: Boolean(id),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Альбом не найден.</p>;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
<Cover src={data.album.coverUrl} name={data.album.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
<div className="min-w-0 flex-1">
<span className="chip">Альбом</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{data.album.title}</h1>
{data.album.artist && (
<Link to={`/artist/${data.album.artist.id}`} className="link mt-1 block text-sm">
{data.album.artist.name}
</Link>
)}
<p className="mt-1 text-sm text-zinc-500">
{formatDate(data.album.releaseDate)} · {data.tracks.length} треков
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playQueue(data.tracks, 0)} disabled={!data.tracks.length}>
<Play size={15} /> Играть
</button>
<ShareButton path={`/album/${id}`} />
</div>
</div>
</div>
{data.tracks.length ? (
<div className="card p-2">
{data.tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={data.tracks} index={i} showCover={false} showAlbum={false} showUploader />
))}
</div>
) : (
<p className="text-sm text-zinc-500">В альбоме пока нет треков.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Play, UserCheck, UserPlus } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { artistsApi } from "../lib/api";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function ArtistPage() {
const { id = "" } = useParams();
const qc = useQueryClient();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({
queryKey: ["artist", id],
queryFn: () => artistsApi.get(id),
enabled: Boolean(id),
});
const { data: tracks = [] } = useQuery({
queryKey: ["artist-tracks", id],
queryFn: () => artistsApi.tracks(id),
enabled: Boolean(id),
});
const follow = useMutation({
mutationFn: () => (data?.following ? artistsApi.unfollow(id) : artistsApi.follow(id)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["artist", id] });
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Артист не найден.</p>;
const { artist } = data;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
<Cover src={artist.imageUrl} name={artist.name} rounded="rounded-full" className="h-36 w-36 shrink-0" />
<div className="min-w-0 flex-1">
<span className="chip">Артист</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{artist.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{artist.followersCount} подписчиков
{artist.trackCount !== undefined && ` · ${artist.trackCount} треков`}
{artist.country && ` · ${artist.country}`}
</p>
{artist.description && (
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-zinc-400">{artist.description}</p>
)}
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
<Play size={15} /> Слушать
</button>
<button className="btn-ghost" onClick={() => follow.mutate()}>
{data.following ? <UserCheck size={15} /> : <UserPlus size={15} />}
{data.following ? "Вы подписаны" : "Подписаться"}
</button>
<ShareButton path={`/artist/${id}`} />
</div>
</div>
</div>
{data.albums.length > 0 && (
<section className="mb-8">
<h2 className="section-title mb-3">Альбомы</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data.albums.map((al) => (
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
<div className="truncate text-xs text-zinc-500">{al.releaseDate?.slice(0, 4)}</div>
</Link>
))}
</div>
</section>
)}
<section>
<h2 className="section-title mb-3">Треки</h2>
{tracks.length ? (
<div className="card p-2">
{tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={tracks} index={i} showCover={false} showUploader />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Треков пока нет.</p>
)}
</section>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useQuery } from "@tanstack/react-query";
import { Play, RefreshCw } from "lucide-react";
import { tracksApi } from "../lib/api";
import { useAuth } from "../store/auth";
import { usePlayer } from "../store/player";
import { TrackRow } from "../components/TrackRow";
export function Home() {
const { user } = useAuth();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ["random-tracks"],
queryFn: () => tracksApi.random(25),
});
const items = data ?? [];
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">
Привет{user?.displayName ? `, ${user.displayName}` : ""} 👋
</h1>
<p className="mt-1 text-sm text-zinc-500">
Случайные рекомендации из общей библиотеки. Персональные рекомендации появятся позже.
</p>
</div>
<div className="flex gap-2">
<button className="btn-ghost" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw size={15} className={isFetching ? "animate-spin" : ""} />
Обновить
</button>
<button
className="btn-primary"
onClick={() => items.length && playQueue(items, 0)}
disabled={!items.length}
>
<Play size={15} /> Играть всё
</button>
</div>
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка рекомендаций...</p>
) : items.length === 0 ? (
<div className="card p-8 text-center text-sm text-zinc-500">
В библиотеке пока нет одобренных треков. Загрузите музыку на вкладке «Добавить».
</div>
) : (
<div className="card p-2">
{items.map((track, i) => (
<TrackRow key={track.id} track={track} list={items} index={i} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Play, Plus } from "lucide-react";
import { libraryApi } from "../lib/api";
import { cx } from "../lib/format";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { CreatePlaylistModal } from "../components/CreatePlaylistModal";
import { PlaylistCard } from "../components/PlaylistCard";
import { TrackRow } from "../components/TrackRow";
type Tab = "playlists" | "artists" | "tracks";
export function Library() {
const [tab, setTab] = useState<Tab>("playlists");
const [createOpen, setCreateOpen] = useState(false);
const navigate = useNavigate();
const playQueue = usePlayer((s) => s.playQueue);
const { data, isLoading } = useQuery({ queryKey: ["library"], queryFn: libraryApi.get });
const tabs: { id: Tab; label: string }[] = [
{ id: "playlists", label: "Плейлисты" },
{ id: "artists", label: "Артисты" },
{ id: "tracks", label: "Треки" },
];
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">Библиотека</h1>
<p className="mb-5 text-sm text-zinc-500">Ваши плейлисты, подписки и избранные треки.</p>
<div className="mb-5 flex flex-wrap items-center gap-2">
<button className="btn-primary" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> Создать плейлист
</button>
{tabs.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cx(
"rounded-full px-4 py-1.5 text-sm transition",
tab === t.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
{t.label}
</button>
))}
</div>
{isLoading ? (
<p className="text-sm text-zinc-500">Загрузка...</p>
) : tab === "playlists" ? (
<div className="space-y-8">
<section>
<h2 className="section-title mb-3">Мои плейлисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button
onClick={() => setCreateOpen(true)}
className="card grid aspect-auto place-items-center border-dashed p-3 text-zinc-400 transition hover:bg-white/[0.07] hover:text-white"
>
<div className="flex flex-col items-center gap-2 py-6">
<Plus size={26} />
<span className="text-sm">Создать плейлист</span>
</div>
</button>
{data?.ownedPlaylists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
{!isLoading && !data?.ownedPlaylists.length && (
<p className="mt-3 text-sm text-zinc-500">Пока нет своих плейлистов.</p>
)}
</section>
<section>
<h2 className="section-title mb-3">Сохранённые плейлисты</h2>
{data?.savedPlaylists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.savedPlaylists.map((s) => (
<div key={s.originalPlaylistId} className="relative">
<PlaylistCard playlist={s.playlist} />
<span className="absolute right-2 top-2 chip">
{s.mode === "copy" ? "копия" : "ссылка"}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Вы ещё не добавляли чужие плейлисты.</p>
)}
</section>
</div>
) : tab === "artists" ? (
data?.followedArtists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.followedArtists.map((a) => (
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-24" />
<div className="mt-3 truncate text-center text-sm font-medium text-white">{a.name}</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-zinc-500">Вы пока ни на кого не подписаны.</p>
)
) : data?.likedTracks.length ? (
<div>
<div className="mb-3">
<button className="btn-primary" onClick={() => playQueue(data.likedTracks, 0)}>
<Play size={15} /> Играть всё
</button>
</div>
<div className="card p-2">
{data.likedTracks.map((t, i) => (
<TrackRow
key={t.id}
track={t}
list={data.likedTracks}
index={i}
initialLiked
showUploader
/>
))}
</div>
</div>
) : (
<p className="text-sm text-zinc-500">Нет избранных треков.</p>
)}
<CreatePlaylistModal
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreated={(id) => navigate(`/playlist/${id}`)}
/>
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import { useAuth } from "../store/auth";
export function Login() {
const { login } = useAuth();
const [form, setForm] = useState({ login: "", password: "" });
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(form.login, form.password);
} catch (err: any) {
setError(err?.response?.data?.error ?? "Не удалось войти");
} finally {
setLoading(false);
}
};
return (
<div className="grid min-h-full place-items-center p-4">
<div className="w-full max-w-md animate-fade-in">
<div className="mb-8 text-center">
<div className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-accent to-accent2 text-lg font-black text-black">
AR
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">ADC Retivo</h1>
<p className="mt-1 text-sm text-zinc-500">
Самохостинговая музыка. Вход доступен только по аккаунту, созданному администратором.
</p>
</div>
<form onSubmit={submit} className="card space-y-4 p-6">
<div>
<label className="label">Почта или имя пользователя</label>
<input
className="input"
value={form.login}
onChange={(e) => setForm({ ...form, login: e.target.value })}
autoComplete="username"
required
/>
</div>
<div>
<label className="label">Пароль</label>
<input
className="input"
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
autoComplete="current-password"
required
/>
</div>
{error && (
<div className="rounded-xl border border-red-400/20 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
<button className="btn-primary w-full" disabled={loading}>
{loading ? "Входим..." : "Войти"}
</button>
</form>
<p className="mt-6 text-center text-xs text-zinc-600">
Регистрация новых пользователей открыта только из админ-панели.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, Pencil, Play, Plus, Search, Shuffle, Trash2, X } from "lucide-react";
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { playlistsApi, tracksApi } from "../lib/api";
import { cx, formatDuration } from "../lib/format";
import type { Playlist, Track } from "../lib/types";
import { usePlayer } from "../store/player";
import { Cover } from "../components/Cover";
import { Modal } from "../components/Modal";
import { ShareButton } from "../components/ShareButton";
import { TrackRow } from "../components/TrackRow";
export function PlaylistPage() {
const { id = "" } = useParams();
const qc = useQueryClient();
const playQueue = usePlayer((s) => s.playQueue);
const [editOpen, setEditOpen] = useState(false);
const [libOpen, setLibOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["playlist", id],
queryFn: () => playlistsApi.get(id),
enabled: Boolean(id),
});
const playlist = data?.playlist;
const entries = data?.entries ?? [];
const tracks = entries.map((e) => e.track).filter(Boolean) as Track[];
const totalMs = tracks.reduce((sum, t) => sum + (t.durationMs ?? 0), 0);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["playlist", id] });
void qc.invalidateQueries({ queryKey: ["library"] });
void qc.invalidateQueries({ queryKey: ["my-playlists"] });
};
const removeEntry = useMutation({
mutationFn: (entryId: string) => playlistsApi.removeTrack(id, entryId),
onSuccess: invalidate,
});
const removePlaylist = useMutation({
mutationFn: () => playlistsApi.remove(id),
onSuccess: () => {
invalidate();
history.back();
},
});
const addToLibrary = useMutation({
mutationFn: (mode: "reference" | "copy") => playlistsApi.addToLibrary(id, mode),
onSuccess: () => {
setLibOpen(false);
invalidate();
},
});
const removeFromLibrary = useMutation({
mutationFn: () => playlistsApi.removeFromLibrary(id),
onSuccess: invalidate,
});
const reorder = useMutation({
mutationFn: (entryIds: string[]) => playlistsApi.reorder(id, entryIds),
onSuccess: invalidate,
});
const move = (from: number, to: number) => {
if (to < 0 || to >= entries.length) return;
const ids = entries.map((e) => e.entryId);
const [moved] = ids.splice(from, 1);
ids.splice(to, 0, moved);
reorder.mutate(ids);
};
const shufflePlay = () => {
if (!tracks.length) return;
const shuffled = [...tracks].sort(() => Math.random() - 0.5);
playQueue(shuffled, 0);
};
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка плейлиста...</p>;
if (!playlist)
return <p className="text-sm text-zinc-500">Плейлист не найден или недоступен.</p>;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
<Cover src={playlist.coverUrl} name={playlist.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<span className="chip">Плейлист</span>
<span className="chip">
{playlist.visibility === "public"
? "публичный"
: playlist.visibility === "unlisted"
? "по ссылке"
: "приватный"}
</span>
{playlist.isShareable && <span className="chip">можно делиться</span>}
{playlist.allowCollaboratorEdit && <span className="chip">можно редактировать из библиотеки</span>}
</div>
<h1 className="truncate text-3xl font-bold tracking-tight text-white">{playlist.title}</h1>
{playlist.description && <p className="mt-1 text-sm text-zinc-400">{playlist.description}</p>}
<p className="mt-2 text-sm text-zinc-500">
{playlist.owner && (
<>
Автор:{" "}
<Link to={`/u/${playlist.owner.username}`} className="link">
{playlist.owner.displayName || playlist.owner.username}
</Link>
{" · "}
</>
)}
{tracks.length} треков · {formatDuration(totalMs)}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playQueue(tracks, 0)} disabled={!tracks.length}>
<Play size={15} /> Играть
</button>
<button className="btn-ghost" onClick={shufflePlay} disabled={!tracks.length}>
<Shuffle size={15} /> Перемешать
</button>
<ShareButton path={`/playlist/${id}`} />
{playlist.canEdit && (
<>
<button className="btn-ghost" onClick={() => setEditOpen(true)}>
<Pencil size={15} /> Редактировать
</button>
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
<Plus size={15} /> Добавить треки
</button>
</>
)}
{data?.inLibrary ? (
<button className="btn-ghost" onClick={() => removeFromLibrary.mutate()}>
<X size={15} /> Убрать из библиотеки
</button>
) : (
<button className="btn-ghost" onClick={() => setLibOpen(true)}>
<Plus size={15} /> В библиотеку
</button>
)}
{playlist.isOwner && (
<button className="btn-danger" onClick={() => confirm("Удалить плейлист?") && removePlaylist.mutate()}>
<Trash2 size={15} /> Удалить
</button>
)}
</div>
</div>
</div>
{tracks.length === 0 ? (
<div className="card p-8 text-center text-sm text-zinc-500">
В плейлисте пока нет треков.
</div>
) : (
<div className="card p-2">
{entries.map((entry, i) =>
entry.track ? (
<TrackRow
key={entry.entryId}
track={entry.track}
list={tracks}
index={i}
showUploader
right={
playlist.canEdit ? (
<div className="flex items-center">
<button
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
disabled={i === 0}
onClick={() => move(i, i - 1)}
title="Выше"
>
<ChevronUp size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-white disabled:opacity-30"
disabled={i === entries.length - 1}
onClick={() => move(i, i + 1)}
title="Ниже"
>
<ChevronDown size={14} />
</button>
<button
className="p-1 text-zinc-500 hover:text-red-300"
onClick={() => removeEntry.mutate(entry.entryId)}
title="Убрать"
>
<Trash2 size={14} />
</button>
</div>
) : undefined
}
/>
) : null,
)}
</div>
)}
{playlist && (
<EditPlaylistModal
open={editOpen}
onClose={() => setEditOpen(false)}
playlist={playlist}
onSaved={invalidate}
/>
)}
<AddTracksModal open={addOpen} onClose={() => setAddOpen(false)} playlistId={id} onChanged={invalidate} />
<Modal open={libOpen} onClose={() => setLibOpen(false)} title="Добавить плейлист в библиотеку">
<div className="space-y-3 text-sm">
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("reference")}>
<div className="font-medium text-white">Ссылка на оригинал</div>
<p className="mt-1 text-zinc-500">
Плейлист останется общим. Изменения автора будут видны вам. Редактирование — если автор
разрешил.
</p>
</button>
<button className="card w-full p-4 text-left hover:bg-white/[0.07]" onClick={() => addToLibrary.mutate("copy")}>
<div className="font-medium text-white">Сохранить копию</div>
<p className="mt-1 text-zinc-500">
Создастся личная редактируемая копия, независимая от оригинала.
</p>
</button>
</div>
</Modal>
</div>
);
}
function EditPlaylistModal({
open,
onClose,
playlist,
onSaved,
}: {
open: boolean;
onClose: () => void;
playlist: Playlist;
onSaved: () => void;
}) {
const [form, setForm] = useState({
title: playlist.title,
description: playlist.description ?? "",
visibility: playlist.visibility,
isShareable: playlist.isShareable,
allowCollaboratorEdit: playlist.allowCollaboratorEdit,
});
const save = useMutation({
mutationFn: () => playlistsApi.update(playlist.id, form),
onSuccess: () => {
onSaved();
onClose();
},
});
return (
<Modal open={open} onClose={onClose} title="Редактировать плейлист">
<div className="space-y-3">
<div>
<label className="label">Название</label>
<input className="input" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<label className="label">Описание</label>
<textarea
className="input min-h-20"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div>
<label className="label">Доступ</label>
<select
className="input"
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as Playlist["visibility"] })}
>
<option value="private">Приватный</option>
<option value="unlisted">По ссылке</option>
<option value="public">Публичный</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.isShareable}
onChange={(e) => setForm({ ...form, isShareable: e.target.checked })}
/>
Разрешить делиться ссылкой
</label>
<label className="flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={form.allowCollaboratorEdit}
onChange={(e) => setForm({ ...form, allowCollaboratorEdit: e.target.checked })}
/>
Разрешить редактирование тем, кто добавил плейлист в библиотеку
</label>
<button className="btn-primary w-full" onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</button>
</div>
</Modal>
);
}
function AddTracksModal({
open,
onClose,
playlistId,
onChanged,
}: {
open: boolean;
onClose: () => void;
playlistId: string;
onChanged: () => void;
}) {
const [q, setQ] = useState("");
const { data } = useQuery({
queryKey: ["track-search", q],
queryFn: () => tracksApi.list({ q, limit: 20 }),
enabled: open && q.length > 1,
});
const add = useMutation({
mutationFn: (trackId: string) => playlistsApi.addTrack(playlistId, trackId),
onSuccess: onChanged,
});
return (
<Modal open={open} onClose={onClose} title="Добавить треки" wide>
<div className="relative mb-3">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input className="input pl-9" placeholder="Поиск по библиотеке..." value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="max-h-80 space-y-1 overflow-y-auto">
{data?.items.map((t) => (
<button
key={t.id}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left hover:bg-white/5"
onClick={() => add.mutate(t.id)}
>
<Cover src={t.coverUrl} name={t.title} className="h-9 w-9" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-white">{t.title}</div>
<div className="truncate text-xs text-zinc-500">{t.artist?.name}</div>
</div>
<Plus size={15} className="text-zinc-500" />
</button>
))}
{q.length > 1 && data?.items.length === 0 && (
<p className="px-2 text-sm text-zinc-500">Ничего не найдено.</p>
)}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,140 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { useState } from "react";
import { searchApi } from "../lib/api";
import { cx } from "../lib/format";
import { Cover } from "../components/Cover";
import { TrackRow } from "../components/TrackRow";
const filters = [
{ id: "all", label: "Всё" },
{ id: "tracks", label: "Треки" },
{ id: "artists", label: "Артисты" },
{ id: "albums", label: "Альбомы" },
{ id: "playlists", label: "Плейлисты" },
{ id: "users", label: "Люди" },
];
export function SearchPage() {
const [params] = useSearchParams();
const q = params.get("q") ?? "";
const [type, setType] = useState("all");
const { data, isLoading } = useQuery({
queryKey: ["search", q, type],
queryFn: () => searchApi.query(q, type),
enabled: q.length > 0,
});
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<h1 className="mb-1 text-2xl font-bold tracking-tight text-white">
Поиск{q && `: «${q}»`}
</h1>
<p className="mb-4 text-sm text-zinc-500">Треки, артисты, альбомы, публичные плейлисты и люди.</p>
<div className="mb-5 flex flex-wrap gap-2">
{filters.map((f) => (
<button
key={f.id}
onClick={() => setType(f.id)}
className={cx(
"rounded-full px-3.5 py-1.5 text-sm transition",
type === f.id ? "bg-white text-black" : "bg-white/5 text-zinc-300 hover:bg-white/10",
)}
>
{f.label}
</button>
))}
</div>
{!q ? (
<p className="text-sm text-zinc-500">Введите запрос в строке поиска сверху.</p>
) : isLoading ? (
<p className="text-sm text-zinc-500">Поиск...</p>
) : (
<div className="space-y-8">
{data!.tracks.length > 0 && (
<section>
<h2 className="section-title mb-3">Треки</h2>
<div className="card p-2">
{data!.tracks.map((t, i) => (
<TrackRow key={t.id} track={t} list={data!.tracks} index={i} showUploader />
))}
</div>
</section>
)}
{data!.artists.length > 0 && (
<section>
<h2 className="section-title mb-3">Артисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.artists.map((a) => (
<Link key={a.id} to={`/artist/${a.id}`} className="card p-3 text-center hover:bg-white/[0.07]">
<Cover src={a.imageUrl} name={a.name} rounded="rounded-full" className="mx-auto aspect-square w-20" />
<div className="mt-2 truncate text-sm text-white">{a.name}</div>
</Link>
))}
</div>
</section>
)}
{data!.albums.length > 0 && (
<section>
<h2 className="section-title mb-3">Альбомы</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.albums.map((al) => (
<Link key={al.id} to={`/album/${al.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={al.coverUrl} name={al.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{al.title}</div>
<div className="truncate text-xs text-zinc-500">{al.artist?.name}</div>
</Link>
))}
</div>
</section>
)}
{data!.playlists.length > 0 && (
<section>
<h2 className="section-title mb-3">Плейлисты</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.playlists.map((p) => (
<Link key={p.id} to={`/playlist/${p.id}`} className="card p-3 hover:bg-white/[0.07]">
<Cover src={p.coverUrl} name={p.title} className="aspect-square w-full" />
<div className="mt-2 truncate text-sm text-white">{p.title}</div>
<div className="truncate text-xs text-zinc-500">
{p.owner?.username} · {p.trackCount} треков
</div>
</Link>
))}
</div>
</section>
)}
{data!.users.length > 0 && (
<section>
<h2 className="section-title mb-3">Люди</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{data!.users.map((u) => (
<Link key={u.id} to={`/u/${u.username}`} className="card p-3 text-center hover:bg-white/[0.07]">
<Cover
src={u.avatarUrl}
name={u.displayName || u.username}
rounded="rounded-full"
className="mx-auto aspect-square w-20"
/>
<div className="mt-2 truncate text-sm text-white">{u.displayName || u.username}</div>
<div className="truncate text-xs text-zinc-500">@{u.username}</div>
</Link>
))}
</div>
</section>
)}
{data!.tracks.length + data!.artists.length + data!.albums.length + data!.playlists.length + data!.users.length ===
0 && <p className="text-sm text-zinc-500">Ничего не найдено.</p>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Heart, Play, Plus } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { tracksApi } from "../lib/api";
import { formatDate, formatDuration } from "../lib/format";
import { usePlayer } from "../store/player";
import { AddToPlaylistModal } from "../components/AddToPlaylistModal";
import { ArtistLinks } from "../components/ArtistLinks";
import { Comments } from "../components/Comments";
import { Cover } from "../components/Cover";
import { LyricsView } from "../components/LyricsView";
import { ShareButton } from "../components/ShareButton";
export function TrackPage() {
const { id = "" } = useParams();
const playNow = usePlayer((s) => s.playNow);
const addToQueue = usePlayer((s) => s.addToQueue);
const qc = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["track", id],
queryFn: () => tracksApi.get(id),
enabled: Boolean(id),
});
const like = useMutation({
mutationFn: () => (data?.liked ? tracksApi.unlike(id) : tracksApi.like(id)),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["track", id] });
void qc.invalidateQueries({ queryKey: ["library"] });
},
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Трек не найден.</p>;
const { track } = data;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-end">
<Cover src={track.coverUrl} name={track.title} className="h-40 w-40 shrink-0" rounded="rounded-2xl" />
<div className="min-w-0 flex-1">
<span className="chip">Трек</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">{track.title}</h1>
<p className="mt-1 text-sm text-zinc-400">
<ArtistLinks artists={track.artists} fallback={track.artist?.name} linkClassName="link" />
{track.album && (
<>
{" · "}
<Link to={`/album/${track.album.id}`} className="link">
{track.album.title}
</Link>
</>
)}
</p>
<p className="mt-1 text-sm text-zinc-500">
{formatDuration(track.durationMs)}
{track.releaseDate && ` · ${formatDate(track.releaseDate)}`}
{` · ${track.playCount} прослушиваний`}
</p>
<p className="mt-2 text-xs text-zinc-500">
Добавил в библиотеку:{" "}
{track.uploadedBy ? (
<Link to={`/u/${track.uploadedBy.username}`} className="link">
{track.uploadedBy.displayName || track.uploadedBy.username}
</Link>
) : (
"неизвестно"
)}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button className="btn-primary" onClick={() => playNow(track, [track])}>
<Play size={15} /> Играть
</button>
<button className="btn-ghost" onClick={() => addToQueue(track)}>
<Plus size={15} /> В очередь
</button>
<button className={data.liked ? "btn-danger" : "btn-ghost"} onClick={() => like.mutate()}>
<Heart size={15} fill={data.liked ? "currentColor" : "none"} />
{data.liked ? "В избранном" : "В избранное"}
</button>
<button className="btn-ghost" onClick={() => setAddOpen(true)}>
В плейлист
</button>
<a className="btn-ghost" href={`/api/tracks/${id}/stream?download=1`}>
Скачать
</a>
<ShareButton path={`/track/${id}`} />
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<section className="card max-h-[60vh] overflow-hidden p-2">
<h2 className="section-title px-3 pb-2 pt-3">Текст песни</h2>
<LyricsView trackId={id} />
</section>
<Comments trackId={id} />
</div>
<AddToPlaylistModal track={track} open={addOpen} onClose={() => setAddOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
import { usersApi } from "../lib/api";
import { Cover } from "../components/Cover";
import { PlaylistCard } from "../components/PlaylistCard";
import { ShareButton } from "../components/ShareButton";
export function UserPage() {
const { username = "" } = useParams();
const { data, isLoading } = useQuery({
queryKey: ["user", username],
queryFn: () => usersApi.get(username),
enabled: Boolean(username),
});
if (isLoading) return <p className="text-sm text-zinc-500">Загрузка...</p>;
if (!data) return <p className="text-sm text-zinc-500">Пользователь не найден.</p>;
return (
<div className="mx-auto max-w-5xl animate-fade-in">
<div className="card mb-6 flex flex-col items-center gap-5 p-6 text-center sm:flex-row sm:items-end sm:text-left">
<Cover
src={data.user.avatarUrl}
name={data.user.displayName || data.user.username}
rounded="rounded-full"
className="h-32 w-32 shrink-0"
/>
<div className="min-w-0 flex-1">
<span className="chip">Профиль</span>
<h1 className="mt-2 truncate text-3xl font-bold tracking-tight text-white">
{data.user.displayName || data.user.username}
</h1>
<p className="text-sm text-zinc-500">@{data.user.username}</p>
{data.user.bio && <p className="mt-2 max-w-2xl text-sm text-zinc-400">{data.user.bio}</p>}
<p className="mt-1 text-xs text-zinc-600">Загружено треков: {data.trackCount}</p>
<div className="mt-4 flex flex-wrap justify-center gap-2 sm:justify-start">
<ShareButton path={`/u/${data.user.username}`} />
</div>
</div>
</div>
<section>
<h2 className="section-title mb-3">Публичные плейлисты</h2>
{data.playlists.length ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{data.playlists.map((p) => (
<PlaylistCard key={p.id} playlist={p} />
))}
</div>
) : (
<p className="text-sm text-zinc-500">Публичных плейлистов нет.</p>
)}
</section>
<p className="mt-6 text-xs text-zinc-600">
<Link to="/" className="link">
← На главную
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { authApi } from "../lib/api";
import type { SelfUser } from "../lib/types";
interface AuthContextValue {
user: SelfUser | null;
loading: boolean;
setUser: (user: SelfUser | null) => void;
login: (login: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<SelfUser | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const u = await authApi.session();
setUser(u);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const login = useCallback(async (loginValue: string, password: string) => {
const u = await authApi.login(loginValue, password);
setUser(u);
}, []);
const logout = useCallback(async () => {
await authApi.logout();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, loading, setUser, login, logout, refresh }),
[user, loading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,170 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Track } from "../lib/types";
export type RepeatMode = "off" | "all" | "one";
interface PlayerState {
queue: Track[];
index: number;
isPlaying: boolean;
shuffle: boolean;
repeat: RepeatMode;
expanded: boolean;
showLyrics: boolean;
currentTime: number;
duration: number;
volume: number;
playNow: (track: Track, list?: Track[]) => void;
playQueue: (tracks: Track[], startIndex?: number) => void;
setQueue: (tracks: Track[]) => void;
toggle: () => void;
next: () => void;
prev: () => void;
goTo: (index: number) => void;
seek: (time: number) => void;
addToQueue: (track: Track) => void;
playNext: (track: Track) => void;
removeAt: (index: number) => void;
move: (from: number, to: number) => void;
clearQueue: () => void;
toggleShuffle: () => void;
cycleRepeat: () => void;
setExpanded: (v: boolean) => void;
setShowLyrics: (v: boolean) => void;
setPlaying: (v: boolean) => void;
setCurrentTime: (t: number) => void;
setDuration: (d: number) => void;
setVolume: (v: number) => void;
}
export const usePlayer = create<PlayerState>()(
persist(
(set, get) => ({
queue: [],
index: 0,
isPlaying: false,
shuffle: false,
repeat: "off",
expanded: false,
showLyrics: false,
currentTime: 0,
duration: 0,
volume: 1,
playNow: (track, list) =>
set(() => {
const queue = list && list.length ? list : [track];
const index = Math.max(
0,
queue.findIndex((t) => t.id === track.id),
);
return { queue, index, isPlaying: true, currentTime: 0, duration: 0 };
}),
playQueue: (tracks, startIndex = 0) =>
set(() => ({ queue: tracks, index: startIndex, isPlaying: true, currentTime: 0, duration: 0 })),
setQueue: (tracks) => set({ queue: tracks }),
toggle: () => set((s) => (s.queue.length ? { isPlaying: !s.isPlaying } : {})),
next: () => {
const { queue, index, shuffle, repeat } = get();
if (!queue.length) return;
if (repeat === "one") {
set({ currentTime: 0, isPlaying: true });
return;
}
let nextIndex: number;
if (shuffle) {
nextIndex = Math.floor(Math.random() * queue.length);
if (queue.length > 1) {
while (nextIndex === index) nextIndex = Math.floor(Math.random() * queue.length);
}
} else {
nextIndex = index + 1;
}
if (nextIndex >= queue.length) {
if (repeat === "all") nextIndex = 0;
else {
set({ isPlaying: false, index: queue.length - 1 });
return;
}
}
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
},
prev: () => {
const { index, currentTime } = get();
if (currentTime > 3 && index >= 0) {
set({ currentTime: 0 });
return;
}
const nextIndex = index > 0 ? index - 1 : 0;
set({ index: nextIndex, currentTime: 0, duration: 0, isPlaying: true });
},
goTo: (index) => set({ index, isPlaying: true, currentTime: 0, duration: 0 }),
seek: (time) => set({ currentTime: time }),
addToQueue: (track) => set((s) => ({ queue: [...s.queue, track] })),
playNext: (track) =>
set((s) => {
const queue = [...s.queue];
queue.splice(s.index + 1, 0, track);
return { queue };
}),
removeAt: (index) =>
set((s) => {
const queue = s.queue.filter((_, i) => i !== index);
let newIndex = s.index;
if (index < s.index) newIndex = s.index - 1;
if (newIndex >= queue.length) newIndex = Math.max(0, queue.length - 1);
return { queue, index: newIndex };
}),
move: (from, to) =>
set((s) => {
const queue = [...s.queue];
const [item] = queue.splice(from, 1);
queue.splice(to, 0, item);
let newIndex = s.index;
if (s.index === from) newIndex = to;
else if (from < s.index && to >= s.index) newIndex = s.index - 1;
else if (from > s.index && to <= s.index) newIndex = s.index + 1;
return { queue, index: newIndex };
}),
clearQueue: () => set({ queue: [], index: 0, isPlaying: false, currentTime: 0, duration: 0 }),
toggleShuffle: () => set((s) => ({ shuffle: !s.shuffle })),
cycleRepeat: () =>
set((s) => ({ repeat: s.repeat === "off" ? "all" : s.repeat === "all" ? "one" : "off" })),
setExpanded: (v) => set({ expanded: v }),
setShowLyrics: (v) => set({ showLyrics: v }),
setPlaying: (v) => set({ isPlaying: v }),
setCurrentTime: (t) => set({ currentTime: t }),
setDuration: (d) => set({ duration: d }),
setVolume: (v) => set({ volume: v }),
}),
{
name: "adc-retivo-player",
partialize: (s) => ({
queue: s.queue,
index: s.index,
volume: s.volume,
shuffle: s.shuffle,
repeat: s.repeat,
}),
},
),
);
export const selectCurrentTrack = (s: PlayerState): Track | null => s.queue[s.index] ?? null;

View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
bg: "#07080c",
panel: "rgba(255,255,255,0.04)",
accent: "#8b5cf6",
accent2: "#22d3ee",
},
fontFamily: {
sans: ["Inter", "system-ui", "Segoe UI", "sans-serif"],
},
backdropBlur: {
xs: "2px",
},
keyframes: {
"fade-in": {
from: { opacity: "0", transform: "translateY(6px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
"pulse-slow": {
"0%,100%": { opacity: "0.6" },
"50%": { opacity: "1" },
},
},
animation: {
"fade-in": "fade-in .35s ease both",
"pulse-slow": "pulse-slow 2.4s ease-in-out infinite",
},
},
},
plugins: [],
};

22
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

Some files were not shown because too many files have changed in this diff Show More