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>
This commit is contained in:
1 parent
439ffede18
commit
a7e2280969
16 files changed
+1135
-5
No files matched your search
@@ -0,0 +1,270 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/testutil/fakedb"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mp3 returns data that starts like an MP3 file, made unique by content.
|
||||
func mp3(content string) []byte {
|
||||
return append([]byte("ID3"), content...)
|
||||
}
|
||||
|
||||
type uploadFixture struct {
|
||||
t *testing.T
|
||||
musicDir string
|
||||
store *fakedb.Store
|
||||
sync *SyncService
|
||||
uploads *UploadService
|
||||
}
|
||||
|
||||
func newUploadFixture(t *testing.T) *uploadFixture {
|
||||
musicDir := t.TempDir()
|
||||
store := fakedb.New()
|
||||
syncService := NewSyncService(store, musicDir, nil)
|
||||
return &uploadFixture{
|
||||
t: t, musicDir: musicDir, store: store, sync: syncService,
|
||||
uploads: NewUploadService(store, musicDir, syncService, NewLibraryService(store), 1<<20, 3<<20),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *uploadFixture) start(name string) Upload {
|
||||
f.t.Helper()
|
||||
upload, err := f.uploads.Start(context.Background(), name)
|
||||
require.NoError(f.t, err)
|
||||
return upload
|
||||
}
|
||||
|
||||
func (f *uploadFixture) put(id uuid.UUID, fileName, content string) {
|
||||
f.t.Helper()
|
||||
_, err := f.uploads.PutFile(id, fileName, mp3(content))
|
||||
require.NoError(f.t, err)
|
||||
}
|
||||
|
||||
func TestUploadSoundtrack(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
upload := f.start(" Chrono Trigger ")
|
||||
assert.Equal(t, "Chrono Trigger", upload.Name, "the name is trimmed")
|
||||
f.put(upload.ID, "02 Corridors of Time.mp3", "b")
|
||||
f.put(upload.ID, "01 Wind Scene.MP3", "a")
|
||||
f.put(upload.ID, "02 Corridors of Time.mp3", "b2") // sent again
|
||||
|
||||
got, err := f.uploads.Get(upload.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []UploadFile{{Name: "01 Wind Scene.MP3", Size: 4}, {Name: "02 Corridors of Time.mp3", Size: 5}}, got.Files)
|
||||
assert.Equal(t, int64(9), got.TotalSize)
|
||||
_, err = f.store.GetSoundtrackById(ctx, 1)
|
||||
assert.Error(t, err, "nothing is in the library before finishing")
|
||||
|
||||
soundtrack, err := f.uploads.Finish(ctx, upload.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Chrono Trigger", soundtrack.Name)
|
||||
require.Len(t, soundtrack.Songs, 2)
|
||||
assert.Equal(t, "01 Wind Scene", soundtrack.Songs[0].Name)
|
||||
assert.FileExists(t, filepath.Join(f.musicDir, "Chrono Trigger", "01 Wind Scene.MP3"))
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(f.musicDir, uploadsFolder))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, entries, "nothing is left of the upload")
|
||||
_, err = f.uploads.Get(upload.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// The next sync sees nothing new.
|
||||
require.True(t, f.sync.Start(false))
|
||||
f.sync.Wait()
|
||||
result := f.sync.Result()
|
||||
assert.Empty(t, result.SoundtracksAdded)
|
||||
assert.Empty(t, result.CatchedErrors)
|
||||
}
|
||||
|
||||
func TestUploadNames(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
f.store.AddSoundtrack(repository.Soundtrack{SoundtrackName: "Kingdom Hearts"})
|
||||
writeFile(t, filepath.Join(f.musicDir, "Folder Only", "x.txt"), "x")
|
||||
f.start("Pending")
|
||||
|
||||
for name, want := range map[string]error{
|
||||
"": ErrInvalidName,
|
||||
" ": ErrInvalidName,
|
||||
"a/b": ErrInvalidName,
|
||||
`a\b`: ErrInvalidName,
|
||||
"..": ErrInvalidName,
|
||||
".hidden": ErrInvalidName,
|
||||
"ends with dot.": ErrInvalidName,
|
||||
"what?": ErrInvalidName,
|
||||
"tab\tname": ErrInvalidName,
|
||||
"characters": ErrInvalidName,
|
||||
strings.Repeat("a", 201): ErrInvalidName,
|
||||
"kingdom hearts": ErrConflict,
|
||||
"FOLDER ONLY": ErrConflict,
|
||||
"pending": ErrConflict,
|
||||
} {
|
||||
_, err := f.uploads.Start(ctx, name)
|
||||
assert.ErrorIs(t, err, want, "%q", name)
|
||||
}
|
||||
_, err := f.uploads.Start(ctx, "Ys: Origin")
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "':' is not allowed on Windows")
|
||||
_, err = f.uploads.Start(ctx, "Pokémon HeartGold & SoulSilver (DS)")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadFileChecks(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
upload := f.start("Game")
|
||||
|
||||
for fileName, want := range map[string]error{
|
||||
"song.wav": ErrInvalidName,
|
||||
"song": ErrInvalidName,
|
||||
"Disc 1/01.mp3": ErrInvalidName,
|
||||
"../escape.mp3": ErrInvalidName,
|
||||
".hidden.mp3": ErrInvalidName,
|
||||
} {
|
||||
_, err := f.uploads.PutFile(upload.ID, fileName, mp3("x"))
|
||||
assert.ErrorIs(t, err, want, fileName)
|
||||
}
|
||||
_, err := f.uploads.PutFile(upload.ID, "text.mp3", []byte("just text"))
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "not an MP3 file")
|
||||
_, err = f.uploads.PutFile(upload.ID, "frame.mp3", []byte{0xFF, 0xFB, 0x90, 0x00})
|
||||
assert.NoError(t, err, "an MP3 without an ID3 tag starts with a frame")
|
||||
_, err = f.uploads.PutFile(uuid.New(), "song.mp3", mp3("x"))
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
_, err = f.uploads.PutFile(upload.ID, "big.mp3", mp3(strings.Repeat("x", 1<<20)))
|
||||
assert.ErrorIs(t, err, ErrTooLarge, "over the size of one song")
|
||||
for i := range 3 {
|
||||
f.put(upload.ID, string(rune('a'+i))+".mp3", strings.Repeat("x", 1<<20-10))
|
||||
}
|
||||
_, err = f.uploads.PutFile(upload.ID, "d.mp3", mp3(strings.Repeat("x", 1000)))
|
||||
assert.ErrorIs(t, err, ErrTooLarge, "over the size of the whole upload")
|
||||
_, err = f.uploads.PutFile(upload.ID, "a.mp3", mp3("small"))
|
||||
assert.NoError(t, err, "replacing a song counts its new size only")
|
||||
}
|
||||
|
||||
func TestUploadFinishChecks(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
ctx := context.Background()
|
||||
|
||||
empty := f.start("Empty")
|
||||
_, err := f.uploads.Finish(ctx, empty.ID)
|
||||
assert.ErrorIs(t, err, ErrInvalidName, "an upload without songs")
|
||||
_, err = f.uploads.Finish(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// A folder of the same name appeared meanwhile, e.g. from ResilioSync.
|
||||
clash := f.start("Clash")
|
||||
f.put(clash.ID, "01.mp3", "c")
|
||||
writeFile(t, filepath.Join(f.musicDir, "Clash", "01.mp3"), "other")
|
||||
_, err = f.uploads.Finish(ctx, clash.ID)
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
assert.FileExists(t, filepath.Join(f.musicDir, uploadsFolder, clash.ID.String(), "01.mp3"), "the upload is kept")
|
||||
|
||||
// The same songs as a soundtrack that is already in the library.
|
||||
original := f.start("Original")
|
||||
f.put(original.ID, "01.mp3", "same")
|
||||
_, err = f.uploads.Finish(ctx, original.ID)
|
||||
require.NoError(t, err)
|
||||
duplicate := f.start("Copy")
|
||||
f.put(duplicate.ID, "01.mp3", "same")
|
||||
_, err = f.uploads.Finish(ctx, duplicate.ID)
|
||||
assert.ErrorIs(t, err, ErrConflict)
|
||||
assert.Contains(t, err.Error(), "Original")
|
||||
assert.NoDirExists(t, filepath.Join(f.musicDir, "Copy"))
|
||||
}
|
||||
|
||||
func TestUploadWhileSyncing(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
release := make(chan struct{})
|
||||
f.sync = NewSyncService(f.store, f.musicDir, func(context.Context) { <-release })
|
||||
f.uploads.sync = f.sync
|
||||
upload := f.start("Game")
|
||||
f.put(upload.ID, "01.mp3", "a")
|
||||
require.True(t, f.sync.Start(false))
|
||||
|
||||
_, err := f.uploads.Start(context.Background(), "Other")
|
||||
assert.ErrorIs(t, err, ErrSyncing)
|
||||
_, err = f.uploads.Finish(context.Background(), upload.ID)
|
||||
assert.ErrorIs(t, err, ErrSyncing)
|
||||
assert.NoDirExists(t, filepath.Join(f.musicDir, "Game"), "nothing was moved")
|
||||
|
||||
close(release)
|
||||
f.sync.Wait()
|
||||
_, err = f.uploads.Finish(context.Background(), upload.ID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUploadFinishBlocksASync(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
upload := f.start("Game")
|
||||
f.put(upload.ID, "01.mp3", "a")
|
||||
started := make(chan bool, 1)
|
||||
|
||||
_, err := f.sync.SyncFolder(context.Background(), "Game", func() error {
|
||||
started <- f.sync.Start(false)
|
||||
return os.Rename(f.uploads.dir(upload.ID), filepath.Join(f.musicDir, "Game"))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, <-started, "a sync cannot start while a folder is being added")
|
||||
}
|
||||
|
||||
func TestUploadCancelAndCleanup(t *testing.T) {
|
||||
f := newUploadFixture(t)
|
||||
cancelled := f.start("Cancelled")
|
||||
f.put(cancelled.ID, "01.mp3", "a")
|
||||
require.NoError(t, f.uploads.Cancel(cancelled.ID))
|
||||
assert.NoDirExists(t, f.uploads.dir(cancelled.ID))
|
||||
assert.ErrorIs(t, f.uploads.Cancel(cancelled.ID), ErrNotFound)
|
||||
|
||||
old := f.start("Old")
|
||||
f.put(old.ID, "01.mp3", "a")
|
||||
recent := f.start("Recent")
|
||||
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge - time.Minute) }
|
||||
f.uploads.RemoveOld()
|
||||
_, err := f.uploads.Get(old.ID)
|
||||
assert.NoError(t, err, "not a day old yet")
|
||||
|
||||
f.uploads.now = func() time.Time { return time.Now().Add(uploadMaxAge + time.Minute) }
|
||||
f.uploads.RemoveOld()
|
||||
_, err = f.uploads.Get(old.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
_, err = f.uploads.Get(recent.ID)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// A new upload of the same name works after the old one is gone.
|
||||
f.uploads.now = time.Now
|
||||
f.start("Old")
|
||||
}
|
||||
|
||||
func TestSyncSkipsUploadsAndHiddenFolders(t *testing.T) {
|
||||
f := newSyncFixture(t)
|
||||
f.write("Game A/01.mp3", "a")
|
||||
f.write(".uploads/123/01.mp3", "b")
|
||||
f.write(".sync/x/01.mp3", "c")
|
||||
result := f.run(false)
|
||||
assert.Equal(t, []string{"Game A"}, result.SoundtracksAdded)
|
||||
}
|
||||
|
||||
func TestUploadsUnavailableWhenMusicFolderIsReadOnly(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("root can write to read-only folders")
|
||||
}
|
||||
f := newUploadFixture(t)
|
||||
require.NoError(t, os.Chmod(f.musicDir, 0o555))
|
||||
t.Cleanup(func() { os.Chmod(f.musicDir, 0o755) })
|
||||
|
||||
_, err := f.uploads.Start(context.Background(), "Game")
|
||||
assert.ErrorIs(t, err, ErrUploadsUnavailable)
|
||||
}
|
||||
Reference in new issue
Block a user