diff --git a/.gitignore b/.gitignore index 3a25a46..e0b9efe 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ .vite/ *.tsbuildinfo data/ +media/ server.exe .DS_Store .env diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 45aac13..9a4a764 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -14,6 +14,7 @@ export type HomePayload = { title: string year: number trackCount: number + coverArtId: string }> artists: Array<{ id: string @@ -67,3 +68,8 @@ export async function fetchHome() { export async function fetchTracks() { return request<{ items: Track[] }>('/api/tracks') } + +export function coverArtUrl(id: string) { + const token = useSessionStore.getState().token + return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}` +} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index a8d207b..d520198 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query' -import { fetchHome } from '@/lib/api' +import { coverArtUrl, fetchHome } from '@/lib/api' import { SectionTitle } from '@/components/section-title' export function HomePage() { @@ -28,7 +28,15 @@ export function HomePage() {
{home?.recentAlbums.map((album) => (
-
+ {album.coverArtId ? ( + {album.title} + ) : ( +
+ )}
{album.title}
{album.artistName}
@@ -57,4 +65,3 @@ export function HomePage() {
) } - diff --git a/internal/auth/service.go b/internal/auth/service.go index ee27e76..9dd83ea 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -107,6 +107,40 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e return user, nil } +func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) { + if username == "" { + return User{}, ErrUnauthorized + } + + user, passwordHash, err := s.findUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrUnauthorized + } + return User{}, fmt.Errorf("find user by username: %w", err) + } + + if token != "" || salt != "" { + // We only support legacy `p` auth right now because password hashes are stored using bcrypt + // and cannot be converted back into the plain password needed for Subsonic token auth. + return User{}, ErrUnauthorized + } + + if strings.HasPrefix(password, "enc:") { + decoded, err := hex.DecodeString(strings.TrimPrefix(password, "enc:")) + if err != nil { + return User{}, ErrUnauthorized + } + password = string(decoded) + } + + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil { + return User{}, ErrUnauthorized + } + + return user, nil +} + func (s *Service) findUserByUsername(ctx context.Context, username string) (User, string, error) { var user User var passwordHash string diff --git a/internal/httpapi/middleware.go b/internal/httpapi/middleware.go index 4b05be2..701446c 100644 --- a/internal/httpapi/middleware.go +++ b/internal/httpapi/middleware.go @@ -79,3 +79,34 @@ func currentUserFromContext(r *http.Request) auth.User { } return user } + +func (a app) requireSubsonicAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, err := a.auth.CurrentUserBySubsonicAuth( + r.Context(), + r.URL.Query().Get("u"), + r.URL.Query().Get("p"), + r.URL.Query().Get("t"), + r.URL.Query().Get("s"), + ) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]any{ + "subsonic-response": map[string]any{ + "status": "failed", + "version": "1.16.1", + "type": "temporserv", + "serverVersion": "0.1.0", + "openSubsonic": true, + "error": map[string]any{ + "code": 40, + "message": "Wrong username or password", + }, + }, + }) + return + } + + ctx := context.WithValue(r.Context(), currentUserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index e89c702..17b01b6 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -52,9 +52,11 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service private.Get("/me", application.me) private.Get("/home", application.home) private.Get("/tracks", application.tracks) + private.Get("/admin/scan-status", application.scanStatus) private.Post("/admin/scan", application.scanLibrary) }) + api.Get("/cover-art/{id}", application.coverArt) api.Get("/stream/{id}", application.streamTrack) }) @@ -65,8 +67,14 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service 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) + rest.Group(func(authed chi.Router) { + authed.Use(application.requireSubsonicAuth) + authed.Get("/getArtists.view", application.subsonicArtists) + authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) + authed.Get("/getScanStatus.view", application.subsonicScanStatus) + authed.Get("/startScan.view", application.subsonicStartScan) + authed.Get("/getCoverArt.view", application.subsonicCoverArt) + }) }) fs := http.FileServer(http.Dir("./web")) @@ -149,6 +157,22 @@ func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +func (a app) scanStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, a.scanner.Status()) +} + +func (a app) coverArt(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + } + if _, err := a.auth.CurrentUserByToken(r.Context(), token); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + a.serveCoverArtByID(w, r, chi.URLParam(r, "id")) +} + func (a app) streamTrack(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { @@ -183,6 +207,62 @@ func (a app) streamTrack(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, info.Name(), info.ModTime(), file) } +func (a app) subsonicScanStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, subsonic.ScanStatusResponse(a.scanner.Status())) +} + +func (a app) subsonicStartScan(w http.ResponseWriter, r *http.Request) { + if started := a.scanner.ScanAsync(r.Context()); !started { + writeJSON(w, http.StatusConflict, subsonic.ErrorResponse(0, "scan already running")) + return + } + writeJSON(w, http.StatusOK, subsonic.ScanStatusResponse(a.scanner.Status())) +} + +func (a app) subsonicCoverArt(w http.ResponseWriter, r *http.Request) { + a.serveCoverArtByID(w, r, r.URL.Query().Get("id")) +} + +func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) { + path, err := a.library.CoverArtPathByEntityID(r.Context(), id) + if err != nil { + if errors.Is(err, library.ErrNotFound) { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "cover art not found"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load cover art"}) + return + } + + file, err := os.Open(path) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "cover art file not available"}) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to stat cover art"}) + return + } + + w.Header().Set("Content-Type", detectImageContentType(path)) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) +} + +func detectImageContentType(path string) string { + lower := strings.ToLower(path) + switch { + case strings.HasSuffix(lower, ".jpg"), strings.HasSuffix(lower, ".jpeg"): + return "image/jpeg" + case strings.HasSuffix(lower, ".png"): + return "image/png" + default: + return "application/octet-stream" + } +} + func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/library/service.go b/internal/library/service.go index 8a41521..9efdc6f 100644 --- a/internal/library/service.go +++ b/internal/library/service.go @@ -3,9 +3,12 @@ package library import ( "context" "database/sql" + "errors" "fmt" ) +var ErrNotFound = errors.New("not found") + type Artist struct { ID string `json:"id"` Name string `json:"name"` @@ -19,6 +22,7 @@ type Album struct { Title string `json:"title"` Year int `json:"year"` TrackCount int `json:"trackCount"` + CoverArtID string `json:"coverArtId"` } type Track struct { @@ -32,6 +36,7 @@ type Track struct { DurationSecs int `json:"durationSeconds"` FilePath string `json:"filePath"` ContentType string `json:"contentType"` + CoverArtID string `json:"coverArtId"` } type HomePayload struct { @@ -96,6 +101,7 @@ 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 + , COALESCE(al.cover_art_id, '') FROM albums al JOIN artists a ON a.id = al.artist_id LEFT JOIN tracks t ON t.album_id = al.id @@ -112,7 +118,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) 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 { + if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { return nil, fmt.Errorf("scan album: %w", err) } albums = append(albums, album) @@ -125,7 +131,7 @@ 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, '') + COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -152,6 +158,7 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) { &track.DurationSecs, &track.FilePath, &track.ContentType, + &track.CoverArtID, ); err != nil { return nil, fmt.Errorf("scan track: %w", err) } @@ -167,7 +174,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { err := s.db.QueryRowContext( 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, '') + COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') FROM tracks t JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id @@ -184,6 +191,7 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { &track.DurationSecs, &track.FilePath, &track.ContentType, + &track.CoverArtID, ) if err != nil { return Track{}, fmt.Errorf("query track by id: %w", err) @@ -191,3 +199,35 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) { return track, nil } + +func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) { + var path string + + err := s.db.QueryRowContext( + ctx, + `SELECT cover_art_id FROM albums WHERE id = ? AND cover_art_id <> ''`, + id, + ).Scan(&path) + if err == nil { + return path, nil + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", fmt.Errorf("query album cover art: %w", err) + } + + err = s.db.QueryRowContext( + ctx, + `SELECT al.cover_art_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE t.id = ? AND al.cover_art_id <> ''`, + id, + ).Scan(&path) + if err == nil { + return path, nil + } + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", fmt.Errorf("query track cover art: %w", err) +} diff --git a/internal/scanner/service.go b/internal/scanner/service.go index df8ddce..1f4edbb 100644 --- a/internal/scanner/service.go +++ b/internal/scanner/service.go @@ -11,6 +11,7 @@ import ( "path/filepath" "slices" "strings" + "sync" "time" "github.com/dhowden/tag" @@ -37,6 +38,18 @@ type Result struct { type Service struct { db *sql.DB mediaRoot string + mu sync.RWMutex + status Status +} + +type Status struct { + Scanning bool `json:"scanning"` + StartedAt time.Time `json:"startedAt,omitempty"` + FinishedAt time.Time `json:"finishedAt,omitempty"` + LastError string `json:"lastError,omitempty"` + Artists int `json:"artists"` + Albums int `json:"albums"` + Tracks int `json:"tracks"` } type scannedArtist struct { @@ -50,6 +63,7 @@ type scannedAlbum struct { Title string Year int Genre string + CoverArt string } type scannedTrack struct { @@ -84,12 +98,23 @@ func (s *Service) HasMediaFiles() bool { } func (s *Service) Scan(ctx context.Context) (Result, error) { + if !s.tryMarkStarted() { + return Result{}, fmt.Errorf("scan already running") + } + return s.runScan(ctx) +} + +func (s *Service) runScan(ctx context.Context) (Result, error) { if s.mediaRoot == "" { - return Result{}, fmt.Errorf("media root is empty") + err := fmt.Errorf("media root is empty") + s.markFailed(err) + return Result{}, err } if _, err := os.Stat(s.mediaRoot); err != nil { - return Result{}, fmt.Errorf("media root unavailable: %w", err) + wrapped := fmt.Errorf("media root unavailable: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } artists := map[string]scannedArtist{} @@ -115,25 +140,35 @@ func (s *Service) Scan(ctx context.Context) (Result, error) { return nil }) if err != nil { - return Result{}, fmt.Errorf("walk media root: %w", err) + wrapped := fmt.Errorf("walk media root: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return Result{}, fmt.Errorf("begin scan transaction: %w", err) + wrapped := fmt.Errorf("begin scan transaction: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("clear tracks: %w", err) + wrapped := fmt.Errorf("clear tracks: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("clear albums: %w", err) + wrapped := fmt.Errorf("clear albums: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("clear artists: %w", err) + wrapped := fmt.Errorf("clear artists: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } now := time.Now().UTC().Format(time.RFC3339) @@ -157,7 +192,9 @@ func (s *Service) Scan(ctx context.Context) (Result, error) { now, ); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("insert artist: %w", err) + wrapped := fmt.Errorf("insert artist: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } } @@ -178,12 +215,14 @@ func (s *Service) Scan(ctx context.Context) (Result, error) { album.Title, album.Year, album.Genre, - "", + album.CoverArt, now, now, ); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("insert album: %w", err) + wrapped := fmt.Errorf("insert album: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } } @@ -204,19 +243,44 @@ func (s *Service) Scan(ctx context.Context) (Result, error) { now, ); err != nil { _ = tx.Rollback() - return Result{}, fmt.Errorf("insert track: %w", err) + wrapped := fmt.Errorf("insert track: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } } if err := tx.Commit(); err != nil { - return Result{}, fmt.Errorf("commit scan transaction: %w", err) + wrapped := fmt.Errorf("commit scan transaction: %w", err) + s.markFailed(wrapped) + return Result{}, wrapped } - return Result{ + result := Result{ Artists: len(artists), Albums: len(albums), Tracks: len(tracks), - }, nil + } + + s.markFinished(result) + return result, nil +} + +func (s *Service) ScanAsync(ctx context.Context) bool { + if !s.tryMarkStarted() { + return false + } + + go func() { + _, _ = s.runScan(ctx) + }() + + return true +} + +func (s *Service) Status() Status { + s.mu.RLock() + defer s.mu.RUnlock() + return s.status } type scannedItem struct { @@ -263,6 +327,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) { artistID := hashID("artist", artistName) albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year)) trackID := hashID("track", path) + coverArt := findCoverArt(filepath.Dir(path)) return scannedItem{ artist: scannedArtist{ @@ -275,6 +340,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) { Title: albumTitle, Year: year, Genre: genre, + CoverArt: coverArt, }, track: scannedTrack{ ID: trackID, @@ -327,3 +393,58 @@ func hashID(parts ...string) string { sum := sha1.Sum([]byte(strings.Join(parts, "::"))) return hex.EncodeToString(sum[:]) } + +func findCoverArt(dir string) string { + candidates := []string{ + "cover.jpg", + "cover.jpeg", + "cover.png", + "folder.jpg", + "folder.jpeg", + "folder.png", + "front.jpg", + "front.jpeg", + "front.png", + } + + for _, candidate := range candidates { + path := filepath.Join(dir, candidate) + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +func (s *Service) tryMarkStarted() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.status.Scanning { + return false + } + s.status.Scanning = true + s.status.StartedAt = time.Now().UTC() + s.status.FinishedAt = time.Time{} + s.status.LastError = "" + return true +} + +func (s *Service) markFinished(result Result) { + s.mu.Lock() + defer s.mu.Unlock() + s.status.Scanning = false + s.status.FinishedAt = time.Now().UTC() + s.status.LastError = "" + s.status.Artists = result.Artists + s.status.Albums = result.Albums + s.status.Tracks = result.Tracks +} + +func (s *Service) markFailed(err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.status.Scanning = false + s.status.FinishedAt = time.Now().UTC() + s.status.LastError = err.Error() +} diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 4d5a0b2..319a937 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -1,6 +1,11 @@ package subsonic -import "github.com/benya/temporserv/internal/library" +import ( + "time" + + "github.com/benya/temporserv/internal/library" + "github.com/benya/temporserv/internal/scanner" +) type Envelope struct { SubsonicResponse Response `json:"subsonic-response"` @@ -14,6 +19,8 @@ type Response struct { OpenAPI bool `json:"openSubsonic"` Artists []ArtistRef `json:"artists,omitempty"` RandomSong []SongRef `json:"randomSongs,omitempty"` + ScanStatus *ScanStatus `json:"scanStatus,omitempty"` + Error *ErrorRef `json:"error,omitempty"` } type ArtistRef struct { @@ -28,6 +35,20 @@ type SongRef struct { Artist string `json:"artist"` } +type ScanStatus struct { + Scanning bool `json:"scanning"` + Count int `json:"count"` + FolderCount int `json:"folderCount"` + LastError string `json:"lastError,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` +} + +type ErrorRef struct { + Code int `json:"code"` + Message string `json:"message"` +} + func PingResponse() Envelope { return Envelope{ SubsonicResponse: Response{ @@ -63,3 +84,33 @@ func RandomSongsResponse(tracks []library.Track) Envelope { } return response } + +func ScanStatusResponse(status scanner.Status) Envelope { + response := PingResponse() + response.SubsonicResponse.ScanStatus = &ScanStatus{ + Scanning: status.Scanning, + Count: status.Tracks, + FolderCount: status.Albums, + LastError: status.LastError, + StartedAt: formatTime(status.StartedAt), + FinishedAt: formatTime(status.FinishedAt), + } + return response +} + +func ErrorResponse(code int, message string) Envelope { + response := PingResponse() + response.SubsonicResponse.Status = "failed" + response.SubsonicResponse.Error = &ErrorRef{ + Code: code, + Message: message, + } + return response +} + +func formatTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.Format(time.RFC3339) +}