938 lines
31 KiB
Go
938 lines
31 KiB
Go
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) {
|
|
restGet(rest, "ping", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.PingResponse())
|
|
})
|
|
restGet(rest, "getLicense", func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.PingResponse())
|
|
})
|
|
rest.Group(func(authed chi.Router) {
|
|
authed.Use(application.requireSubsonicAuth)
|
|
restGet(authed, "getArtists", application.subsonicArtists)
|
|
restGet(authed, "getArtist", application.subsonicArtistByID)
|
|
restGet(authed, "getAlbum", application.subsonicAlbumByID)
|
|
restGet(authed, "getSong", application.subsonicSongByID)
|
|
restGet(authed, "getRandomSongs", application.subsonicRandomSongs)
|
|
restGet(authed, "getAlbumList2", application.subsonicAlbumList2)
|
|
restGet(authed, "getMusicFolders", application.subsonicMusicFolders)
|
|
restGet(authed, "getGenres", application.subsonicGenres)
|
|
restGet(authed, "getPodcasts", application.subsonicPodcasts)
|
|
restGet(authed, "getNewestPodcasts", application.subsonicNewestPodcasts)
|
|
restGet(authed, "getInternetRadioStations", application.subsonicInternetRadioStations)
|
|
restGet(authed, "search3", application.subsonicSearch3)
|
|
restGet(authed, "getStarred2", application.subsonicStarred2)
|
|
restGet(authed, "star", application.subsonicStar)
|
|
restGet(authed, "unstar", application.subsonicUnstar)
|
|
restGet(authed, "getPlaylists", application.subsonicPlaylists)
|
|
restGet(authed, "getPlaylist", application.subsonicPlaylistByID)
|
|
restGet(authed, "createPlaylist", application.subsonicCreatePlaylist)
|
|
restGet(authed, "updatePlaylist", application.subsonicUpdatePlaylist)
|
|
restGet(authed, "getScanStatus", application.subsonicScanStatus)
|
|
restGet(authed, "startScan", application.subsonicStartScan)
|
|
restGet(authed, "getCoverArt", application.subsonicCoverArt)
|
|
restGet(authed, "stream", application.subsonicStream)
|
|
restGet(authed, "scrobble", 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) {
|
|
user := currentUserFromContext(r)
|
|
home, err := a.library.Home(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
|
|
return
|
|
}
|
|
home.RecentTracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, home.RecentTracks)
|
|
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
|
|
}
|
|
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
|
|
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) {
|
|
user := currentUserFromContext(r)
|
|
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
|
|
}
|
|
item.Tracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, item.Tracks)
|
|
if err != nil {
|
|
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) {
|
|
user := currentUserFromContext(r)
|
|
items, err := a.library.Tracks(r.Context(), 200)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
|
|
return
|
|
}
|
|
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
|
|
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) {
|
|
user := currentUserFromContext(r)
|
|
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
|
|
}
|
|
enriched, err := a.library.PopulateTrackStats(r.Context(), user.ID, []library.Track{item})
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
|
return
|
|
}
|
|
if len(enriched) > 0 {
|
|
item = enriched[0]
|
|
}
|
|
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) subsonicAlbumList2(w http.ResponseWriter, r *http.Request) {
|
|
size := 60
|
|
if raw := strings.TrimSpace(r.URL.Query().Get("size")); raw != "" {
|
|
if parsed := parsePositiveInt(raw); parsed > 0 {
|
|
size = parsed
|
|
}
|
|
}
|
|
albums, err := a.library.Albums(r.Context(), size)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load albums"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, subsonic.AlbumList2Response(albums))
|
|
}
|
|
|
|
func (a app) subsonicMusicFolders(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.MusicFoldersResponse())
|
|
}
|
|
|
|
func (a app) subsonicGenres(w http.ResponseWriter, r *http.Request) {
|
|
genres, err := a.library.Genres(r.Context())
|
|
if err != nil {
|
|
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load genres"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, subsonic.GenresResponse(genres))
|
|
}
|
|
|
|
func (a app) subsonicPodcasts(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.PodcastsResponse())
|
|
}
|
|
|
|
func (a app) subsonicNewestPodcasts(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.NewestPodcastsResponse())
|
|
}
|
|
|
|
func (a app) subsonicInternetRadioStations(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, subsonic.InternetRadioStationsResponse())
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
|
|
func restGet(router chi.Router, endpoint string, handler http.HandlerFunc) {
|
|
router.Get("/"+endpoint, handler)
|
|
router.Get("/"+endpoint+".view", handler)
|
|
}
|