feat: initial scaffold for ClickTrack monorepo
Full self-hosted click track generator for cover bands. Core technical pieces implemented: - CTP (Click Track Protocol) TypeScript schema, Zod validator, and WAV renderer (44.1 kHz, 16-bit PCM, accented downbeats, ramp sections) - MusicBrainz API client with 1 req/s rate limiting - PostgreSQL schema (songs, tempo_maps, registry_sync_log) with triggers - Git registry sync logic (clone/pull → validate CTP → upsert DB) - Next.js 14 App Router: search page, track page, API routes (/api/songs, /api/tracks, /api/generate) - UI components: SearchBar, SongResult, TempoMapEditor, ClickTrackPlayer (Web Audio API in-browser playback + WAV download) - Docker Compose stack: app + postgres + redis + nginx + registry-sync - Multi-stage Dockerfile with standalone Next.js output - .env.example documenting all configuration variables - README with setup instructions, CTP format spec, and API reference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
61
docker/Dockerfile
Normal file
61
docker/Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — deps: install all node_modules
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS deps
|
||||
|
||||
RUN apk add --no-cache libc6-compat git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* yarn.lock* pnpm-lock.yaml* ./
|
||||
|
||||
RUN \
|
||||
if [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install --frozen-lockfile; \
|
||||
else echo "No lockfile found." && npm install; \
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — builder: compile Next.js production build
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — runner: minimal production image
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy only what's needed to run
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
75
docker/init.sql
Normal file
75
docker/init.sql
Normal file
@@ -0,0 +1,75 @@
|
||||
-- init.sql
|
||||
-- Executed by PostgreSQL on first container start.
|
||||
-- The full schema is copied here so it runs automatically via
|
||||
-- docker-entrypoint-initdb.d without requiring a separate psql call.
|
||||
|
||||
-- Enable UUID generation
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ─── songs ────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
mbid UUID PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
duration_seconds NUMERIC(8, 3),
|
||||
acousticbrainz_bpm NUMERIC(6, 2),
|
||||
acousticbrainz_time_sig_num INTEGER,
|
||||
source TEXT NOT NULL DEFAULT 'musicbrainz',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS songs_title_artist_idx
|
||||
ON songs USING gin(to_tsvector('english', title || ' ' || artist));
|
||||
|
||||
-- ─── tempo_maps ───────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS tempo_maps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
song_mbid UUID NOT NULL REFERENCES songs(mbid) ON DELETE CASCADE,
|
||||
ctp_data JSONB NOT NULL,
|
||||
contributed_by TEXT NOT NULL,
|
||||
verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
upvotes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tempo_maps_song_mbid_idx ON tempo_maps (song_mbid);
|
||||
CREATE INDEX IF NOT EXISTS tempo_maps_verified_upvotes_idx
|
||||
ON tempo_maps (verified DESC, upvotes DESC);
|
||||
|
||||
ALTER TABLE tempo_maps
|
||||
ADD CONSTRAINT ctp_data_has_version
|
||||
CHECK (ctp_data ? 'version');
|
||||
|
||||
-- ─── registry_sync_log ────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS registry_sync_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
records_added INTEGER NOT NULL DEFAULT 0,
|
||||
records_updated INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
message TEXT
|
||||
);
|
||||
|
||||
-- ─── Triggers ─────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'songs_set_updated_at') THEN
|
||||
CREATE TRIGGER songs_set_updated_at
|
||||
BEFORE UPDATE ON songs FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'tempo_maps_set_updated_at') THEN
|
||||
CREATE TRIGGER tempo_maps_set_updated_at
|
||||
BEFORE UPDATE ON tempo_maps FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
38
docker/nginx.conf
Normal file
38
docker/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Increase buffer for WAV downloads
|
||||
proxy_buffers 16 64k;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
# Gzip (skip audio/wav — already binary)
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Cache static Next.js assets aggressively
|
||||
location /_next/static/ {
|
||||
proxy_pass http://app:3000;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# WAV downloads — no nginx caching (app sets its own Cache-Control)
|
||||
location /api/generate {
|
||||
proxy_pass http://app:3000;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user