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 }) }