Upload new soundtracks through the API
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

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:
SansanandClaude Opus 5.5 committed 2026-10-15 21:38:26 +02:00
1 parent 439ffede18
commit a7e2280969
16 files changed
+1135 -5

No files matched your search

+41 -3
View File
@@ -35,8 +35,15 @@ const (
brokenWorkers = 50
)
// foldersToSkip are directories in the music path that are never synced.
var foldersToSkip = map[string]struct{}{".sync": {}, "characters": {}, "dist": {}, "old": {}}
// foldersToSkip are directories in the music path that are never synced, besides hidden
// ones like .sync and .uploads.
var foldersToSkip = map[string]struct{}{"characters": {}, "dist": {}, "old": {}}
// skipFolder reports whether a directory in the music path is not a soundtrack.
func skipFolder(name string) bool {
_, skip := foldersToSkip[name]
return skip || strings.HasPrefix(name, ".")
}
type SyncResponse struct {
SoundtracksAdded []string `json:"soundtracks_added"`
@@ -117,6 +124,37 @@ func (s *SyncService) Start(full bool) bool {
return true
}
// SyncFolder runs prepare and then syncs the soundtrack folder name, while no other sync
// can run. It returns ErrSyncing when a sync is running. prepare can put the folder in
// place; when it fails, nothing is synced. onComplete is not called, as only one
// soundtrack changes.
func (s *SyncService) SyncFolder(ctx context.Context, name string, prepare func() error) (repository.Soundtrack, error) {
if !s.running.CompareAndSwap(false, true) {
return repository.Soundtrack{}, ErrSyncing
}
defer s.running.Store(false)
existing, err := s.q.GetAllSoundtracksIncludingDeleted(ctx)
if err != nil {
return repository.Soundtrack{}, err
}
if err := prepare(); err != nil {
return repository.Soundtrack{}, err
}
run := &syncRun{q: s.q, musicPath: s.musicPath, existing: existing, progress: func() {}}
run.syncSoundtrack(ctx, name)
if len(run.errs) > 0 {
return repository.Soundtrack{}, fmt.Errorf("syncing %q: %s", name, strings.Join(run.errs, "; "))
}
if len(run.foundIDs) != 1 {
return repository.Soundtrack{}, fmt.Errorf("syncing %q found %d soundtracks", name, len(run.foundIDs))
}
if err := s.UpdateLibraryMetrics(ctx); err != nil {
logging.GetLogger().Error("Failed to count the library", zap.Error(err))
}
return s.q.GetSoundtrackById(ctx, run.foundIDs[0])
}
// Wait blocks until the running sync, if any, has finished.
func (s *SyncService) Wait() {
s.wg.Wait()
@@ -276,7 +314,7 @@ func (r *syncRun) execute(ctx context.Context, setTotal func(int)) SyncResponse
for _, dir := range directories {
g.Go(func() error {
defer r.progress()
if _, skip := foldersToSkip[dir.Name()]; dir.IsDir() && !skip {
if dir.IsDir() && !skipFolder(dir.Name()) {
r.syncSoundtrack(ctx, dir.Name())
}
return nil