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:
13
.editorconfig
Normal file
13
.editorconfig
Normal 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
7
.env.example
Normal 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
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
coverage/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
|
bin/
|
||||||
|
|
||||||
18
Makefile
Normal file
18
Makefile
Normal 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."
|
||||||
|
|
||||||
757
SUBSONIC_SERVER_BLUEPRINT.md
Normal file
757
SUBSONIC_SERVER_BLUEPRINT.md
Normal 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
13
apps/web/index.html
Normal 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
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
33
apps/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
7
apps/web/postcss.config.js
Normal file
7
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
25
apps/web/src/App.tsx
Normal file
25
apps/web/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
85
apps/web/src/components/app-shell.tsx
Normal file
85
apps/web/src/components/app-shell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
53
apps/web/src/components/player-bar.tsx
Normal file
53
apps/web/src/components/player-bar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
18
apps/web/src/components/section-title.tsx
Normal file
18
apps/web/src/components/section-title.tsx
Normal 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
66
apps/web/src/lib/api.ts
Normal 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
19
apps/web/src/main.tsx
Normal 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>,
|
||||||
|
)
|
||||||
|
|
||||||
60
apps/web/src/pages/home-page.tsx
Normal file
60
apps/web/src/pages/home-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
67
apps/web/src/pages/library-page.tsx
Normal file
67
apps/web/src/pages/library-page.tsx
Normal 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')}`
|
||||||
|
}
|
||||||
114
apps/web/src/pages/login-page.tsx
Normal file
114
apps/web/src/pages/login-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
16
apps/web/src/stores/player-store.ts
Normal file
16
apps/web/src/stores/player-store.ts
Normal 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 }),
|
||||||
|
}))
|
||||||
16
apps/web/src/stores/session-store.ts
Normal file
16
apps/web/src/stores/session-store.ts
Normal 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
40
apps/web/src/styles.css
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
25
apps/web/tailwind.config.js
Normal file
25
apps/web/tailwind.config.js
Normal 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
26
apps/web/tsconfig.json
Normal 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": []
|
||||||
|
}
|
||||||
|
|
||||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal 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
18
apps/web/vite.config.ts
Normal 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
44
cmd/server/main.go
Normal 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
23
deploy/Dockerfile
Normal 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
20
docker-compose.yml
Normal 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
6
go.mod
Normal 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
23
internal/auth/service.go
Normal 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
36
internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
53
internal/httpapi/middleware.go
Normal file
53
internal/httpapi/middleware.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
81
internal/httpapi/router.go
Normal file
81
internal/httpapi/router.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
56
internal/library/service.go
Normal file
56
internal/library/service.go
Normal 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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
internal/subsonic/service.go
Normal file
26
internal/subsonic/service.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
74
migrations/0001_initial.sql
Normal file
74
migrations/0001_initial.sql
Normal 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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user