feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
- schema for sources, path patterns, media files, artists, albums, tracks, genres (migration 00003) - path patterns (RE2 with named groups, skip), merge with tags by source priority, fallbacks from the file name and folder, name normalization and multi-artist split - scanner: walk, skip unchanged files by size and mtime, ffprobe in 8 workers with a single catalog writer, renames by content hash keep the track, duplicates fold into one track with the best file as primary, missing files are kept 30 days - storage-agent mounts NFS with validated server, export and an option whitelist, no shell; test mount counts audio files - admin API: sources CRUD, connection test, patterns with preview, scan, library status, file errors, host folder picker; scheduled scans and health checks; mounts are restored on start - catalog API: artist, artist albums, album with tracks, track - api image is alpine with ffmpeg for ffprobe Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab092def54
commit
c583991d14
24 files changed
+7467
-84
No files matched your search
@@ -0,0 +1,99 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMatchRenames(t *testing.T) {
|
||||
a := FileRow{ID: uuid.New(), RelPath: "old/a.flac", Size: 10, ContentHash: []byte{1}}
|
||||
b := FileRow{ID: uuid.New(), RelPath: "old/b.flac", Size: 10, ContentHash: []byte{2}}
|
||||
noHash := FileRow{ID: uuid.New(), RelPath: "old/c.flac", Size: 10}
|
||||
newFiles := []walked{
|
||||
{rel: "new/a.flac", size: 10, hash: []byte{1}},
|
||||
{rel: "new/a-copy.flac", size: 10, hash: []byte{1}}, // второй файл с тем же содержимым — новый
|
||||
{rel: "new/x.flac", size: 11, hash: []byte{2}}, // другой размер — не переименование
|
||||
{rel: "new/y.flac", size: 10},
|
||||
}
|
||||
moves, rest, gone := matchRenames(newFiles, []FileRow{a, b, noHash})
|
||||
|
||||
require.Len(t, moves, 1)
|
||||
assert.Equal(t, a.ID, moves[0].from.ID)
|
||||
assert.Equal(t, "new/a.flac", moves[0].to.rel)
|
||||
assert.Equal(t, []string{"new/a-copy.flac", "new/x.flac", "new/y.flac"}, rels(rest))
|
||||
assert.Equal(t, []uuid.UUID{b.ID, noHash.ID}, []uuid.UUID{gone[0].ID, gone[1].ID})
|
||||
}
|
||||
|
||||
func rels(ws []walked) []string {
|
||||
var out []string
|
||||
for _, w := range ws {
|
||||
out = append(out, w.rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestWalkAndHash(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(rel string, size int) {
|
||||
p := filepath.Join(root, filepath.FromSlash(rel))
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
|
||||
data := make([]byte, size)
|
||||
for i := range data {
|
||||
data[i] = byte(i)
|
||||
}
|
||||
require.NoError(t, os.WriteFile(p, data, 0o644))
|
||||
}
|
||||
write("Artist/Album/01 - Song.FLAC", 100)
|
||||
write("Artist/Album/cover.jpg", 10)
|
||||
write("Artist/.hidden/02.mp3", 10)
|
||||
write("_incoming/new.mp3", 10)
|
||||
write("big.mp3", 3<<20)
|
||||
|
||||
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" })
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
|
||||
for _, w := range found {
|
||||
assert.Equal(t, w.mtime, w.mtime.Truncate(time.Microsecond))
|
||||
}
|
||||
|
||||
h1, err := contentHash(filepath.Join(root, "big.mp3"), 3<<20)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Rename(filepath.Join(root, "big.mp3"), filepath.Join(root, "moved.mp3")))
|
||||
h2, err := contentHash(filepath.Join(root, "moved.mp3"), 3<<20)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, h1, h2, "hash survives a rename")
|
||||
|
||||
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false })
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestParseFFprobe(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "mjpeg"},
|
||||
{"codec_type": "audio", "codec_name": "opus", "sample_rate": "48000", "channels": 2,
|
||||
"tags": {"TITLE": "Song", "ARTIST": "Band feat. Guest", "ALBUM": "Record", "DATE": "2020", "track": "3/10"}}
|
||||
],
|
||||
"format": {"duration": "215.004", "bit_rate": "128000", "tags": {"album_artist": "Band"}}
|
||||
}`)
|
||||
p, err := parseFFprobe(raw, "/x/Song.OPUS")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "opus", p.Format)
|
||||
assert.Equal(t, "opus", p.Codec)
|
||||
assert.Equal(t, 215004, p.DurationMs)
|
||||
assert.Equal(t, 128000, p.Bitrate)
|
||||
assert.Equal(t, 48000, p.SampleRate)
|
||||
assert.Equal(t, "Song", p.Tags.Title)
|
||||
assert.Equal(t, "Band", p.Tags.AlbumArtist)
|
||||
assert.Equal(t, "3/10", p.Tags.Track)
|
||||
assert.False(t, p.Lossless())
|
||||
|
||||
_, err = parseFFprobe([]byte(`{"streams": [], "format": {}}`), "x.mp3")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
Reference in new issue
Block a user