Files
pipu-music/services/api/internal/library/meta/template_test.go
T
mrleo1nidandClaude Opus 5.5 f66be2ba0a
ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
- storage-agent: POST /internal/files copies a staged file into a source
  (temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
  " (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
  POST /internal/files/test. NFS sources that accept uploads are mounted
  rw in an agent-only /library-rw and bind-mounted read-only for others;
  local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
  sanitising and length limits, suggestion from the source's regexes
  and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
  write-template suggest and check endpoints, write test result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:56 +03:00

96 lines
4.2 KiB
Go

package meta
import (
"strings"
"testing"
)
const docTemplate = "{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}"
func in(f Fields, discs int, ext string) TemplateInput {
return TemplateInput{Fields: f, DiscCount: discs, Ext: ext}
}
func TestRenderTemplate(t *testing.T) {
tpl, err := ParseTemplate(docTemplate)
if err != nil {
t.Fatal(err)
}
f := Fields{Artist: "Кино", Album: "Группа крови", Year: "1988-01-05", Track: "3/11", Disc: "1", Title: "Война"}
cases := []struct {
name string
input TemplateInput
want string
}{
{"single disc", in(f, 1, "FLAC"), "Кино/1988 - Группа крови/03 - Война.flac"},
{"multi disc", in(f, 2, "mp3"), "Кино/1988 - Группа крови/1-03 - Война.mp3"},
{"forbidden chars and trailing dots", in(Fields{Artist: "AC/DC", Album: "What?...", Year: "1980", Track: "1", Title: ".hidden: x"}, 1, "mp3"),
"AC_DC/1980 - What_/01 - .hidden_ x.mp3"},
}
for _, c := range cases {
got, err := tpl.Render(c.input)
if err != nil || got != c.want {
t.Errorf("%s: got %q, %v; want %q", c.name, got, err, c.want)
}
}
if _, err := tpl.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: "C"}, 1, "mp3")); err == nil {
t.Error("missing year outside [ ] must fail")
}
opt, _ := ParseTemplate("{albumartist}/[{year} - ]{album}/{track:02} - {title}.{ext}")
if got, _ := opt.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: "C"}, 1, "mp3")); got != "A/B/01 - C.mp3" {
t.Errorf("optional part: %q", got)
}
long, _ := opt.Render(in(Fields{Artist: "A", Album: "B", Track: "1", Title: strings.Repeat("я", 300)}, 1, "flac"))
if seg := long[strings.LastIndex(long, "/")+1:]; len(seg) > MaxSegmentBytes || !strings.HasSuffix(seg, ".flac") {
t.Errorf("long title segment: %d bytes %q", len(seg), seg[len(seg)-10:])
}
}
func TestParseTemplateErrors(t *testing.T) {
for _, bad := range []string{"", "/{artist}/{title}.{ext}", "{artist}/{title}.mp3", "{artist}/{nope}/{title}.{ext}",
"{artist}/../{title}.{ext}", "[{artist}/{title}.{ext}", "{artist}/{year:02}/{title}.{ext}", "{artist}//{title}.{ext}"} {
if _, err := ParseTemplate(bad); err == nil {
t.Errorf("%q must be rejected", bad)
}
}
}
// Шаблоны из docs/library-and-storage.md превращаются в шаблоны записи и проходят проверку обратимости.
func TestSuggestAndCheck(t *testing.T) {
exprs := []string{
`^(?P<skip>_incoming)/`,
`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
}
paths := []string{"Кино/1988 - Группа крови/01 - Группа крови.flac", "Кино/1988 - Группа крови/02 - Закрой за мной дверь.flac",
"Rock/Queen/Jazz (1978)/3. Jealousy.mp3"}
got, err := SuggestTemplate(exprs, paths)
if err != nil {
t.Fatal(err)
}
if got != "{artist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}" {
t.Fatalf("suggested %q", got)
}
tpl, _ := ParseTemplate(got)
patterns, _ := CompilePatterns(exprs)
samples := []TemplateSample{
{RelPath: paths[0], Input: in(Fields{Artist: "Кино", Year: "1988", Album: "Группа крови", Track: "1", Title: "Группа крови"}, 1, "flac")},
{RelPath: paths[1], Input: in(Fields{Artist: "Кино", Year: "1988", Album: "Группа крови", Track: "2", Title: "Закрой за мной дверь"}, 1, "flac")},
{RelPath: "Pink Floyd/1979 - The Wall/2-01 - Hey You.flac",
Input: in(Fields{Artist: "Pink Floyd", Year: "1979", Album: "The Wall", Disc: "2", Track: "1", Title: "Hey You"}, 2, "flac")},
}
res := CheckTemplate(tpl, patterns, samples)
if !res.OK || res.RoundTrip != 3 || res.SamePath != 3 {
t.Fatalf("check: %+v", res)
}
// Шаблон, который регэкспы источника не разберут обратно.
bad, _ := ParseTemplate("{artist} - {album}/{title}.{ext}")
res = CheckTemplate(bad, patterns, samples)
if res.OK || res.RoundTrip != 0 || len(res.Mismatches) != 3 || res.Mismatches[0].Fields[0] != "pattern" {
t.Fatalf("bad template check: %+v", res)
}
}