feat: add playlists mvp for web and subsonic
This commit is contained in:
@@ -15,21 +15,24 @@ import (
|
||||
"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
|
||||
scanner *scanner.Service
|
||||
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),
|
||||
scanner: scanService,
|
||||
auth: auth.NewService(database, cfg.EncryptionKey),
|
||||
library: library.NewService(database),
|
||||
playlists: playlist.NewService(database),
|
||||
scanner: scanService,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -59,6 +62,11 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Get("/tracks", application.tracks)
|
||||
private.Get("/tracks/{id}", application.trackByID)
|
||||
private.Get("/search", application.search)
|
||||
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)
|
||||
})
|
||||
@@ -85,6 +93,10 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
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)
|
||||
@@ -217,6 +229,80 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -289,6 +375,63 @@ func (a app) subsonicUnstar(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
@@ -479,6 +622,28 @@ func parsePositiveInt(raw string) int {
|
||||
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 spaFallback(next http.Handler) http.HandlerFunc {
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user