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

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