diff --git a/.env.example b/.env.example index e56a493..c2c9fc2 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,5 @@ SERVER_PORT=4040 DATABASE_PATH=./data/app.db MEDIA_ROOT=./media CORS_ORIGINS=http://localhost:5173 - +DEFAULT_ADMIN_USERNAME=demo +DEFAULT_ADMIN_PASSWORD=demo diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4415055..45aac13 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { useSessionStore } from '@/stores/session-store' + export type User = { id: string username: string @@ -34,9 +36,11 @@ export type Track = { const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040' async function request(path: string, init?: RequestInit): Promise { + const token = useSessionStore.getState().token const response = await fetch(`${API_BASE}${path}`, { headers: { 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(init?.headers ?? {}), }, ...init, @@ -63,4 +67,3 @@ export async function fetchHome() { export async function fetchTracks() { return request<{ items: Track[] }>('/api/tracks') } - diff --git a/apps/web/src/stores/session-store.ts b/apps/web/src/stores/session-store.ts index 1049da5..6265255 100644 --- a/apps/web/src/stores/session-store.ts +++ b/apps/web/src/stores/session-store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { persist } from 'zustand/middleware' type SessionState = { token: string | null @@ -7,10 +8,16 @@ type SessionState = { clearSession: () => void } -export const useSessionStore = create((set) => ({ - token: null, - username: null, - setSession: (token, username) => set({ token, username }), - clearSession: () => set({ token: null, username: null }), -})) - +export const useSessionStore = create()( + persist( + (set) => ({ + token: null, + username: null, + setSession: (token, username) => set({ token, username }), + clearSession: () => set({ token: null, username: null }), + }), + { + name: 'temporserv-session', + }, + ), +) diff --git a/cmd/server/main.go b/cmd/server/main.go index 68d11a3..6d0bb6e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,12 +10,29 @@ import ( "time" "github.com/benya/temporserv/internal/config" + "github.com/benya/temporserv/internal/db" "github.com/benya/temporserv/internal/httpapi" ) func main() { cfg := config.Load() - handler := httpapi.NewRouter(cfg) + ctx := context.Background() + + database, err := db.Open(ctx, cfg) + if err != nil { + log.Fatalf("database bootstrap failed: %v", err) + } + defer database.Close() + + if err := db.Migrate(ctx, database); err != nil { + log.Fatalf("database migrations failed: %v", err) + } + + if err := db.Seed(ctx, database, cfg); err != nil { + log.Fatalf("database seed failed: %v", err) + } + + handler := httpapi.NewRouter(cfg, database) server := &http.Server{ Addr: cfg.Address(), @@ -41,4 +58,3 @@ func main() { log.Printf("shutdown error: %v", err) } } - diff --git a/go.mod b/go.mod index 378cc01..cb3118e 100644 --- a/go.mod +++ b/go.mod @@ -2,5 +2,8 @@ module github.com/benya/temporserv go 1.25.0 -require github.com/go-chi/chi/v5 v5.2.1 - +require ( + github.com/go-chi/chi/v5 v5.2.1 + golang.org/x/crypto v0.43.0 + modernc.org/sqlite v1.39.1 +) diff --git a/internal/auth/service.go b/internal/auth/service.go index 95eba0d..903a75c 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -1,6 +1,22 @@ package auth -import "time" +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUnauthorized = errors.New("unauthorized") +) type User struct { ID string `json:"id"` @@ -10,14 +26,163 @@ type User struct { 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, - } +type Session struct { + Token string `json:"token"` + User User `json:"user"` } +type Service struct { + db *sql.DB +} + +func NewService(db *sql.DB) *Service { + return &Service{db: db} +} + +func (s *Service) Login(ctx context.Context, username, password string) (Session, error) { + user, passwordHash, err := s.findUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrInvalidCredentials + } + return Session{}, fmt.Errorf("find user: %w", err) + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { + return Session{}, ErrInvalidCredentials + } + + token, err := newToken() + if err != nil { + return Session{}, fmt.Errorf("generate token: %w", err) + } + + now := time.Now().UTC() + expiresAt := now.Add(30 * 24 * time.Hour) + + if _, err := s.db.ExecContext( + ctx, + `INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`, + token, + user.ID, + now.Format(time.RFC3339), + expiresAt.Format(time.RFC3339), + ); err != nil { + return Session{}, fmt.Errorf("insert session: %w", err) + } + + if _, err := s.db.ExecContext( + ctx, + `UPDATE users SET last_login_at = ? WHERE id = ?`, + now.Format(time.RFC3339), + user.ID, + ); err == nil { + user.LastLoginAt = now + } + + return Session{ + Token: token, + User: user, + }, nil +} + +func (s *Service) CurrentUser(ctx context.Context, authorizationHeader string) (User, error) { + token := strings.TrimSpace(strings.TrimPrefix(authorizationHeader, "Bearer ")) + if token == "" { + return User{}, ErrUnauthorized + } + + user, err := s.findUserByToken(ctx, token) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrUnauthorized + } + return User{}, fmt.Errorf("find user by token: %w", err) + } + + return user, nil +} + +func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) { + var user User + var passwordHash string + var createdAt string + var lastLoginAt sql.NullString + var isAdmin int + + err := s.db.QueryRowContext( + ctx, + `SELECT id, username, password_hash, is_admin, created_at, last_login_at + FROM users + WHERE username = ?`, + username, + ).Scan( + &user.ID, + &user.Username, + &passwordHash, + &isAdmin, + &createdAt, + &lastLoginAt, + ) + if err != nil { + return User{}, "", err + } + + user.IsAdmin = isAdmin == 1 + user.CreatedAt = parseTime(createdAt) + if lastLoginAt.Valid { + user.LastLoginAt = parseTime(lastLoginAt.String) + } + + return user, passwordHash, nil +} + +func (s *Service) findUserByToken(ctx context.Context, token string) (User, error) { + var user User + var createdAt string + var lastLoginAt sql.NullString + var isAdmin int + + err := s.db.QueryRowContext( + ctx, + `SELECT u.id, u.username, u.is_admin, u.created_at, u.last_login_at + FROM users u + JOIN sessions s ON s.user_id = u.id + WHERE s.token = ? AND s.expires_at > ?`, + token, + time.Now().UTC().Format(time.RFC3339), + ).Scan( + &user.ID, + &user.Username, + &isAdmin, + &createdAt, + &lastLoginAt, + ) + if err != nil { + return User{}, err + } + + user.IsAdmin = isAdmin == 1 + user.CreatedAt = parseTime(createdAt) + if lastLoginAt.Valid { + user.LastLoginAt = parseTime(lastLoginAt.String) + } + + return user, nil +} + +func parseTime(raw string) time.Time { + parsed, err := time.Parse(time.RFC3339, raw) + if err != nil { + return time.Time{} + } + return parsed +} + +func newToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index ef6ea09..8593312 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,6 +9,8 @@ type Config struct { DatabasePath string MediaRoot string CORSOrigins string + DefaultAdminUsername string + DefaultAdminPassword string } func Load() Config { @@ -19,6 +21,8 @@ func Load() Config { DatabasePath: getenv("DATABASE_PATH", "./data/app.db"), MediaRoot: getenv("MEDIA_ROOT", "./media"), CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"), + DefaultAdminUsername: getenv("DEFAULT_ADMIN_USERNAME", "demo"), + DefaultAdminPassword: getenv("DEFAULT_ADMIN_PASSWORD", "demo"), } } @@ -33,4 +37,3 @@ func getenv(key, fallback string) string { } return value } - diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..3c193f6 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,71 @@ +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + _ "modernc.org/sqlite" + + "github.com/benya/temporserv/internal/config" +) + +//go:embed migrations/*.sql +var migrationFiles embed.FS + +func Open(ctx context.Context, cfg config.Config) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o755); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + + db, err := sql.Open("sqlite", cfg.DatabasePath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + db.SetMaxOpenConns(1) + db.SetConnMaxLifetime(30 * time.Minute) + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("ping database: %w", err) + } + + if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON;"); err != nil { + return nil, fmt.Errorf("enable foreign keys: %w", err) + } + + return db, nil +} + +func Migrate(ctx context.Context, database *sql.DB) error { + entries, err := migrationFiles.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name()) + if err != nil { + return fmt.Errorf("read migration %s: %w", entry.Name(), err) + } + + if _, err := database.ExecContext(ctx, string(sqlBytes)); err != nil { + return fmt.Errorf("apply migration %s: %w", entry.Name(), err) + } + } + + return nil +} diff --git a/internal/db/migrations/0001_initial.sql b/internal/db/migrations/0001_initial.sql new file mode 100644 index 0000000..f04ad49 --- /dev/null +++ b/internal/db/migrations/0001_initial.sql @@ -0,0 +1,87 @@ +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 sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + +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) +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id); +CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id); +CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id); diff --git a/internal/db/seed.go b/internal/db/seed.go new file mode 100644 index 0000000..70d3f80 --- /dev/null +++ b/internal/db/seed.go @@ -0,0 +1,133 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "time" + + "golang.org/x/crypto/bcrypt" + + "github.com/benya/temporserv/internal/config" +) + +func Seed(ctx context.Context, database *sql.DB, cfg config.Config) error { + if err := seedAdmin(ctx, database, cfg); err != nil { + return err + } + + if cfg.AppEnv == "development" { + if err := seedLibrary(ctx, database); err != nil { + return err + } + } + + return nil +} + +func seedAdmin(ctx context.Context, database *sql.DB, cfg config.Config) error { + var count int + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return fmt.Errorf("count users: %w", err) + } + + if count > 0 { + return nil + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(cfg.DefaultAdminPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("hash admin password: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + + _, err = database.ExecContext( + ctx, + `INSERT INTO users (id, username, password_hash, is_admin, created_at, last_login_at) + VALUES (?, ?, ?, 1, ?, ?)`, + "user-admin", + cfg.DefaultAdminUsername, + string(passwordHash), + now, + now, + ) + if err != nil { + return fmt.Errorf("insert admin user: %w", err) + } + + return nil +} + +func seedLibrary(ctx context.Context, database *sql.DB) error { + var artistCount int + if err := database.QueryRowContext(ctx, "SELECT COUNT(*) FROM artists").Scan(&artistCount); err != nil { + return fmt.Errorf("count artists: %w", err) + } + + if artistCount > 0 { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + + statements := []struct { + query string + args []any + }{ + { + query: `INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + args: []any{"artist-1", "Tycho", "Tycho", "", now, now}, + }, + { + query: `INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + args: []any{"artist-2", "Bonobo", "Bonobo", "", now, now}, + }, + { + query: `INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + args: []any{"artist-3", "Boards of Canada", "Boards of Canada", "", now, now}, + }, + { + query: `INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"album-1", "artist-1", "Awake", "Awake", 2014, "Electronic", "", now, now}, + }, + { + query: `INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"album-2", "artist-2", "Migration", "Migration", 2017, "Electronic", "", now, now}, + }, + { + query: `INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"album-3", "artist-3", "Tomorrow's Harvest", "Tomorrow's Harvest", 2013, "Electronic", "", now, now}, + }, + { + query: `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"track-1", "album-1", "artist-1", "Awake", 1, 1, 224, "demo/awake.mp3", "audio/mpeg", now, now}, + }, + { + query: `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"track-2", "album-2", "artist-2", "Migration", 1, 1, 301, "demo/migration.mp3", "audio/mpeg", now, now}, + }, + { + query: `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + args: []any{"track-3", "album-3", "artist-3", "Reach for the Dead", 1, 1, 292, "demo/reach-for-the-dead.mp3", "audio/mpeg", now, now}, + }, + } + + tx, err := database.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin seed transaction: %w", err) + } + + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement.query, statement.args...); err != nil { + _ = tx.Rollback() + return fmt.Errorf("seed statement failed: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit seed transaction: %w", err) + } + + return nil +} diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 469bfcf..4b05be2 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -1,12 +1,19 @@ package httpapi import ( + "context" "log" "net/http" "strings" "time" + + "github.com/benya/temporserv/internal/auth" ) +type contextKey string + +const currentUserKey contextKey = "currentUser" + func requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { startedAt := time.Now() @@ -51,3 +58,24 @@ func cors(origins string) func(http.Handler) http.Handler { }) } } + +func (a app) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := a.auth.CurrentUser(r.Context(), r.Header.Get("Authorization")) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + + ctx := context.WithValue(r.Context(), currentUserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func currentUserFromContext(r *http.Request) auth.User { + user, ok := r.Context().Value(currentUserKey).(auth.User) + if !ok { + return auth.User{} + } + return user +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 84d419c..1c4d222 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -1,7 +1,9 @@ package httpapi import ( + "database/sql" "encoding/json" + "errors" "net/http" "strings" "time" @@ -14,7 +16,17 @@ import ( "github.com/benya/temporserv/internal/subsonic" ) -func NewRouter(cfg config.Config) http.Handler { +type app struct { + auth *auth.Service + library *library.Service +} + +func NewRouter(cfg config.Config, database *sql.DB) http.Handler { + application := app{ + auth: auth.NewService(database), + library: library.NewService(database), + } + r := chi.NewRouter() r.Use(requestLogger) r.Use(recoverer) @@ -29,22 +41,13 @@ func NewRouter(cfg config.Config) http.Handler { }) 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(), - }) + api.Post("/auth/login", application.login) + + api.Group(func(private chi.Router) { + private.Use(application.requireAuth) + private.Get("/me", application.me) + private.Get("/home", application.home) + private.Get("/tracks", application.tracks) }) }) @@ -55,6 +58,8 @@ func NewRouter(cfg config.Config) http.Handler { rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, subsonic.PingResponse()) }) + rest.Get("/getArtists.view", application.subsonicArtists) + rest.Get("/getRandomSongs.view", application.subsonicRandomSongs) }) fs := http.FileServer(http.Dir("./web")) @@ -63,6 +68,71 @@ func NewRouter(cfg config.Config) http.Handler { return r } +func (a app) login(w http.ResponseWriter, r *http.Request) { + var payload struct { + Username string `json:"username"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + session, err := a.auth.Login(r.Context(), payload.Username, payload.Password) + if err != nil { + if errors.Is(err, auth.ErrInvalidCredentials) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "login failed"}) + return + } + + writeJSON(w, http.StatusOK, session) +} + +func (a app) me(w http.ResponseWriter, r *http.Request) { + user := currentUserFromContext(r) + writeJSON(w, http.StatusOK, user) +} + +func (a app) home(w http.ResponseWriter, r *http.Request) { + home, err := a.library.Home(r.Context()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"}) + return + } + writeJSON(w, http.StatusOK, home) +} + +func (a app) tracks(w http.ResponseWriter, r *http.Request) { + items, err := a.library.Tracks(r.Context(), 200) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) { + artists, err := a.library.Artists(r.Context(), 1000) + if err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse()) + return + } + writeJSON(w, http.StatusOK, subsonic.ArtistsResponse(artists)) +} + +func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) { + tracks, err := a.library.Tracks(r.Context(), 20) + if err != nil { + writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse()) + return + } + writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) @@ -78,4 +148,3 @@ func spaFallback(next http.Handler) http.HandlerFunc { next.ServeHTTP(w, r) } } - diff --git a/internal/library/service.go b/internal/library/service.go index 366583b..062ad21 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -1,5 +1,11 @@ package library +import ( + "context" + "database/sql" + "fmt" +) + type Artist struct { ID string `json:"id"` Name string `json:"name"` @@ -24,6 +30,8 @@ type Track struct { AlbumTitle string `json:"albumTitle"` TrackNumber int `json:"trackNumber"` DurationSecs int `json:"durationSeconds"` + FilePath string `json:"filePath"` + ContentType string `json:"contentType"` } type HomePayload struct { @@ -31,26 +39,124 @@ type HomePayload struct { Artists []Artist `json:"artists"` } -func DemoHome() HomePayload { +type Service struct { + db *sql.DB +} + +func NewService(db *sql.DB) *Service { + return &Service{db: db} +} + +func (s *Service) Home(ctx context.Context) (HomePayload, error) { + albums, err := s.RecentAlbums(ctx, 6) + if err != nil { + return HomePayload{}, err + } + + artists, err := s.Artists(ctx, 12) + if err != nil { + return HomePayload{}, err + } + 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}, - }, - } + RecentAlbums: albums, + Artists: artists, + }, nil } -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}, +func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) { + rows, err := s.db.QueryContext( + ctx, + `SELECT a.id, a.name, COUNT(al.id) AS album_count + FROM artists a + LEFT JOIN albums al ON al.artist_id = a.id + GROUP BY a.id, a.name + ORDER BY a.name ASC + LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("query artists: %w", err) } + defer rows.Close() + + var artists []Artist + for rows.Next() { + var artist Artist + if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount); err != nil { + return nil, fmt.Errorf("scan artist: %w", err) + } + artists = append(artists, artist) + } + + return artists, rows.Err() } +func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) { + rows, err := s.db.QueryContext( + ctx, + `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count + FROM albums al + JOIN artists a ON a.id = al.artist_id + LEFT JOIN tracks t ON t.album_id = al.id + GROUP BY al.id, al.artist_id, a.name, al.title, al.year + ORDER BY al.updated_at DESC, al.title ASC + LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("query albums: %w", err) + } + defer rows.Close() + + var albums []Album + for rows.Next() { + var album Album + if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount); err != nil { + return nil, fmt.Errorf("scan album: %w", err) + } + albums = append(albums, album) + } + + return albums, rows.Err() +} + +func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) { + rows, err := s.db.QueryContext( + ctx, + `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), + COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, '') + FROM tracks t + JOIN artists a ON a.id = t.artist_id + JOIN albums al ON al.id = t.album_id + ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC + LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("query tracks: %w", err) + } + defer rows.Close() + + var tracks []Track + for rows.Next() { + var track Track + if err := rows.Scan( + &track.ID, + &track.AlbumID, + &track.ArtistID, + &track.Title, + &track.ArtistName, + &track.AlbumTitle, + &track.TrackNumber, + &track.DurationSecs, + &track.FilePath, + &track.ContentType, + ); err != nil { + return nil, fmt.Errorf("scan track: %w", err) + } + tracks = append(tracks, track) + } + + return tracks, rows.Err() +} diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 2726c93..4d5a0b2 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -1,15 +1,31 @@ package subsonic +import "github.com/benya/temporserv/internal/library" + 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"` + Status string `json:"status"` + Version string `json:"version"` + Type string `json:"type"` + Server string `json:"serverVersion"` + OpenAPI bool `json:"openSubsonic"` + Artists []ArtistRef `json:"artists,omitempty"` + RandomSong []SongRef `json:"randomSongs,omitempty"` +} + +type ArtistRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type SongRef struct { + ID string `json:"id"` + Title string `json:"title"` + Album string `json:"album"` + Artist string `json:"artist"` } func PingResponse() Envelope { @@ -24,3 +40,26 @@ func PingResponse() Envelope { } } +func ArtistsResponse(artists []library.Artist) Envelope { + response := PingResponse() + for _, artist := range artists { + response.SubsonicResponse.Artists = append(response.SubsonicResponse.Artists, ArtistRef{ + ID: artist.ID, + Name: artist.Name, + }) + } + return response +} + +func RandomSongsResponse(tracks []library.Track) Envelope { + response := PingResponse() + for _, track := range tracks { + response.SubsonicResponse.RandomSong = append(response.SubsonicResponse.RandomSong, SongRef{ + ID: track.ID, + Title: track.Title, + Album: track.AlbumTitle, + Artist: track.ArtistName, + }) + } + return response +} diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql index 8ff3da4..f04ad49 100644 --- a/migrations/0001_initial.sql +++ b/migrations/0001_initial.sql @@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS users ( last_login_at TEXT ); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS library_roots ( id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, @@ -72,3 +79,9 @@ CREATE TABLE IF NOT EXISTS favorites ( created_at TEXT NOT NULL, PRIMARY KEY (user_id, entity_id, entity_type) ); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id); +CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id); +CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id);