Initial commit
This commit is contained in:
38
apps/server/Dockerfile
Normal file
38
apps/server/Dockerfile
Normal 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"]
|
||||
11
apps/server/Dockerfile.dev
Normal file
11
apps/server/Dockerfile.dev
Normal 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"]
|
||||
12
apps/server/drizzle.config.ts
Normal file
12
apps/server/drizzle.config.ts
Normal 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;
|
||||
174
apps/server/drizzle/0000_clever_famine.sql
Normal file
174
apps/server/drizzle/0000_clever_famine.sql
Normal 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"));
|
||||
1
apps/server/drizzle/0001_colossal_roxanne_simpson.sql
Normal file
1
apps/server/drizzle/0001_colossal_roxanne_simpson.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP INDEX "tracks_fingerprint_idx";
|
||||
14
apps/server/drizzle/0002_red_susan_delgado.sql
Normal file
14
apps/server/drizzle/0002_red_susan_delgado.sql
Normal 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;
|
||||
1408
apps/server/drizzle/meta/0000_snapshot.json
Normal file
1408
apps/server/drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1393
apps/server/drizzle/meta/0001_snapshot.json
Normal file
1393
apps/server/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1483
apps/server/drizzle/meta/0002_snapshot.json
Normal file
1483
apps/server/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/server/drizzle/meta/_journal.json
Normal file
27
apps/server/drizzle/meta/_journal.json
Normal 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
49
apps/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
apps/server/src/db/index.ts
Normal file
13
apps/server/src/db/index.ts
Normal 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;
|
||||
38
apps/server/src/db/migrate.ts
Normal file
38
apps/server/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
10
apps/server/src/db/queries.ts
Normal file
10
apps/server/src/db/queries.ts
Normal 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;
|
||||
434
apps/server/src/db/schema.ts
Normal file
434
apps/server/src/db/schema.ts
Normal 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;
|
||||
40
apps/server/src/db/seed.ts
Normal file
40
apps/server/src/db/seed.ts
Normal 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
56
apps/server/src/env.ts
Normal 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
13
apps/server/src/express.d.ts
vendored
Normal 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
93
apps/server/src/index.ts
Normal 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);
|
||||
});
|
||||
49
apps/server/src/lib/http.ts
Normal file
49
apps/server/src/lib/http.ts
Normal 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;
|
||||
}
|
||||
13
apps/server/src/lib/logger.ts
Normal file
13
apps/server/src/lib/logger.ts
Normal 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 },
|
||||
});
|
||||
67
apps/server/src/lib/metadata/acoustid.ts
Normal file
67
apps/server/src/lib/metadata/acoustid.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
35
apps/server/src/lib/metadata/coverart.ts
Normal file
35
apps/server/src/lib/metadata/coverart.ts
Normal 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;
|
||||
}
|
||||
132
apps/server/src/lib/metadata/index.ts
Normal file
132
apps/server/src/lib/metadata/index.ts
Normal 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 };
|
||||
}
|
||||
108
apps/server/src/lib/metadata/lrclib.ts
Normal file
108
apps/server/src/lib/metadata/lrclib.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
142
apps/server/src/lib/metadata/musicbrainz.ts
Normal file
142
apps/server/src/lib/metadata/musicbrainz.ts
Normal 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;
|
||||
}
|
||||
27
apps/server/src/lib/metadata/tags.ts
Normal file
27
apps/server/src/lib/metadata/tags.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
76
apps/server/src/lib/metadata/types.ts
Normal file
76
apps/server/src/lib/metadata/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
49
apps/server/src/lib/metadata/wikipedia.ts
Normal file
49
apps/server/src/lib/metadata/wikipedia.ts
Normal 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;
|
||||
}
|
||||
9
apps/server/src/lib/password.ts
Normal file
9
apps/server/src/lib/password.ts
Normal 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);
|
||||
}
|
||||
27
apps/server/src/lib/permissions.ts
Normal file
27
apps/server/src/lib/permissions.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
181
apps/server/src/lib/presenters.ts
Normal file
181
apps/server/src/lib/presenters.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
apps/server/src/lib/queue.ts
Normal file
33
apps/server/src/lib/queue.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
21
apps/server/src/lib/redis.ts
Normal file
21
apps/server/src/lib/redis.ts
Normal 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 });
|
||||
}
|
||||
70
apps/server/src/lib/session.ts
Normal file
70
apps/server/src/lib/session.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
81
apps/server/src/lib/storage.ts
Normal file
81
apps/server/src/lib/storage.ts
Normal 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);
|
||||
}
|
||||
43
apps/server/src/middleware/auth.ts
Normal file
43
apps/server/src/middleware/auth.ts
Normal 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();
|
||||
}
|
||||
324
apps/server/src/routes/admin.ts
Normal file
324
apps/server/src/routes/admin.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
60
apps/server/src/routes/albums.ts
Normal file
60
apps/server/src/routes/albums.ts
Normal 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) });
|
||||
}),
|
||||
);
|
||||
142
apps/server/src/routes/artists.ts
Normal file
142
apps/server/src/routes/artists.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
68
apps/server/src/routes/auth.ts
Normal file
68
apps/server/src/routes/auth.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
132
apps/server/src/routes/comments.ts
Normal file
132
apps/server/src/routes/comments.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
30
apps/server/src/routes/index.ts
Normal file
30
apps/server/src/routes/index.ts
Normal 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);
|
||||
80
apps/server/src/routes/library.ts
Normal file
80
apps/server/src/routes/library.ts
Normal 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)),
|
||||
});
|
||||
}),
|
||||
);
|
||||
89
apps/server/src/routes/me.ts
Normal file
89
apps/server/src/routes/me.ts
Normal 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) });
|
||||
}),
|
||||
);
|
||||
28
apps/server/src/routes/media.ts
Normal file
28
apps/server/src/routes/media.ts
Normal 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);
|
||||
}),
|
||||
);
|
||||
450
apps/server/src/routes/playlists.ts
Normal file
450
apps/server/src/routes/playlists.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
68
apps/server/src/routes/search.ts
Normal file
68
apps/server/src/routes/search.ts
Normal 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)),
|
||||
});
|
||||
}),
|
||||
);
|
||||
311
apps/server/src/routes/tracks.ts
Normal file
311
apps/server/src/routes/tracks.ts
Normal 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) });
|
||||
}),
|
||||
);
|
||||
207
apps/server/src/routes/uploads.ts
Normal file
207
apps/server/src/routes/uploads.ts
Normal 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 });
|
||||
}),
|
||||
);
|
||||
72
apps/server/src/routes/users.ts
Normal file
72
apps/server/src/routes/users.ts
Normal 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!) });
|
||||
}),
|
||||
);
|
||||
67
apps/server/src/services/access.ts
Normal file
67
apps/server/src/services/access.ts
Normal 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));
|
||||
}
|
||||
235
apps/server/src/services/trackIngest.ts
Normal file
235
apps/server/src/services/trackIngest.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
34
apps/server/src/workers/metadata.worker.ts
Normal file
34
apps/server/src/workers/metadata.worker.ts
Normal 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
13
apps/server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user