Files
MusicServer/internal/server/routes.go
T
SansanandClaude Opus 5.5 a7e2280969
CI / integration (pull_request) Successful in 21s
CI / test (pull_request) Successful in 26s
CI / publish (pull_request) Skipped
CI / docker (pull_request) Successful in 43s
Upload new soundtracks through the API
A client starts an upload with the soundtrack's name, sends one MP3 per
request, and finishes it. The server then moves the songs into a new folder
in the music folder and syncs only that folder, so the soundtrack is in the
library, lists and search right away.

- POST /api/v1/soundtracks/uploads, GET/DELETE .../uploads/{id},
  PUT .../uploads/{id}/files/{file} and POST .../uploads/{id}/finish.
- Names must work as file names on every system and be free, ignoring case.
  File names are plain: clients flatten disc folders.
- Only MP3 files. Size limits from UPLOAD_MAX_FILE_MB (50) and
  UPLOAD_MAX_TOTAL_MB (2048).
- Refused while a sync runs, and a sync cannot start while an upload is
  finished. Songs equal to an existing soundtrack's are refused, as the sync
  would take them for that soundtrack renamed.
- Unfinished uploads live in MUSIC_PATH/.uploads and are removed after a day.
  The sync now skips every hidden folder.

Closes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:26 +02:00

107 lines
3.4 KiB
Go

package server
import (
"encoding/json"
"net/http"
"music-server/cmd/web"
"music-server/internal/logging"
"music-server/internal/metrics"
"music-server/internal/server/middleware"
"github.com/a-h/templ"
"github.com/danielgtaylor/huma/v2"
"github.com/labstack/echo/v5"
echoMiddleware "github.com/labstack/echo/v5/middleware"
"go.uber.org/zap"
)
func (s *Server) RegisterRoutes() http.Handler {
e, _ := s.routes()
return e
}
// OpenAPISpec returns the API description. It needs no database or configuration:
// the handlers are registered but never called.
func OpenAPISpec() *huma.OpenAPI {
_, a := (&Server{}).routes()
return a.OpenAPI()
}
func (s *Server) routes() (*echo.Echo, huma.API) {
e := echo.New()
e.HTTPErrorHandler = httpErrorHandler
if len(s.cfg.TrustedProxies) > 0 {
var trust []echo.TrustOption
for _, ipNet := range s.cfg.TrustedProxies {
trust = append(trust, echo.TrustIPRange(ipNet))
}
e.IPExtractor = echo.ExtractIPFromXFFHeader(trust...)
}
e.Use(logging.RequestLogger())
// Outside Recover, so requests that panic are counted with status 500.
e.Use(metrics.Middleware())
e.Use(echoMiddleware.Recover())
// Without allowed origins no CORS headers are sent, so only same-origin browser requests work.
// The API uses Bearer tokens, not cookies, so credentials are not allowed.
if len(s.cfg.CORSAllowedOrigins) > 0 {
e.Use(echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{
AllowOrigins: s.cfg.CORSAllowedOrigins,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Accept", "Authorization", "Cache-Control", "Content-Type", "Range", middleware.QueueHeader},
ExposeHeaders: []string{middleware.QueueHeader, "Content-Range", "Accept-Ranges"},
MaxAge: 300,
}))
}
// The API: /api/v1 is documented at /docs and /openapi.json.
a := newAPI(e)
a.UseMiddleware(middleware.ClientInfo, middleware.TokenAuth(a, s.q, s.cfg.DisableSessionAuth))
s.registerToken(a)
s.registerMisc(a)
s.registerSync(a)
s.registerMusic(a)
s.registerLibrary(a)
s.registerCovers(a)
s.registerUploads(a)
s.registerStatistics(a)
// Older links to the spec and docs.
e.GET("/openapi", redirect("/openapi.json"))
e.GET("/swagger/*", redirect("/docs"))
// Search page and frontend.
e.GET("/assets/*", echo.WrapHandler(http.FileServer(http.FS(web.Assets))))
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(web.NewSearchHandler(s.search)))
e.GET("/config.js", s.frontendConfig)
e.Static("/", s.cfg.FrontendPath)
for _, r := range e.Router().Routes() {
logging.GetLogger().Debug("Registered route", zap.String("method", r.Method), zap.String("path", r.Path))
}
return e, a
}
// frontendConfig serves the runtime config of the bundled frontend. The frontend uses
// the API password to get a token, so anyone who can open the frontend can read it.
func (s *Server) frontendConfig(c *echo.Context) error {
cfg, err := json.Marshal(map[string]string{
// Empty: the frontend calls this server with relative paths.
"API_HOSTNAME": "",
"API_PASSWORD": s.cfg.APIPassword,
})
if err != nil {
return err
}
c.Response().Header().Set("Cache-Control", "no-store")
return c.Blob(http.StatusOK, "text/javascript; charset=utf-8", []byte("window.__RUNTIME_CONFIG__ = "+string(cfg)+";\n"))
}
func redirect(to string) echo.HandlerFunc {
return func(c *echo.Context) error {
return c.Redirect(http.StatusMovedPermanently, to)
}
}