Files
pipu-music/services/api/internal/library/scanner_test.go
T
mrleo1nidandClaude Opus 5.5 c583991d14
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
feat(api): library sources, scanner and catalog (M2)
- 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>
2026-10-20 06:48:37 +03:00

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