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>
121 lines
4.6 KiB
Go
121 lines
4.6 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"music-server/internal/backend"
|
|
|
|
"github.com/danielgtaylor/huma/v2"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// UploadRequest is the body of POST /soundtracks/uploads.
|
|
type UploadRequest struct {
|
|
Name string `json:"name" minLength:"1" maxLength:"200" doc:"Name of the new soundtrack, also its folder name in the music folder"`
|
|
}
|
|
|
|
type startUploadInput struct {
|
|
Body UploadRequest
|
|
}
|
|
|
|
type uploadIDInput struct {
|
|
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
|
|
}
|
|
|
|
func (in *uploadIDInput) id() uuid.UUID {
|
|
return uuid.MustParse(in.ID)
|
|
}
|
|
|
|
type uploadFileInput struct {
|
|
ID string `path:"id" format:"uuid" doc:"ID of the upload, from POST /soundtracks/uploads"`
|
|
File string `path:"file" doc:"File name of the song, e.g. \"01 Title.mp3\". Plain names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"."`
|
|
RawBody []byte `contentType:"audio/mpeg" doc:"The MP3 file"`
|
|
}
|
|
|
|
type createdUpload struct {
|
|
Status int
|
|
Body backend.Upload
|
|
}
|
|
|
|
func (s *Server) registerUploads(a huma.API) {
|
|
u := s.uploads
|
|
op := func(id, method, path, summary, description string, errs ...int) route {
|
|
return route{
|
|
op: huma.Operation{
|
|
OperationID: id,
|
|
Method: method,
|
|
Path: path,
|
|
Summary: summary,
|
|
Description: description,
|
|
Tags: []string{"uploads"},
|
|
Errors: errs,
|
|
},
|
|
// Uploads and syncs never run at the same time.
|
|
locked: true,
|
|
}
|
|
}
|
|
|
|
start := op("start-upload", http.MethodPost, "/soundtracks/uploads", "Start uploading a soundtrack",
|
|
"Starts adding a new soundtrack. Send its songs with PUT /soundtracks/uploads/{id}/files/{file}, then "+
|
|
"POST /soundtracks/uploads/{id}/finish. The name must be usable as a folder name, and no soundtrack, "+
|
|
"folder or other upload may have it, ignoring case. Unfinished uploads are removed after a day.",
|
|
http.StatusBadRequest, http.StatusConflict, http.StatusServiceUnavailable)
|
|
start.op.DefaultStatus = http.StatusCreated
|
|
register(s, a, start, func(ctx context.Context, in *startUploadInput) (*createdUpload, error) {
|
|
upload, err := u.Start(ctx, in.Body.Name)
|
|
if err != nil {
|
|
return nil, apiError(err)
|
|
}
|
|
return &createdUpload{Status: http.StatusCreated, Body: upload}, nil
|
|
})
|
|
|
|
register(s, a, op("get-upload", http.MethodGet, "/soundtracks/uploads/{id}", "Get an upload",
|
|
"Returns an unfinished upload with the songs received so far, for example to resume it.", http.StatusNotFound),
|
|
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.Upload], error) {
|
|
return ok(u.Get(in.id()))
|
|
})
|
|
|
|
file := op("upload-song", http.MethodPut, "/soundtracks/uploads/{id}/files/{file}", "Upload a song",
|
|
"Stores one song of the upload, replacing a song of the same name, so a failed song can be sent again. "+
|
|
"Only MP3 files are accepted. The size limits are set on the server with UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB.",
|
|
http.StatusBadRequest, http.StatusNotFound, http.StatusRequestEntityTooLarge)
|
|
file.op.MaxBodyBytes = s.cfg.UploadMaxFileSize
|
|
register(s, a, file, func(ctx context.Context, in *uploadFileInput) (*jsonBody[backend.UploadFile], error) {
|
|
id, err := uuid.Parse(in.ID)
|
|
if err != nil {
|
|
return nil, huma.Error422UnprocessableEntity("invalid upload ID")
|
|
}
|
|
// With an encoded slash (%2F) in the path, Echo leaves the file name encoded.
|
|
if decoded, err := url.PathUnescape(in.File); err == nil && strings.ContainsAny(decoded, `/\`) {
|
|
return nil, huma.Error400BadRequest("plain file names only: put songs from disc folders in one folder, e.g. \"1-01 Title.mp3\"")
|
|
}
|
|
return ok(u.PutFile(id, in.File, in.RawBody))
|
|
})
|
|
|
|
register(s, a, op("finish-upload", http.MethodPost, "/soundtracks/uploads/{id}/finish", "Finish an upload",
|
|
"Moves the songs into a new folder in the music folder, adds the soundtrack to the library and returns it. "+
|
|
"Refused when a soundtrack with the same songs exists.",
|
|
http.StatusBadRequest, http.StatusNotFound, http.StatusConflict),
|
|
func(ctx context.Context, in *uploadIDInput) (*jsonBody[backend.SoundtrackWithSongList], error) {
|
|
soundtrack, err := u.Finish(ctx, in.id())
|
|
if err != nil {
|
|
return nil, apiError(err)
|
|
}
|
|
s.soundtracksChanged(ctx)
|
|
return ok(soundtrack, nil)
|
|
})
|
|
|
|
cancel := op("cancel-upload", http.MethodDelete, "/soundtracks/uploads/{id}", "Cancel an upload",
|
|
"Deletes an unfinished upload and its songs.", http.StatusNotFound)
|
|
cancel.op.DefaultStatus = http.StatusNoContent
|
|
register(s, a, cancel, func(ctx context.Context, in *uploadIDInput) (*struct{}, error) {
|
|
if err := u.Cancel(in.id()); err != nil {
|
|
return nil, apiError(err)
|
|
}
|
|
return nil, nil
|
|
})
|
|
}
|