feat: bootstrap temporserv project scaffold

Add the initial project blueprint, Go backend skeleton, frontend app shell, database schema draft, and local development/deployment files.
This commit is contained in:
2026-04-02 22:17:48 +03:00
commit 2b3123a9a7
37 changed files with 4863 additions and 0 deletions

13
.editorconfig Normal file
View File

@@ -0,0 +1,13 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.go]
indent_style = tab

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
APP_ENV=development
SERVER_HOST=0.0.0.0
SERVER_PORT=4040
DATABASE_PATH=./data/app.db
MEDIA_ROOT=./media
CORS_ORIGINS=http://localhost:5173

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
dist/
build/
.vite/
.DS_Store
.env
.env.local
coverage/
tmp/
*.log
bin/

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
backend-run:
go run ./cmd/server
backend-test:
go test ./...
frontend-install:
npm --prefix apps/web install
frontend-dev:
npm --prefix apps/web run dev
frontend-build:
npm --prefix apps/web run build
dev:
@echo "Run backend and frontend in separate terminals."

View File

@@ -0,0 +1,757 @@
# Subsonic Server Blueprint
## Goal
Build a self-hosted music server with:
- a Subsonic-compatible API for third-party clients
- a modern web interface inspired by Aonsoku
- an architecture that is simpler to build than a full Navidrome fork
- a codebase that can grow into a serious long-term project
This project should not try to clone Navidrome feature-for-feature on day one.
The right approach is:
- build a focused backend with strong library and streaming fundamentals
- build a separate SPA frontend with a polished listening experience
- support the Subsonic API where it matters
- keep a separate internal web API for faster frontend development
## Reference Direction
### What to take from Navidrome
- overall product shape: self-hosted music server
- robust scanning and indexing mindset
- low-overhead backend architecture
- Subsonic compatibility strategy
- cover art, streaming, playlists, favorites, search, multi-user
### What to take from Aonsoku
- modern visual language
- frontend stack direction
- player UX patterns
- layout ideas for home, album, artist, playlist, queue, lyrics
- separation between data fetching, UI state, and playback state
### What not to copy directly
- do not embed the frontend tightly into the backend from day one
- do not aim for full Subsonic coverage immediately
- do not duplicate Navidrome's whole surface area before the core works well
## Recommended Stack
## Backend
- Language: Go
- HTTP router: `chi`
- Config: `viper` or a smaller env-based config layer
- Database: SQLite for MVP
- Migrations: `goose` or `atlas`
- Tag reading: a mature tag library for MP3/FLAC/M4A/OGG metadata
- Background jobs: in-process worker loops
- File watching: `fsnotify` or polling fallback
- Auth: session + token support
- Logging: `slog` or `logrus`
## Frontend
- Framework: React
- Build tool: Vite
- Language: TypeScript
- Routing: React Router
- Server state: TanStack Query
- Client state: Zustand
- UI primitives: Radix UI
- Styling: Tailwind CSS
- Forms: React Hook Form + Zod
- Audio playback: HTMLAudioElement first, optional HLS/transcoding later
## Infra
- Single binary backend for local/self-hosted use
- Separate frontend app built into static assets
- Docker support early
- Local dev via `docker-compose` or separate dev servers
## High-Level Architecture
```text
music files
-> scanner
-> metadata extractor
-> database index
-> artwork cache
clients
-> web SPA
-> third-party Subsonic clients
backend
-> auth
-> library API
-> Subsonic compatibility API
-> streaming/transcoding
-> playlist/favorites/history
```
## Recommended Repository Layout
```text
/
apps/
web/
cmd/
server/
internal/
auth/
config/
db/
httpapi/
library/
media/
playlist/
scanner/
subsonic/
users/
migrations/
assets/
scripts/
deploy/
docs/
```
## API Strategy
Use two APIs.
### 1. Subsonic-compatible API
Purpose:
- support existing clients
- preserve compatibility expectations
- allow easy migration from other Subsonic servers
Examples:
- `/rest/ping`
- `/rest/getLicense`
- `/rest/getArtists`
- `/rest/getArtist`
- `/rest/getAlbum`
- `/rest/getSong`
- `/rest/stream`
- `/rest/getCoverArt`
- `/rest/search3`
- `/rest/getRandomSongs`
- `/rest/getStarred2`
- `/rest/star`
- `/rest/unstar`
- `/rest/createPlaylist`
- `/rest/updatePlaylist`
- `/rest/getPlaylists`
- `/rest/getPlaylist`
- `/rest/scrobble`
### 2. Internal web API
Purpose:
- reduce frontend complexity
- return cleaner payloads than Subsonic XML/legacy JSON
- aggregate data for fast UI screens
Examples:
- `GET /api/me`
- `GET /api/home`
- `GET /api/artists`
- `GET /api/artists/:id`
- `GET /api/albums/:id`
- `GET /api/tracks/:id`
- `GET /api/playlists`
- `GET /api/playlists/:id`
- `POST /api/queue/scrobble`
- `GET /api/search?q=...`
- `GET /api/browse/recent`
- `GET /api/browse/random`
- `POST /api/favorites/:id`
- `DELETE /api/favorites/:id`
## Data Model
Core entities:
- users
- artists
- albums
- tracks
- genres
- album_art
- playlists
- playlist_tracks
- favorites
- play_history
- scan_jobs
- library_roots
Important fields:
### users
- id
- username
- password_hash
- is_admin
- last_login_at
- created_at
### artists
- id
- name
- sort_name
- album_count
- song_count
- biography
- cover_art_id
### albums
- id
- artist_id
- title
- sort_title
- year
- genre
- cover_art_id
- track_count
- duration_seconds
- album_artist
- release_type
### tracks
- id
- album_id
- artist_id
- title
- track_number
- disc_number
- year
- genre
- duration_seconds
- bitrate
- sample_rate
- file_path
- file_size
- suffix
- content_type
- lyrics_embedded
- cover_art_id
- created_at
- updated_at
### playlists
- id
- user_id
- name
- comment
- public
- created_at
- updated_at
## Phased Delivery Plan
## Phase 0: Foundation
Outcome:
- repo initialized
- dev tooling set up
- backend and frontend boot independently
Deliverables:
- monorepo or single repo with `apps/web` and Go backend
- linting and formatting
- basic CI
- environment examples
- Dockerfile and local compose
## Phase 1: Library Core
Outcome:
- server can scan a music folder and build a usable index
Deliverables:
- config for music folder paths
- scanner for recursive file discovery
- metadata extraction
- SQLite schema
- initial scan endpoint/job
- rescan changed files
- artist/album/track records
- cover art extraction or sidecar loading
## Phase 2: Playback Core
Outcome:
- music can be streamed and played in the web app
Deliverables:
- authenticated stream endpoint
- range requests
- MIME/content-type handling
- artwork endpoint
- queue and now-playing state in frontend
- album/artist pages
- playable track lists
## Phase 3: Subsonic Compatibility MVP
Outcome:
- major Subsonic clients can connect
Deliverables:
- auth handshake support
- required response envelope
- core artist/album/song endpoints
- stream endpoint compatibility
- cover art compatibility
- search support
- favorites support
- playlists support
## Phase 4: Product UX
Outcome:
- the web app feels like a polished daily driver
Deliverables:
- home page
- recent albums
- random picks
- favorites
- search experience
- playlists
- mini player + full player
- keyboard shortcuts
- responsive layout
## Phase 5: Power Features
Outcome:
- project becomes meaningfully competitive
Deliverables:
- on-the-fly transcoding
- lyrics
- listening history
- scrobble
- share links
- radio/mixes
- folder browsing
- multi-library
- admin dashboard
## Frontend Product Shape
## Core Screens
- login
- home
- artists list
- artist detail
- album detail
- playlist detail
- search
- queue
- settings
- full-screen player or expanded player panel
## UX Goals
- art-forward layout
- fast navigation
- minimal friction to start playback
- stable queue behavior
- good empty/loading/error states
- pleasant desktop and mobile behavior
## Visual Direction
- dark-first listening UI is acceptable, but keep theme system flexible
- strong album art presence
- large typography for hero sections
- compact dense lists where appropriate
- smooth transitions for queue and player states
- do not overbuild visual effects before playback and browsing feel solid
## Backend Module Breakdown
## `internal/config`
Responsibilities:
- env/file config loading
- defaults
- path validation
## `internal/db`
Responsibilities:
- DB connection
- migrations
- query helpers
- transactional boundaries
## `internal/scanner`
Responsibilities:
- directory crawl
- changed file detection
- deleted file cleanup
- scheduling rescans
## `internal/library`
Responsibilities:
- domain services for artists/albums/tracks
- browse/search logic
- cover art association
## `internal/media`
Responsibilities:
- file streaming
- range requests
- transcoding hooks
- content-type detection
- artwork serving
## `internal/subsonic`
Responsibilities:
- request parsing
- auth compatibility
- response shaping
- endpoint mapping from internal domain services
## `internal/auth`
Responsibilities:
- password hashing
- sessions/tokens
- permissions
## `internal/httpapi`
Responsibilities:
- REST endpoints for the web app
- JSON response contracts
- middleware
## Detailed MVP Checklist
## Project Setup
- [ ] Choose repository structure and naming
- [ ] Initialize Go module
- [ ] Initialize frontend app in `apps/web`
- [ ] Add root `.editorconfig`
- [ ] Add root `.gitignore`
- [ ] Add backend formatter/linter commands
- [ ] Add frontend formatter/linter commands
- [ ] Add shared `Makefile` or task runner
- [ ] Add `.env.example`
- [ ] Add `docker-compose.yml`
- [ ] Add backend `Dockerfile`
- [ ] Add frontend `Dockerfile` if needed
- [ ] Add CI workflow for lint/build/test
## Backend Bootstrap
- [ ] Create `cmd/server/main.go`
- [ ] Add config loading
- [ ] Add HTTP server bootstrap
- [ ] Add graceful shutdown
- [ ] Add structured logging
- [ ] Add health endpoint
- [ ] Add request logging middleware
- [ ] Add panic recovery middleware
- [ ] Add CORS strategy for local dev
## Database
- [ ] Choose migration tool
- [ ] Create initial schema migration
- [ ] Add DB connection setup
- [ ] Add migration runner at startup
- [ ] Add indexes for artist/album/track lookup
- [ ] Add indexes for search
- [ ] Add repository helpers or query layer
## Auth and Users
- [ ] Create users table
- [ ] Implement password hashing
- [ ] Implement login endpoint
- [ ] Implement session or bearer token issuance
- [ ] Implement auth middleware
- [ ] Implement current user endpoint
- [ ] Implement admin bootstrap user creation
- [ ] Add logout endpoint
## Library Scanning
- [ ] Add library roots table
- [ ] Add config for one or more music paths
- [ ] Recursively discover supported audio files
- [ ] Ignore unsupported file types
- [ ] Read tags from files
- [ ] Map tags into normalized artist/album/track model
- [ ] Extract embedded artwork when present
- [ ] Load folder sidecar artwork when present
- [ ] Persist scan results transactionally
- [ ] Track deleted files and remove stale DB rows
- [ ] Add initial full scan command
- [ ] Add rescan endpoint or admin action
- [ ] Add filesystem watch or scheduled scan
- [ ] Record scan job progress
- [ ] Expose scan status endpoint
## Browse API
- [ ] List artists
- [ ] Artist detail with albums
- [ ] Album detail with tracks
- [ ] Track detail
- [ ] Recent albums
- [ ] Random albums or songs
- [ ] Favorites listing
- [ ] Search endpoint
- [ ] Pagination support
- [ ] Sorting support
## Streaming
- [ ] Implement authenticated stream endpoint
- [ ] Support range requests
- [ ] Support HEAD where appropriate
- [ ] Return correct content type
- [ ] Handle missing files gracefully
- [ ] Add cover art endpoint
- [ ] Add basic download endpoint
## Playlists and History
- [ ] Create playlists table
- [ ] Create playlist tracks table
- [ ] Add create playlist endpoint
- [ ] Add rename playlist endpoint
- [ ] Add delete playlist endpoint
- [ ] Add reorder tracks endpoint
- [ ] Add add/remove track endpoints
- [ ] Add listening history table
- [ ] Record play/scrobble events
- [ ] Add recently played endpoint
## Favorites
- [ ] Add favorites table
- [ ] Star track
- [ ] Unstar track
- [ ] Star album if desired
- [ ] Unstar album if desired
- [ ] Star artist if desired
- [ ] Unstar artist if desired
## Subsonic Compatibility
- [ ] Implement request auth parsing
- [ ] Support username/password auth where needed
- [ ] Support token/salt auth
- [ ] Add common Subsonic response builder
- [ ] Implement `ping`
- [ ] Implement `getLicense`
- [ ] Implement `getArtists`
- [ ] Implement `getArtist`
- [ ] Implement `getAlbum`
- [ ] Implement `getSong`
- [ ] Implement `stream`
- [ ] Implement `getCoverArt`
- [ ] Implement `search3`
- [ ] Implement `getRandomSongs`
- [ ] Implement `getStarred2`
- [ ] Implement `star`
- [ ] Implement `unstar`
- [ ] Implement playlist endpoints
- [ ] Implement `scrobble`
- [ ] Test against at least one existing Subsonic client
## Frontend Bootstrap
- [ ] Create Vite React TypeScript app
- [ ] Configure routing
- [ ] Configure Tailwind
- [ ] Add Radix UI primitives
- [ ] Add TanStack Query client
- [ ] Add Zustand stores
- [ ] Add API client layer
- [ ] Add auth persistence strategy
- [ ] Add theme tokens and CSS variables
## Frontend App Shell
- [ ] Login page
- [ ] App layout with sidebar/topbar/player area
- [ ] Responsive navigation
- [ ] Toast/notification system
- [ ] Error boundary
- [ ] Query loading/error patterns
## Frontend Music Views
- [ ] Home page
- [ ] Artists grid/list page
- [ ] Artist detail page
- [ ] Album detail page
- [ ] Playlist page
- [ ] Search results page
- [ ] Favorites page
- [ ] Recently played page
## Frontend Player
- [ ] Global player store
- [ ] Queue model
- [ ] Play/pause
- [ ] Next/previous
- [ ] Seek bar
- [ ] Volume control
- [ ] Repeat modes
- [ ] Shuffle
- [ ] Track switching
- [ ] Keyboard shortcuts
- [ ] Mini player
- [ ] Expanded player
## Quality and Testing
- [ ] Unit tests for scanner parsing
- [ ] Unit tests for auth
- [ ] Unit tests for Subsonic envelope formatting
- [ ] Backend integration tests for browse endpoints
- [ ] Backend integration tests for stream endpoint
- [ ] Frontend component tests for player and key pages
- [ ] Frontend E2E smoke tests
- [ ] Test with a realistic sample library
- [ ] Test on Windows paths
- [ ] Test on Linux paths
- [ ] Test large album art and missing metadata edge cases
## Deployment
- [ ] Build production frontend assets
- [ ] Serve frontend assets from backend or reverse proxy
- [ ] Docker image for all-in-one deployment
- [ ] Persistent DB volume
- [ ] Persistent cache volume
- [ ] Music folder mount strategy
- [ ] Reverse proxy example
- [ ] HTTPS deployment notes
- [ ] Backup/restore notes
## Nice-to-Have After MVP
- [ ] On-the-fly transcoding
- [ ] Multi-bitrate streaming profiles
- [ ] Lyrics support
- [ ] Last.fm or ListenBrainz scrobbling
- [ ] Shareable public links
- [ ] Smart playlists
- [ ] Radio or generated mixes
- [ ] Folder view
- [ ] Multi-library roots with permissions
- [ ] Podcast support
- [ ] Admin analytics page
- [ ] PWA support
## Suggested MVP Cut Line
If we want the fastest realistic first release, include only:
- auth
- scanner
- artists/albums/tracks browse
- cover art
- streaming
- search
- favorites
- playlists
- recent albums
- Subsonic core endpoints
- polished web player
Delay until later:
- transcoding
- lyrics
- podcasts
- radio
- advanced admin tools
- recommendations and smart mixes
## Recommended First Build Order
1. Backend bootstrap and DB
2. Scanner and normalized schema
3. Browse endpoints
4. Stream and cover art endpoints
5. Web login and app shell
6. Artist/album pages
7. Queue and player
8. Search and favorites
9. Playlists
10. Subsonic compatibility layer
11. Docker and deployment polish
## Practical Recommendation
The best implementation strategy is:
- build your own backend
- use Navidrome as a behavior reference, not a codebase to fork
- use Aonsoku as a frontend inspiration and partial architectural reference
- prioritize a strong MVP over broad feature parity
If you want, the next step should be scaffolding the actual repository:
- Go backend skeleton
- React frontend skeleton
- initial DB schema
- first endpoints
- first app shell
```

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

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TemporServ</title>
</head>
<body class="bg-canvas">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2911
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,33 @@
{
"name": "temporserv-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.3",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.65.0",
"react-router-dom": "^7.8.2",
"zod": "^4.1.5",
"zustand": "^5.0.8"
},
"devDependencies": {
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.9.2",
"vite": "^7.1.5"
}
}

View File

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

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

@@ -0,0 +1,25 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from '@/components/app-shell'
import { HomePage } from '@/pages/home-page'
import { LibraryPage } from '@/pages/library-page'
import { LoginPage } from '@/pages/login-page'
import { useSessionStore } from '@/stores/session-store'
export default function App() {
const token = useSessionStore((state) => state.token)
if (!token) {
return <LoginPage />
}
return (
<AppShell>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppShell>
)
}

View File

@@ -0,0 +1,85 @@
import { Disc3, Home, Library, LogOut, Search } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { PlayerBar } from '@/components/player-bar'
import { useSessionStore } from '@/stores/session-store'
export function AppShell({ children }: { children: React.ReactNode }) {
const username = useSessionStore((state) => state.username)
const clearSession = useSessionStore((state) => state.clearSession)
return (
<div className="min-h-screen bg-transparent px-4 py-4 text-ink md:px-6">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] max-w-7xl grid-cols-1 gap-4 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="rounded-[28px] border border-line bg-panel/80 p-5 shadow-glow backdrop-blur">
<div className="mb-8 flex items-center gap-3">
<div className="rounded-2xl bg-accent p-3 text-slate-900">
<Disc3 size={22} />
</div>
<div>
<div className="font-display text-lg font-semibold">TemporServ</div>
<div className="text-sm text-slate-400">Subsonic-ready music server</div>
</div>
</div>
<nav className="space-y-2">
<SidebarLink to="/" icon={<Home size={18} />} label="Home" />
<SidebarLink to="/library" icon={<Library size={18} />} label="Library" />
<button
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-slate-300 transition hover:border-line hover:bg-slate-800/40"
type="button"
>
<Search size={18} />
Search
</button>
</nav>
<div className="mt-10 rounded-3xl border border-line bg-slate-900/50 p-4">
<div className="text-sm text-slate-400">Signed in as</div>
<div className="mt-1 font-medium">{username ?? 'demo'}</div>
<button
className="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-900 transition hover:bg-white"
onClick={clearSession}
type="button"
>
<LogOut size={16} />
Sign out
</button>
</div>
</aside>
<main className="flex min-h-0 flex-col gap-4">
<section className="min-h-0 flex-1 rounded-[28px] border border-line bg-panel/60 p-5 backdrop-blur md:p-6">
{children}
</section>
<PlayerBar />
</main>
</div>
</div>
)
}
function SidebarLink({
to,
icon,
label,
}: {
to: string
icon: React.ReactNode
label: string
}) {
return (
<NavLink
to={to}
className={({ isActive }) =>
[
'flex items-center gap-3 rounded-2xl px-4 py-3 transition',
isActive ? 'bg-accent text-slate-900' : 'text-slate-300 hover:bg-slate-800/40',
].join(' ')
}
>
{icon}
{label}
</NavLink>
)
}

View File

@@ -0,0 +1,53 @@
import { Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react'
import { usePlayerStore } from '@/stores/player-store'
export function PlayerBar() {
const currentTrack = usePlayerStore((state) => state.currentTrack)
return (
<section className="grid gap-4 rounded-[28px] border border-line bg-slate-950/70 p-4 backdrop-blur md:grid-cols-[1.3fr_auto_1fr] md:items-center">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">Now playing</div>
<div className="mt-1 text-lg font-semibold">{currentTrack?.title ?? 'Nothing queued yet'}</div>
<div className="text-sm text-slate-400">
{currentTrack ? `${currentTrack.artistName}${currentTrack.albumTitle}` : 'Wire this to /api/stream next'}
</div>
</div>
<div className="flex items-center justify-center gap-3">
<ControlButton icon={<SkipBack size={16} />} />
<ControlButton icon={<Play size={16} />} active />
<ControlButton icon={<Pause size={16} />} />
<ControlButton icon={<SkipForward size={16} />} />
</div>
<div className="flex items-center gap-3 md:justify-end">
<Volume2 size={16} className="text-slate-400" />
<div className="h-2 w-full max-w-40 rounded-full bg-slate-800">
<div className="h-2 w-2/3 rounded-full bg-accent" />
</div>
</div>
</section>
)
}
function ControlButton({
icon,
active = false,
}: {
icon: React.ReactNode
active?: boolean
}) {
return (
<button
className={[
'flex h-11 w-11 items-center justify-center rounded-full border transition',
active ? 'border-accent bg-accent text-slate-900' : 'border-line bg-slate-900/70 text-ink hover:bg-slate-800',
].join(' ')}
type="button"
>
{icon}
</button>
)
}

View File

@@ -0,0 +1,18 @@
export function SectionTitle({
eyebrow,
title,
copy,
}: {
eyebrow: string
title: string
copy: string
}) {
return (
<header className="mb-6">
<div className="text-xs uppercase tracking-[0.26em] text-accentSoft">{eyebrow}</div>
<h1 className="mt-2 text-3xl font-semibold md:text-4xl">{title}</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-400 md:text-base">{copy}</p>
</header>
)
}

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

@@ -0,0 +1,66 @@
export type User = {
id: string
username: string
isAdmin: boolean
}
export type HomePayload = {
recentAlbums: Array<{
id: string
artistId: string
artistName: string
title: string
year: number
trackCount: number
}>
artists: Array<{
id: string
name: string
albumCount: number
}>
}
export type Track = {
id: string
albumId: string
artistId: string
title: string
artistName: string
albumTitle: string
trackNumber: number
durationSeconds: number
}
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json() as Promise<T>
}
export async function login(username: string, password: string) {
return request<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function fetchHome() {
return request<HomePayload>('/api/home')
}
export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks')
}

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

@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from '@/App'
import '@/styles.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { fetchHome } from '@/lib/api'
import { SectionTitle } from '@/components/section-title'
export function HomePage() {
const homeQuery = useQuery({
queryKey: ['home'],
queryFn: fetchHome,
})
const home = homeQuery.data
return (
<div>
<SectionTitle
eyebrow="Overview"
title="Aonsoku-like web UI on top of your own server"
copy="This first scaffold gives us a styled shell, data fetching boundaries, and a clean place to add real library, scan, and playback flows."
/>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Recent albums</h2>
<span className="text-sm text-slate-500">{homeQuery.isLoading ? 'Loading...' : 'Demo payload'}</span>
</div>
<div className="grid gap-4 md:grid-cols-3">
{home?.recentAlbums.map((album) => (
<article key={album.id} className="rounded-[24px] border border-line bg-panel p-4">
<div className="aspect-square rounded-[20px] bg-[linear-gradient(145deg,#1f2f45,#152236)]" />
<div className="mt-4 text-lg font-semibold">{album.title}</div>
<div className="text-sm text-slate-400">{album.artistName}</div>
<div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500">
{album.year} {album.trackCount} tracks
</div>
</article>
))}
</div>
</section>
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
<h2 className="text-xl font-semibold">Artists</h2>
<div className="mt-4 space-y-3">
{home?.artists.map((artist) => (
<div key={artist.id} className="flex items-center justify-between rounded-2xl border border-line bg-panel px-4 py-3">
<div>
<div className="font-medium">{artist.name}</div>
<div className="text-sm text-slate-400">{artist.albumCount} albums</div>
</div>
<div className="text-xs uppercase tracking-[0.22em] text-accentSoft">Artist</div>
</div>
))}
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query'
import { SectionTitle } from '@/components/section-title'
import { fetchTracks } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function LibraryPage() {
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const tracksQuery = useQuery({
queryKey: ['tracks'],
queryFn: fetchTracks,
})
const tracks = tracksQuery.data?.items ?? []
return (
<div>
<SectionTitle
eyebrow="Library"
title="Tracks, queue, and playback boundaries"
copy="This is the first useful slice for the app: list tracks from the backend, seed the queue, and hand off current track state to the global player."
/>
<div className="mb-4 flex gap-3">
<button
className="rounded-2xl bg-accent px-4 py-3 font-semibold text-slate-900"
onClick={() => setQueue(tracks)}
type="button"
>
Queue all
</button>
</div>
<div className="overflow-hidden rounded-[28px] border border-line bg-slate-950/35">
<div className="grid grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line px-4 py-3 text-xs uppercase tracking-[0.24em] text-slate-500">
<div>#</div>
<div>Track</div>
<div>Album</div>
<div>Length</div>
</div>
{tracks.map((track) => (
<button
key={track.id}
className="grid w-full grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line/60 px-4 py-4 text-left transition hover:bg-slate-900/60"
onClick={() => playTrack(track)}
type="button"
>
<div className="text-slate-500">{track.trackNumber}</div>
<div>
<div className="font-medium">{track.title}</div>
<div className="text-sm text-slate-400">{track.artistName}</div>
</div>
<div className="text-slate-300">{track.albumTitle}</div>
<div className="text-slate-400">{formatDuration(track.durationSeconds)}</div>
</button>
))}
</div>
</div>
)
}
function formatDuration(durationSeconds: number) {
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -0,0 +1,114 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { login } from '@/lib/api'
import { useSessionStore } from '@/stores/session-store'
const schema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
})
type LoginForm = z.infer<typeof schema>
export function LoginPage() {
const setSession = useSessionStore((state) => state.setSession)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(schema),
defaultValues: {
username: 'demo',
password: 'demo',
},
})
const mutation = useMutation({
mutationFn: (values: LoginForm) => login(values.username, values.password),
onSuccess: (result) => {
setSession(result.token, result.user.username)
},
})
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="grid w-full max-w-6xl overflow-hidden rounded-[36px] border border-line bg-panel/70 shadow-glow backdrop-blur lg:grid-cols-[1.1fr_0.9fr]">
<section className="hidden min-h-[640px] flex-col justify-between bg-[linear-gradient(180deg,rgba(242,159,103,0.14),rgba(17,28,44,0.02))] p-10 lg:flex">
<div className="max-w-md">
<div className="text-xs uppercase tracking-[0.3em] text-accentSoft">TemporServ</div>
<h1 className="mt-4 text-5xl font-semibold leading-tight">
Self-hosted streaming with a modern listening UI.
</h1>
<p className="mt-4 text-base text-slate-300">
This scaffold already separates the Subsonic-friendly backend from a dedicated web client.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<InfoCard label="Backend" value="Go + chi" />
<InfoCard label="Web" value="React + Vite" />
<InfoCard label="Direction" value="Navidrome + Aonsoku" />
</div>
</section>
<section className="p-6 md:p-10">
<div className="mx-auto max-w-md">
<div className="text-sm uppercase tracking-[0.26em] text-accentSoft">Sign in</div>
<h2 className="mt-3 text-3xl font-semibold">Open your library</h2>
<p className="mt-2 text-sm text-slate-400">
Demo login is wired to the placeholder backend endpoint.
</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<label className="block">
<span className="mb-2 block text-sm text-slate-300">Username</span>
<input
className="w-full rounded-2xl border border-line bg-slate-950/60 px-4 py-3 text-ink outline-none transition focus:border-accent"
{...register('username')}
/>
{errors.username ? <span className="mt-2 block text-sm text-red-300">{errors.username.message}</span> : null}
</label>
<label className="block">
<span className="mb-2 block text-sm text-slate-300">Password</span>
<input
className="w-full rounded-2xl border border-line bg-slate-950/60 px-4 py-3 text-ink outline-none transition focus:border-accent"
type="password"
{...register('password')}
/>
{errors.password ? <span className="mt-2 block text-sm text-red-300">{errors.password.message}</span> : null}
</label>
<button
className="w-full rounded-2xl bg-accent px-4 py-3 font-semibold text-slate-900 transition hover:brightness-105 disabled:opacity-60"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending ? 'Signing in...' : 'Sign in'}
</button>
{mutation.isError ? (
<div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
Could not reach the backend. Make sure the API is running on `http://localhost:4040`.
</div>
) : null}
</form>
</div>
</section>
</div>
</div>
)
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-3xl border border-line bg-slate-950/35 p-4">
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">{label}</div>
<div className="mt-2 text-lg font-medium">{value}</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { create } from 'zustand'
import type { Track } from '@/lib/api'
type PlayerState = {
currentTrack: Track | null
queue: Track[]
setQueue: (tracks: Track[]) => void
playTrack: (track: Track) => void
}
export const usePlayerStore = create<PlayerState>((set) => ({
currentTrack: null,
queue: [],
setQueue: (queue) => set({ queue, currentTrack: queue[0] ?? null }),
playTrack: (currentTrack) => set({ currentTrack }),
}))

View File

@@ -0,0 +1,16 @@
import { create } from 'zustand'
type SessionState = {
token: string | null
username: string | null
setSession: (token: string, username: string) => void
clearSession: () => void
}
export const useSessionStore = create<SessionState>((set) => ({
token: null,
username: null,
setSession: (token, username) => set({ token, username }),
clearSession: () => set({ token: null, username: null }),
}))

40
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #dce6f2;
background:
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
font-family: "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
}
body {
color: #dce6f2;
background:
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
ink: '#dce6f2',
canvas: '#09111c',
panel: '#111c2c',
accent: '#f29f67',
accentSoft: '#f6c8a6',
line: '#223047'
},
boxShadow: {
glow: '0 24px 80px rgba(242, 159, 103, 0.18)'
},
fontFamily: {
display: ['Segoe UI', 'sans-serif'],
body: ['Segoe UI', 'sans-serif']
}
}
},
plugins: []
}

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

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": []
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/app-shell.tsx","./src/components/player-bar.tsx","./src/components/section-title.tsx","./src/lib/api.ts","./src/pages/home-page.tsx","./src/pages/library-page.tsx","./src/pages/login-page.tsx","./src/stores/player-store.ts","./src/stores/session-store.ts"],"version":"5.9.3"}

18
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
},
})

44
cmd/server/main.go Normal file
View File

@@ -0,0 +1,44 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/benya/temporserv/internal/config"
"github.com/benya/temporserv/internal/httpapi"
)
func main() {
cfg := config.Load()
handler := httpapi.NewRouter(cfg)
server := &http.Server{
Addr: cfg.Address(),
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("temporserv listening on %s", cfg.Address())
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}

23
deploy/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM golang:1.25-alpine AS backend-build
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
COPY internal ./internal
RUN go build -o /out/temporserv ./cmd/server
FROM node:24-alpine AS web-build
WORKDIR /src
COPY apps/web/package*.json ./apps/web/
RUN cd apps/web && npm install
COPY apps/web ./apps/web
RUN cd apps/web && npm run build
FROM alpine:3.21
WORKDIR /app
RUN adduser -D appuser
COPY --from=backend-build /out/temporserv /app/temporserv
COPY --from=web-build /src/apps/web/dist /app/web
RUN mkdir -p /app/data /music && chown -R appuser:appuser /app /music
USER appuser
EXPOSE 4040
CMD ["/app/temporserv"]

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: "3.9"
services:
app:
build:
context: .
dockerfile: deploy/Dockerfile
ports:
- "4040:4040"
environment:
APP_ENV: production
SERVER_HOST: 0.0.0.0
SERVER_PORT: 4040
DATABASE_PATH: /app/data/app.db
MEDIA_ROOT: /music
CORS_ORIGINS: http://localhost:4040
volumes:
- ./data:/app/data
- ./media:/music

6
go.mod Normal file
View File

@@ -0,0 +1,6 @@
module github.com/benya/temporserv
go 1.25.0
require github.com/go-chi/chi/v5 v5.2.1

23
internal/auth/service.go Normal file
View File

@@ -0,0 +1,23 @@
package auth
import "time"
type User struct {
ID string `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"isAdmin"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt time.Time `json:"lastLoginAt"`
}
func DemoUser() User {
now := time.Now().UTC()
return User{
ID: "user-demo",
Username: "demo",
IsAdmin: true,
CreatedAt: now,
LastLoginAt: now,
}
}

36
internal/config/config.go Normal file
View File

@@ -0,0 +1,36 @@
package config
import "os"
type Config struct {
AppEnv string
ServerHost string
ServerPort string
DatabasePath string
MediaRoot string
CORSOrigins string
}
func Load() Config {
return Config{
AppEnv: getenv("APP_ENV", "development"),
ServerHost: getenv("SERVER_HOST", "0.0.0.0"),
ServerPort: getenv("SERVER_PORT", "4040"),
DatabasePath: getenv("DATABASE_PATH", "./data/app.db"),
MediaRoot: getenv("MEDIA_ROOT", "./media"),
CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"),
}
}
func (c Config) Address() string {
return c.ServerHost + ":" + c.ServerPort
}
func getenv(key, fallback string) string {
value := os.Getenv(key)
if value == "" {
return fallback
}
return value
}

View File

@@ -0,0 +1,53 @@
package httpapi
import (
"log"
"net/http"
"strings"
"time"
)
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(startedAt))
})
}
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recover() != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func cors(origins string) func(http.Handler) http.Handler {
allowed := strings.Split(origins, ",")
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
for _, candidate := range allowed {
if strings.TrimSpace(candidate) == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
break
}
}
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}

View File

@@ -0,0 +1,81 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/benya/temporserv/internal/auth"
"github.com/benya/temporserv/internal/config"
"github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/subsonic"
)
func NewRouter(cfg config.Config) http.Handler {
r := chi.NewRouter()
r.Use(requestLogger)
r.Use(recoverer)
r.Use(cors(cfg.CORSOrigins))
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"time": time.Now().UTC(),
"env": cfg.AppEnv,
})
})
r.Route("/api", func(api chi.Router) {
api.Get("/me", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, auth.DemoUser())
})
api.Get("/home", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, library.DemoHome())
})
api.Get("/tracks", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"items": library.DemoTracks(),
})
})
api.Post("/auth/login", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"token": "dev-token",
"user": auth.DemoUser(),
})
})
})
r.Route("/rest", func(rest chi.Router) {
rest.Get("/ping.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
})
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
})
})
fs := http.FileServer(http.Dir("./web"))
r.Handle("/*", spaFallback(fs))
return r
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func spaFallback(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api") || strings.HasPrefix(r.URL.Path, "/rest") || strings.HasPrefix(r.URL.Path, "/health") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
}
}

View File

@@ -0,0 +1,56 @@
package library
type Artist struct {
ID string `json:"id"`
Name string `json:"name"`
AlbumCount int `json:"albumCount"`
}
type Album struct {
ID string `json:"id"`
ArtistID string `json:"artistId"`
ArtistName string `json:"artistName"`
Title string `json:"title"`
Year int `json:"year"`
TrackCount int `json:"trackCount"`
}
type Track struct {
ID string `json:"id"`
AlbumID string `json:"albumId"`
ArtistID string `json:"artistId"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
AlbumTitle string `json:"albumTitle"`
TrackNumber int `json:"trackNumber"`
DurationSecs int `json:"durationSeconds"`
}
type HomePayload struct {
RecentAlbums []Album `json:"recentAlbums"`
Artists []Artist `json:"artists"`
}
func DemoHome() HomePayload {
return HomePayload{
RecentAlbums: []Album{
{ID: "album-1", ArtistID: "artist-1", ArtistName: "Tycho", Title: "Awake", Year: 2014, TrackCount: 8},
{ID: "album-2", ArtistID: "artist-2", ArtistName: "Bonobo", Title: "Migration", Year: 2017, TrackCount: 11},
{ID: "album-3", ArtistID: "artist-3", ArtistName: "Boards of Canada", Title: "Tomorrow's Harvest", Year: 2013, TrackCount: 17},
},
Artists: []Artist{
{ID: "artist-1", Name: "Tycho", AlbumCount: 4},
{ID: "artist-2", Name: "Bonobo", AlbumCount: 6},
{ID: "artist-3", Name: "Boards of Canada", AlbumCount: 7},
},
}
}
func DemoTracks() []Track {
return []Track{
{ID: "track-1", AlbumID: "album-1", ArtistID: "artist-1", Title: "Awake", ArtistName: "Tycho", AlbumTitle: "Awake", TrackNumber: 1, DurationSecs: 224},
{ID: "track-2", AlbumID: "album-2", ArtistID: "artist-2", Title: "Migration", ArtistName: "Bonobo", AlbumTitle: "Migration", TrackNumber: 1, DurationSecs: 301},
{ID: "track-3", AlbumID: "album-3", ArtistID: "artist-3", Title: "Reach for the Dead", ArtistName: "Boards of Canada", AlbumTitle: "Tomorrow's Harvest", TrackNumber: 1, DurationSecs: 292},
}
}

View File

@@ -0,0 +1,26 @@
package subsonic
type Envelope struct {
SubsonicResponse Response `json:"subsonic-response"`
}
type Response struct {
Status string `json:"status"`
Version string `json:"version"`
Type string `json:"type"`
Server string `json:"serverVersion"`
OpenAPI bool `json:"openSubsonic"`
}
func PingResponse() Envelope {
return Envelope{
SubsonicResponse: Response{
Status: "ok",
Version: "1.16.1",
Type: "temporserv",
Server: "0.1.0",
OpenAPI: true,
},
}
}

View File

@@ -0,0 +1,74 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS library_roots (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
sort_name TEXT,
cover_art_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
sort_title TEXT,
year INTEGER,
genre TEXT,
cover_art_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tracks (
id TEXT PRIMARY KEY,
album_id TEXT NOT NULL,
artist_id TEXT NOT NULL,
title TEXT NOT NULL,
track_number INTEGER,
disc_number INTEGER,
duration_seconds INTEGER,
file_path TEXT NOT NULL UNIQUE,
content_type TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
comment TEXT,
public INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS playlist_tracks (
playlist_id TEXT NOT NULL,
track_id TEXT NOT NULL,
position INTEGER NOT NULL,
PRIMARY KEY (playlist_id, position)
);
CREATE TABLE IF NOT EXISTS favorites (
user_id TEXT NOT NULL,
entity_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, entity_id, entity_type)
);