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>
100 lines
3.4 KiB
Go
100 lines
3.4 KiB
Go
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)
|
|
}
|