From f8880aa9a4b86c75ce87877dfc3675954eda8445 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 2 Apr 2026 22:37:10 +0300 Subject: [PATCH] feat: add scan status and cover art endpoints Track scanner status for the web API and Subsonic-compatible scan endpoints, add authenticated cover art serving, and wire album artwork into the web UI. Keep Subsonic auth limited to legacy password mode for now so behavior stays honest with the current bcrypt-based user storage. --- .gitignore | 1 + apps/web/src/lib/api.ts | 6 ++ apps/web/src/pages/home-page.tsx | 13 ++- internal/auth/service.go | 34 +++++++ internal/httpapi/middleware.go | 31 +++++++ internal/httpapi/router.go | 84 ++++++++++++++++- internal/library/service.go | 46 +++++++++- internal/scanner/service.go | 149 ++++++++++++++++++++++++++++--- internal/subsonic/service.go | 53 ++++++++++- 9 files changed, 394 insertions(+), 23 deletions(-) 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) +}