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