package httpapi import ( "database/sql" "encoding/json" "errors" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/benya/temporserv/internal/auth" "github.com/benya/temporserv/internal/config" "github.com/benya/temporserv/internal/library" "github.com/benya/temporserv/internal/playlist" "github.com/benya/temporserv/internal/scanner" "github.com/benya/temporserv/internal/subsonic" ) type app struct { auth *auth.Service library *library.Service playlists *playlist.Service scanner *scanner.Service } func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler { application := app{ auth: auth.NewService(database, cfg.EncryptionKey), library: library.NewService(database), playlists: playlist.NewService(database), scanner: scanService, } r := chi.NewRouter() r.Use(requestLogger) r.Use(recoverer) r.Use(cors(cfg.CORSOrigins)) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", "time": time.Now().UTC(), "env": cfg.AppEnv, }) }) r.Route("/api", func(api chi.Router) { api.Post("/auth/login", application.login) api.Post("/auth/logout", application.logout) api.Group(func(private chi.Router) { private.Use(application.requireAuth) private.Get("/me", application.me) private.Get("/home", application.home) private.Get("/recently-played", application.recentlyPlayed) private.Get("/artists", application.artists) private.Get("/artists/{id}", application.artistByID) private.Get("/albums", application.albums) private.Get("/albums/{id}", application.albumByID) private.Get("/tracks", application.tracks) private.Get("/tracks/{id}", application.trackByID) private.Get("/search", application.search) private.Get("/favorites", application.favorites) private.Post("/favorites", application.starFavorites) private.Delete("/favorites", application.unstarFavorites) private.Get("/playlists", application.playlistsList) private.Post("/playlists", application.createPlaylist) private.Get("/playlists/{id}", application.playlistByID) private.Patch("/playlists/{id}", application.updatePlaylist) private.Delete("/playlists/{id}", application.deletePlaylist) private.Get("/admin/scan-status", application.scanStatus) private.Post("/admin/scan", application.scanLibrary) private.Post("/history/scrobble", application.recordPlayEvent) }) api.Get("/cover-art/{id}", application.coverArt) api.Get("/stream/{id}", application.streamTrack) }) r.Route("/rest", func(rest chi.Router) { rest.Get("/ping.view", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, subsonic.PingResponse()) }) rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, subsonic.PingResponse()) }) rest.Group(func(authed chi.Router) { authed.Use(application.requireSubsonicAuth) authed.Get("/getArtists.view", application.subsonicArtists) authed.Get("/getArtist.view", application.subsonicArtistByID) authed.Get("/getAlbum.view", application.subsonicAlbumByID) authed.Get("/getSong.view", application.subsonicSongByID) authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) authed.Get("/search3.view", application.subsonicSearch3) authed.Get("/getStarred2.view", application.subsonicStarred2) authed.Get("/star.view", application.subsonicStar) authed.Get("/unstar.view", application.subsonicUnstar) authed.Get("/getPlaylists.view", application.subsonicPlaylists) authed.Get("/getPlaylist.view", application.subsonicPlaylistByID) authed.Get("/createPlaylist.view", application.subsonicCreatePlaylist) authed.Get("/updatePlaylist.view", application.subsonicUpdatePlaylist) authed.Get("/getScanStatus.view", application.subsonicScanStatus) authed.Get("/startScan.view", application.subsonicStartScan) authed.Get("/getCoverArt.view", application.subsonicCoverArt) authed.Get("/stream.view", application.subsonicStream) authed.Get("/scrobble.view", application.subsonicScrobble) }) }) if frontendRoot := detectFrontendRoot(); frontendRoot != "" { r.NotFound(spaHandler(frontendRoot)) } else { log.Printf("frontend assets not found; API routes are available, but web UI is not being served") } 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) logout(w http.ResponseWriter, r *http.Request) { token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) if token == "" { var payload struct { Token string `json:"token"` } if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { token = strings.TrimSpace(payload.Token) } } if err := a.auth.Logout(r.Context(), token); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "logout failed"}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } 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) artists(w http.ResponseWriter, r *http.Request) { items, err := a.library.Artists(r.Context(), 500) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artists"}) return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a app) recentlyPlayed(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) items, err := a.library.RecentTracks(r.Context(), user.ID, 24) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"}) return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a app) artistByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "artist not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load artist"}) return } writeJSON(w, http.StatusOK, item) } func (a app) albums(w http.ResponseWriter, r *http.Request) { items, err := a.library.Albums(r.Context(), 1000) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load albums"}) return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a app) albumByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "album not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"}) return } writeJSON(w, http.StatusOK, item) } 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) trackByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"}) return } writeJSON(w, http.StatusOK, item) } func (a app) search(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) if query == "" { writeJSON(w, http.StatusOK, library.SearchResults{}) return } results, err := a.library.Search(r.Context(), query, 20) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "search failed"}) return } writeJSON(w, http.StatusOK, results) } func (a app) favorites(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) results, err := a.library.Starred(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"}) return } writeJSON(w, http.StatusOK, results) } func (a app) starFavorites(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) var payload struct { TrackIDs []string `json:"trackIds"` AlbumIDs []string `json:"albumIds"` ArtistIDs []string `json:"artistIds"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if err := a.library.Star(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to star favorites"}) return } results, err := a.library.Starred(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"}) return } writeJSON(w, http.StatusOK, results) } func (a app) unstarFavorites(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) var payload struct { TrackIDs []string `json:"trackIds"` AlbumIDs []string `json:"albumIds"` ArtistIDs []string `json:"artistIds"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if err := a.library.Unstar(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unstar favorites"}) return } results, err := a.library.Starred(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"}) return } writeJSON(w, http.StatusOK, results) } func (a app) playlistsList(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) items, err := a.playlists.List(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load playlists"}) return } writeJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a app) playlistByID(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) item, err := a.playlists.ByID(r.Context(), user.ID, chi.URLParam(r, "id")) if err != nil { if errors.Is(err, playlist.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load playlist"}) return } writeJSON(w, http.StatusOK, item) } func (a app) createPlaylist(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) var payload playlist.CreateInput if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } item, err := a.playlists.Create(r.Context(), user.ID, payload) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create playlist"}) return } writeJSON(w, http.StatusCreated, item) } func (a app) updatePlaylist(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) var payload playlist.UpdateInput if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } item, err := a.playlists.Update(r.Context(), user.ID, chi.URLParam(r, "id"), payload) if err != nil { if errors.Is(err, playlist.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update playlist"}) return } writeJSON(w, http.StatusOK, item) } func (a app) deletePlaylist(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) err := a.playlists.Delete(r.Context(), user.ID, chi.URLParam(r, "id")) if err != nil { if errors.Is(err, playlist.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete playlist"}) return } w.WriteHeader(http.StatusNoContent) } 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) { size := 20 if raw := strings.TrimSpace(r.URL.Query().Get("size")); raw != "" { if parsed := parsePositiveInt(raw); parsed > 0 { size = parsed } } tracks, err := a.library.Tracks(r.Context(), size) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.PingResponse()) return } writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) } func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("query")) if query == "" { writeJSON(w, http.StatusOK, subsonic.Search3Response(library.SearchResults{})) return } count := parsePositiveInt(r.URL.Query().Get("songCount")) if count == 0 { count = 20 } results, err := a.library.Search(r.Context(), query, count) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "search failed")) return } writeJSON(w, http.StatusOK, subsonic.Search3Response(results)) } func (a app) subsonicStarred2(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) results, err := a.library.Starred(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load starred items")) return } writeJSON(w, http.StatusOK, subsonic.Starred2Response(results)) } func (a app) subsonicStar(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) if err := a.library.Star(r.Context(), user.ID, readMultiValue(r, "id"), readMultiValue(r, "albumId"), readMultiValue(r, "artistId")); err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to star items")) return } writeJSON(w, http.StatusOK, subsonic.PingResponse()) } func (a app) subsonicUnstar(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) if err := a.library.Unstar(r.Context(), user.ID, readMultiValue(r, "id"), readMultiValue(r, "albumId"), readMultiValue(r, "artistId")); err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to unstar items")) return } writeJSON(w, http.StatusOK, subsonic.PingResponse()) } func (a app) subsonicPlaylists(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) items, err := a.playlists.List(r.Context(), user.ID) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load playlists")) return } writeJSON(w, http.StatusOK, subsonic.PlaylistsResponse(user.Username, items)) } func (a app) subsonicPlaylistByID(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) item, err := a.playlists.ByID(r.Context(), user.ID, strings.TrimSpace(r.URL.Query().Get("id"))) if err != nil { if errors.Is(err, playlist.ErrNotFound) { writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "playlist not found")) return } writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load playlist")) return } writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item)) } func (a app) subsonicCreatePlaylist(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) item, err := a.playlists.Create(r.Context(), user.ID, playlist.CreateInput{ Name: strings.TrimSpace(r.URL.Query().Get("name")), TrackIDs: readMultiValue(r, "songId"), }) if err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to create playlist")) return } writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item)) } func (a app) subsonicUpdatePlaylist(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) item, err := a.playlists.Update(r.Context(), user.ID, strings.TrimSpace(r.URL.Query().Get("playlistId")), playlist.UpdateInput{ Name: optionalString(r.URL.Query().Get("name")), Comment: optionalString(r.URL.Query().Get("comment")), Public: optionalBool(r.URL.Query().Get("public")), AddTrackIDs: readMultiValue(r, "songIdToAdd"), RemovePositions: playlist.ParseIndices(readMultiValue(r, "songIndexToRemove")), }) if err != nil { if errors.Is(err, playlist.ErrNotFound) { writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "playlist not found")) return } writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to update playlist")) return } writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item)) } func (a app) subsonicArtistByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "artist not found")) return } writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load artist")) return } writeJSON(w, http.StatusOK, subsonic.ArtistResponse(item)) } func (a app) subsonicAlbumByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.AlbumByID(r.Context(), r.URL.Query().Get("id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "album not found")) return } writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load album")) return } writeJSON(w, http.StatusOK, subsonic.AlbumResponse(item)) } func (a app) subsonicSongByID(w http.ResponseWriter, r *http.Request) { item, err := a.library.TrackByID(r.Context(), r.URL.Query().Get("id")) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "song not found")) return } writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load song")) return } writeJSON(w, http.StatusOK, subsonic.SongResponse(item)) } func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) { result, err := a.scanner.Scan(r.Context()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, result) } func (a app) recordPlayEvent(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) var payload struct { TrackID string `json:"trackId"` Submission bool `json:"submission"` Time int64 `json:"time"` ClientName string `json:"clientName"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } if strings.TrimSpace(payload.TrackID) == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trackId is required"}) return } playedAt := time.Now().UTC() if payload.Time > 0 { playedAt = time.UnixMilli(payload.Time).UTC() } eventType := "play" if payload.Submission { eventType = "scrobble" } if err := a.library.RecordPlayEvent(r.Context(), user.ID, payload.TrackID, eventType, payload.ClientName, playedAt, payload.Submission); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record play event"}) return } writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } 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 == "" { 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.serveTrackByID(w, r, chi.URLParam(r, "id")) } 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) subsonicStream(w http.ResponseWriter, r *http.Request) { a.serveTrackByID(w, r, r.URL.Query().Get("id")) } func (a app) subsonicScrobble(w http.ResponseWriter, r *http.Request) { user := currentUserFromContext(r) trackIDs := readMultiValue(r, "id") if len(trackIDs) == 0 { writeJSON(w, http.StatusBadRequest, subsonic.ErrorResponse(10, "missing track id")) return } submission := false if value := strings.TrimSpace(r.URL.Query().Get("submission")); value != "" { submission = value == "true" || value == "1" } timestamp := time.Now().UTC() if raw := strings.TrimSpace(r.URL.Query().Get("time")); raw != "" { if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 { // Subsonic sends seconds since epoch. timestamp = time.Unix(parsed, 0).UTC() } } for _, trackID := range trackIDs { eventType := "play" if submission { eventType = "scrobble" } if err := a.library.RecordPlayEvent(r.Context(), user.ID, trackID, eventType, "subsonic", timestamp, submission); err != nil { writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to record scrobble")) return } } writeJSON(w, http.StatusOK, subsonic.PingResponse()) } 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 (a app) serveTrackByID(w http.ResponseWriter, r *http.Request, id string) { track, err := a.library.TrackByID(r.Context(), id) if err != nil { if errors.Is(err, library.ErrNotFound) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "track not found"}) return } writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"}) return } file, err := os.Open(track.FilePath) if err != nil { writeJSON(w, http.StatusNotFound, map[string]string{"error": "audio 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 file"}) return } w.Header().Set("Content-Type", track.ContentType) 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) _ = json.NewEncoder(w).Encode(payload) } func readMultiValue(r *http.Request, key string) []string { values := r.URL.Query()[key] if len(values) > 0 { return values } if single := strings.TrimSpace(r.URL.Query().Get(key)); single != "" { return []string{single} } return nil } func parsePositiveInt(raw string) int { value, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil || value < 1 { return 0 } return value } func optionalString(raw string) *string { if strings.TrimSpace(raw) == "" { return nil } value := strings.TrimSpace(raw) return &value } func optionalBool(raw string) *bool { value := strings.ToLower(strings.TrimSpace(raw)) switch value { case "true", "1": result := true return &result case "false", "0": result := false return &result default: return nil } } func spaHandler(root string) http.HandlerFunc { fileServer := http.FileServer(http.Dir(root)) indexPath := filepath.Join(root, "index.html") return func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api") || strings.HasPrefix(r.URL.Path, "/rest") || strings.HasPrefix(r.URL.Path, "/health") { http.NotFound(w, r) return } cleanPath := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) if cleanPath == "." { http.ServeFile(w, r, indexPath) return } fullPath := filepath.Join(root, cleanPath) if info, err := os.Stat(fullPath); err == nil && !info.IsDir() { fileServer.ServeHTTP(w, r) return } http.ServeFile(w, r, indexPath) } } func detectFrontendRoot() string { candidates := []string{ "./apps/web/dist", "./web", filepath.Join(".", "apps", "web", "dist"), filepath.Join(".", "web"), } for _, candidate := range candidates { info, err := os.Stat(candidate) if err == nil && info.IsDir() { return candidate } } return "" }