feat: add sqlite-backed auth and library services
Bootstrap SQLite on server startup with embedded migrations and development seed data. Replace placeholder auth and library responses with database-backed services, bearer sessions, and repository-driven API handlers.
This commit is contained in:
@@ -4,4 +4,5 @@ SERVER_PORT=4040
|
|||||||
DATABASE_PATH=./data/app.db
|
DATABASE_PATH=./data/app.db
|
||||||
MEDIA_ROOT=./media
|
MEDIA_ROOT=./media
|
||||||
CORS_ORIGINS=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
DEFAULT_ADMIN_USERNAME=demo
|
||||||
|
DEFAULT_ADMIN_PASSWORD=demo
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
@@ -34,9 +36,11 @@ export type Track = {
|
|||||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
|
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
|
||||||
|
|
||||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const token = useSessionStore.getState().token
|
||||||
const response = await fetch(`${API_BASE}${path}`, {
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
...(init?.headers ?? {}),
|
...(init?.headers ?? {}),
|
||||||
},
|
},
|
||||||
...init,
|
...init,
|
||||||
@@ -63,4 +67,3 @@ export async function fetchHome() {
|
|||||||
export async function fetchTracks() {
|
export async function fetchTracks() {
|
||||||
return request<{ items: Track[] }>('/api/tracks')
|
return request<{ items: Track[] }>('/api/tracks')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
type SessionState = {
|
type SessionState = {
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -7,10 +8,16 @@ type SessionState = {
|
|||||||
clearSession: () => void
|
clearSession: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSessionStore = create<SessionState>((set) => ({
|
export const useSessionStore = create<SessionState>()(
|
||||||
token: null,
|
persist(
|
||||||
username: null,
|
(set) => ({
|
||||||
setSession: (token, username) => set({ token, username }),
|
token: null,
|
||||||
clearSession: () => set({ token: null, username: null }),
|
username: null,
|
||||||
}))
|
setSession: (token, username) => set({ token, username }),
|
||||||
|
clearSession: () => set({ token: null, username: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'temporserv-session',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,12 +10,29 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/benya/temporserv/internal/config"
|
"github.com/benya/temporserv/internal/config"
|
||||||
|
"github.com/benya/temporserv/internal/db"
|
||||||
"github.com/benya/temporserv/internal/httpapi"
|
"github.com/benya/temporserv/internal/httpapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
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{
|
server := &http.Server{
|
||||||
Addr: cfg.Address(),
|
Addr: cfg.Address(),
|
||||||
@@ -41,4 +58,3 @@ func main() {
|
|||||||
log.Printf("shutdown error: %v", err)
|
log.Printf("shutdown error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
go.mod
7
go.mod
@@ -2,5 +2,8 @@ module github.com/benya/temporserv
|
|||||||
|
|
||||||
go 1.25.0
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
package auth
|
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 {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -10,14 +26,163 @@ type User struct {
|
|||||||
LastLoginAt time.Time `json:"lastLoginAt"`
|
LastLoginAt time.Time `json:"lastLoginAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DemoUser() User {
|
type Session struct {
|
||||||
now := time.Now().UTC()
|
Token string `json:"token"`
|
||||||
return User{
|
User User `json:"user"`
|
||||||
ID: "user-demo",
|
|
||||||
Username: "demo",
|
|
||||||
IsAdmin: true,
|
|
||||||
CreatedAt: now,
|
|
||||||
LastLoginAt: now,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ type Config struct {
|
|||||||
DatabasePath string
|
DatabasePath string
|
||||||
MediaRoot string
|
MediaRoot string
|
||||||
CORSOrigins string
|
CORSOrigins string
|
||||||
|
DefaultAdminUsername string
|
||||||
|
DefaultAdminPassword string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
@@ -19,6 +21,8 @@ func Load() Config {
|
|||||||
DatabasePath: getenv("DATABASE_PATH", "./data/app.db"),
|
DatabasePath: getenv("DATABASE_PATH", "./data/app.db"),
|
||||||
MediaRoot: getenv("MEDIA_ROOT", "./media"),
|
MediaRoot: getenv("MEDIA_ROOT", "./media"),
|
||||||
CORSOrigins: getenv("CORS_ORIGINS", "http://localhost:5173"),
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
internal/db/db.go
Normal file
71
internal/db/db.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
87
internal/db/migrations/0001_initial.sql
Normal file
87
internal/db/migrations/0001_initial.sql
Normal file
@@ -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);
|
||||||
133
internal/db/seed.go
Normal file
133
internal/db/seed.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/benya/temporserv/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const currentUserKey contextKey = "currentUser"
|
||||||
|
|
||||||
func requestLogger(next http.Handler) http.Handler {
|
func requestLogger(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
startedAt := time.Now()
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -14,7 +16,17 @@ import (
|
|||||||
"github.com/benya/temporserv/internal/subsonic"
|
"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 := chi.NewRouter()
|
||||||
r.Use(requestLogger)
|
r.Use(requestLogger)
|
||||||
r.Use(recoverer)
|
r.Use(recoverer)
|
||||||
@@ -29,22 +41,13 @@ func NewRouter(cfg config.Config) http.Handler {
|
|||||||
})
|
})
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
api.Get("/me", func(w http.ResponseWriter, r *http.Request) {
|
api.Post("/auth/login", application.login)
|
||||||
writeJSON(w, http.StatusOK, auth.DemoUser())
|
|
||||||
})
|
api.Group(func(private chi.Router) {
|
||||||
api.Get("/home", func(w http.ResponseWriter, r *http.Request) {
|
private.Use(application.requireAuth)
|
||||||
writeJSON(w, http.StatusOK, library.DemoHome())
|
private.Get("/me", application.me)
|
||||||
})
|
private.Get("/home", application.home)
|
||||||
api.Get("/tracks", func(w http.ResponseWriter, r *http.Request) {
|
private.Get("/tracks", application.tracks)
|
||||||
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(),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -55,6 +58,8 @@ func NewRouter(cfg config.Config) http.Handler {
|
|||||||
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
|
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, subsonic.PingResponse())
|
writeJSON(w, http.StatusOK, subsonic.PingResponse())
|
||||||
})
|
})
|
||||||
|
rest.Get("/getArtists.view", application.subsonicArtists)
|
||||||
|
rest.Get("/getRandomSongs.view", application.subsonicRandomSongs)
|
||||||
})
|
})
|
||||||
|
|
||||||
fs := http.FileServer(http.Dir("./web"))
|
fs := http.FileServer(http.Dir("./web"))
|
||||||
@@ -63,6 +68,71 @@ func NewRouter(cfg config.Config) http.Handler {
|
|||||||
return r
|
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) {
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
@@ -78,4 +148,3 @@ func spaFallback(next http.Handler) http.HandlerFunc {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package library
|
package library
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -24,6 +30,8 @@ type Track struct {
|
|||||||
AlbumTitle string `json:"albumTitle"`
|
AlbumTitle string `json:"albumTitle"`
|
||||||
TrackNumber int `json:"trackNumber"`
|
TrackNumber int `json:"trackNumber"`
|
||||||
DurationSecs int `json:"durationSeconds"`
|
DurationSecs int `json:"durationSeconds"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HomePayload struct {
|
type HomePayload struct {
|
||||||
@@ -31,26 +39,124 @@ type HomePayload struct {
|
|||||||
Artists []Artist `json:"artists"`
|
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{
|
return HomePayload{
|
||||||
RecentAlbums: []Album{
|
RecentAlbums: albums,
|
||||||
{ID: "album-1", ArtistID: "artist-1", ArtistName: "Tycho", Title: "Awake", Year: 2014, TrackCount: 8},
|
Artists: artists,
|
||||||
{ID: "album-2", ArtistID: "artist-2", ArtistName: "Bonobo", Title: "Migration", Year: 2017, TrackCount: 11},
|
}, nil
|
||||||
{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 {
|
func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
||||||
return []Track{
|
rows, err := s.db.QueryContext(
|
||||||
{ID: "track-1", AlbumID: "album-1", ArtistID: "artist-1", Title: "Awake", ArtistName: "Tycho", AlbumTitle: "Awake", TrackNumber: 1, DurationSecs: 224},
|
ctx,
|
||||||
{ID: "track-2", AlbumID: "album-2", ArtistID: "artist-2", Title: "Migration", ArtistName: "Bonobo", AlbumTitle: "Migration", TrackNumber: 1, DurationSecs: 301},
|
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
||||||
{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},
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
|
import "github.com/benya/temporserv/internal/library"
|
||||||
|
|
||||||
type Envelope struct {
|
type Envelope struct {
|
||||||
SubsonicResponse Response `json:"subsonic-response"`
|
SubsonicResponse Response `json:"subsonic-response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Response struct {
|
type Response struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Server string `json:"serverVersion"`
|
Server string `json:"serverVersion"`
|
||||||
OpenAPI bool `json:"openSubsonic"`
|
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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
last_login_at TEXT
|
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 (
|
CREATE TABLE IF NOT EXISTS library_roots (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
path TEXT NOT NULL UNIQUE,
|
path TEXT NOT NULL UNIQUE,
|
||||||
@@ -72,3 +79,9 @@ CREATE TABLE IF NOT EXISTS favorites (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (user_id, entity_id, entity_type)
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user