Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title) library. The new Fixer tab takes the master, then the slave, through four reviewed steps: folder structure, album tags (optional MusicBrainz lookup; the slave reuses the fixed master's tags), folder names and file names, then links the two libraries. Every fix is an undoable operation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
16 KiB
Go
385 lines
16 KiB
Go
package engine
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/cdnutter/playlink/internal/meta"
|
|
"github.com/cdnutter/playlink/internal/store"
|
|
"github.com/cdnutter/playlink/internal/tags"
|
|
)
|
|
|
|
// fixCheck runs a check and returns its plan and items.
|
|
func (ev *env) fixCheck(t *testing.T, c FixCheck) (*FixPlan, []FixItem, []*AlbumFix) {
|
|
t.Helper()
|
|
if _, err := ev.e.StartFixCheck(ev.pair.ID, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ev.wait()
|
|
p, items, albums := ev.e.FixerPlan(ev.pair.ID)
|
|
if p == nil {
|
|
t.Fatal("no plan")
|
|
}
|
|
return p, items, albums
|
|
}
|
|
|
|
// fixAll applies every fix that isn't blocked (unsure ones included).
|
|
func (ev *env) fixAll(t *testing.T, items []FixItem) *Job {
|
|
t.Helper()
|
|
var ids []int
|
|
for _, it := range items {
|
|
if it.Blocked == "" && it.Kind != FixProblem {
|
|
ids = append(ids, it.ID)
|
|
}
|
|
}
|
|
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
|
|
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: ids}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ev.wait()
|
|
}
|
|
|
|
func byFrom(items []FixItem) map[string]FixItem {
|
|
m := map[string]FixItem{}
|
|
for _, it := range items {
|
|
if _, ok := m[it.From]; !ok || it.Kind != FixTags {
|
|
m[it.From] = it
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (ev *env) fields(t *testing.T, p string) map[string]string {
|
|
t.Helper()
|
|
f, err := ev.e.tagTools.Read(ctx, p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return f.Fields
|
|
}
|
|
|
|
func TestFixStructureMusic(t *testing.T) {
|
|
ev := tagEnv(t, false)
|
|
s := ev.slave
|
|
mkTagged(t, filepath.Join(s, "Loose.mp3"), "title", "Loose", "artist", "A", "album", "B")
|
|
mkTagged(t, filepath.Join(s, "Nightmare/01 Nightmare.flac"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
os.WriteFile(filepath.Join(s, "Nightmare/cover.jpg"), []byte("art"), 0o644)
|
|
mkTagged(t, filepath.Join(s, "Band/song.mp3"), "title", "Song", "artist", "Band", "album", "Record")
|
|
mkTagged(t, filepath.Join(s, "X/Y/FLAC/01 a.flac"), "title", "a", "artist", "X", "album", "Y")
|
|
mkTagged(t, filepath.Join(s, "Metal/Artist2/Album2/01 s.mp3"), "title", "s", "artist", "Artist2", "album", "Album2")
|
|
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD1/01 t.mp3"), "title", "t", "artist", "Artist3", "album", "Album3", "track", "1")
|
|
mkTagged(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3"), "title", "u", "artist", "Artist3", "album", "Album3", "track", "1")
|
|
mkTagged(t, filepath.Join(s, "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"))
|
|
mkTagged(t, filepath.Join(s, "NoTags.mp3"))
|
|
|
|
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
|
|
m := byFrom(items)
|
|
want := map[string]string{
|
|
"Loose.mp3": "A/B/Loose.mp3",
|
|
"Nightmare": "Avenged Sevenfold/Nightmare",
|
|
"Band/song.mp3": "Band/Record/song.mp3",
|
|
"X/Y/FLAC/01 a.flac": "X/Y/01 a.flac",
|
|
"Metal/Artist2/Album2": "Artist2/Album2",
|
|
"Artist3/Album3/CD2/01 u.mp3": "",
|
|
}
|
|
for from, to := range want {
|
|
it, ok := m[from]
|
|
if !ok || it.To != to || it.Blocked != "" {
|
|
t.Errorf("%s: got %+v, want to %q", from, it, to)
|
|
}
|
|
}
|
|
if it := m["Artist3/Album3/CD2/01 u.mp3"]; it.Changes[tags.Disc][1] != "2" || it.Changes[tags.DiscTotal][1] != "2" {
|
|
t.Errorf("disc tags from the disc folder: %+v", it.Changes)
|
|
}
|
|
fill := m["Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"].Changes
|
|
if fill[tags.Album][1] != "Album4" || fill[tags.Artist][1] != "Artist4" || fill[tags.Title][1] != "Song Name" || fill[tags.Track][1] != "5" {
|
|
t.Errorf("tags from names: %+v", fill)
|
|
}
|
|
if it := m["NoTags.mp3"]; it.Blocked == "" {
|
|
t.Errorf("a loose song without tags can't be placed: %+v", it)
|
|
}
|
|
if plan.Blocked != 1 {
|
|
t.Errorf("blocked %d", plan.Blocked)
|
|
}
|
|
|
|
j := ev.fixAll(t, items)
|
|
if j.Results["skipped"] != 0 {
|
|
t.Fatalf("apply: %+v %v", j.Results, j.Skipped)
|
|
}
|
|
for _, p := range []string{"A/B/Loose.mp3", "Avenged Sevenfold/Nightmare/01 Nightmare.flac", "Avenged Sevenfold/Nightmare/cover.jpg",
|
|
"Band/Record/song.mp3", "X/Y/01 a.flac", "Artist2/Album2/01 s.mp3", "Artist4/Album4 (2010) [FLAC]/05 Song Name.mp3"} {
|
|
if !exists(filepath.Join(s, p)) {
|
|
t.Errorf("missing %s", p)
|
|
}
|
|
}
|
|
for _, p := range []string{"Nightmare", "X/Y/FLAC", "Metal"} {
|
|
if exists(filepath.Join(s, p)) {
|
|
t.Errorf("%s should be gone", p)
|
|
}
|
|
}
|
|
if f := ev.fields(t, filepath.Join(s, "Artist3/Album3/CD2/01 u.mp3")); f[tags.Disc] != "2" || f[tags.DiscTotal] != "2" {
|
|
t.Errorf("disc tags not written: %+v", f)
|
|
}
|
|
// A folder move can be undone.
|
|
ops, _, _ := ev.st.Ops(ctx, store.OpFilter{PairID: ev.pair.ID})
|
|
for _, o := range ops {
|
|
if o.Action == store.ActMoveDir && o.RelNew == "Avenged Sevenfold/Nightmare" {
|
|
if err := ev.e.Undo(ctx, o.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
waitIdle(ev)
|
|
if !exists(filepath.Join(s, "Nightmare/cover.jpg")) || exists(filepath.Join(s, "Avenged Sevenfold")) {
|
|
t.Fatal("undo should move the folder back and remove the empty artist folder")
|
|
}
|
|
// Not everything was fixable, so the step isn't marked done.
|
|
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
|
if FixerStateOf(p).Slave[StepStructure].State != "" {
|
|
t.Fatal("step marked done although a song still needs attention")
|
|
}
|
|
}
|
|
|
|
func waitIdle(ev *env) {
|
|
for i := 0; i < 500; i++ {
|
|
if j := ev.e.Job(ev.pair.ID); j == nil || j.State != "running" {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestFixStructureSoundtracks(t *testing.T) {
|
|
ev := tagEnv(t, false)
|
|
ev.pair.LibraryType = LibrarySoundtracks
|
|
if err := ev.st.SavePair(ctx, &ev.pair); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := ev.slave
|
|
mkTagged(t, filepath.Join(s, "Nobuo Uematsu/Final Fantasy VII/01 Prelude.flac"), "title", "Prelude", "album", "Final Fantasy VII")
|
|
for _, g := range []string{"Halo 3", "Super Mario Galaxy", "Portal 2"} {
|
|
mkTagged(t, filepath.Join(s, "Video Games", g, "01 x.flac"), "title", "x", "album", g)
|
|
}
|
|
mkTagged(t, filepath.Join(s, "Journey/01 Nascence.flac"), "title", "Nascence", "album", "Journey")
|
|
plan, items, _ := ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepStructure})
|
|
m := byFrom(items)
|
|
if it := m["Nobuo Uematsu/Final Fantasy VII"]; it.To != "Final Fantasy VII" || it.Unsure || !strings.Contains(it.Why, "Soundtrack") {
|
|
t.Errorf("composer folder: %+v", it)
|
|
}
|
|
if it := m["Video Games/Halo 3"]; it.To != "Halo 3" || !it.Unsure {
|
|
t.Errorf("a category folder's albums should be listed but not selected: %+v", it)
|
|
}
|
|
if len(plan.Notices) != 1 || !strings.Contains(plan.Notices[0], "Video Games (3 albums)") {
|
|
t.Errorf("notices %v", plan.Notices)
|
|
}
|
|
if _, ok := m["Journey"]; ok {
|
|
t.Error("a soundtrack at the top of the library is fine")
|
|
}
|
|
}
|
|
|
|
func TestFixFoldersAndFiles(t *testing.T) {
|
|
ev := tagEnv(t, false)
|
|
m := ev.master
|
|
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/a.mp3"), "title", "Nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "1")
|
|
mkTagged(t, filepath.Join(m, "avenged sevenfold/nightmare (2010)/b.mp3"), "title", "Welcome to the Family", "artist", "Avenged Sevenfold", "album", "Nightmare", "track", "2")
|
|
mkTagged(t, filepath.Join(m, "Mix/1.mp3"), "title", "One", "artist", "A", "album", "First")
|
|
mkTagged(t, filepath.Join(m, "Mix/2.mp3"), "title", "Two", "artist", "B", "album", "Second")
|
|
_, items, _ := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFolders})
|
|
byF := byFrom(items)
|
|
if it := byF["avenged sevenfold/nightmare (2010)"]; it.Kind != FixMoveFolder || it.To != "Avenged Sevenfold/Nightmare" {
|
|
t.Fatalf("folder rename: %+v", it)
|
|
}
|
|
if it := byF["Mix"]; it.Kind != FixProblem || !strings.Contains(it.Why, "2 different album folders") {
|
|
t.Fatalf("mixed folder: %+v", it)
|
|
}
|
|
ev.fixAll(t, items)
|
|
if !exists(filepath.Join(m, "Avenged Sevenfold/Nightmare/a.mp3")) {
|
|
t.Fatal("folder not renamed")
|
|
}
|
|
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFolders].State != "" {
|
|
t.Fatal("a folder that needs attention keeps the step open")
|
|
}
|
|
_, items, _ = ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepFiles})
|
|
ev.fixAll(t, items)
|
|
if p, _ := ev.st.Pair(ctx, ev.pair.ID); FixerStateOf(p).Master[StepFiles].State != "done" {
|
|
t.Fatal("applying every fix marks the step done")
|
|
}
|
|
for _, p := range []string{"Avenged Sevenfold/Nightmare/01 Nightmare.mp3", "Avenged Sevenfold/Nightmare/02 Welcome to the Family.mp3", "A/First/One.mp3", "B/Second/Two.mp3"} {
|
|
if !exists(filepath.Join(m, p)) {
|
|
t.Errorf("missing %s", p)
|
|
}
|
|
}
|
|
// Applying the same plan twice is refused: paths changed.
|
|
p, _, _ := ev.e.FixerPlan(ev.pair.ID)
|
|
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID, IDs: []int{1}}); err == nil {
|
|
t.Fatal("a stale plan must not be applied again")
|
|
}
|
|
if _, err := ev.e.StartFixApply(ev.pair.ID, FixApply{Plan: p.ID - 1, IDs: []int{1}}); err == nil {
|
|
t.Fatal("a stale plan must not be applied again")
|
|
}
|
|
}
|
|
|
|
// fakeMB serves one release: Avenged Sevenfold - Nightmare, 3 tracks.
|
|
func fakeMB(t *testing.T) *meta.Client {
|
|
const id = "85cb4213-ffc2-38b1-a256-bd6e5ce2e0af"
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, id) {
|
|
fmt.Fprint(w, `{"id":"`+id+`","title":"Nightmare","date":"2010-07-26","status":"Official","artist-credit":[{"name":"Avenged Sevenfold"}],
|
|
"release-group":{"primary-type":"Album"},"media":[{"position":1,"format":"CD","track-count":3,"tracks":[
|
|
{"position":1,"title":"Nightmare","length":374000},{"position":2,"title":"Welcome to the Family","length":245000},
|
|
{"position":3,"title":"Danger Line","length":329000}]}]}`)
|
|
return
|
|
}
|
|
if !strings.Contains(r.URL.Query().Get("query"), "Nightmare") {
|
|
fmt.Fprint(w, `{"releases":[]}`)
|
|
return
|
|
}
|
|
fmt.Fprint(w, `{"releases":[{"id":"`+id+`","score":100,"title":"Nightmare","status":"Official","date":"2010-07-26",
|
|
"track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":3}]}]}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
c := meta.New("test")
|
|
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
|
|
return c
|
|
}
|
|
|
|
func TestFixAlbumTags(t *testing.T) {
|
|
ev := tagEnv(t, false)
|
|
ev.e.SetMeta(fakeMB(t))
|
|
m, s := ev.master, ev.slave
|
|
dir := "Avenged Sevenfold/Nightmare"
|
|
// Titles are slightly off and numbers are missing; one song has no title.
|
|
mkTagged(t, filepath.Join(m, dir, "a.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
mkTagged(t, filepath.Join(m, dir, "b.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
mkTagged(t, filepath.Join(m, dir, "03 Danger Line.mp3"), "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
mkTagged(t, filepath.Join(m, "Nobody/Unknown/x.mp3"), "title", "x", "artist", "Nobody", "album", "Unknown")
|
|
|
|
plan, items, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
|
|
if len(albums) != 2 || plan.Fixable != 1 || plan.Blocked != 1 {
|
|
t.Fatalf("plan %+v albums %d", plan, len(albums))
|
|
}
|
|
var nm *AlbumFix
|
|
for _, a := range albums {
|
|
if a.Folder == dir {
|
|
nm = a
|
|
}
|
|
}
|
|
if nm == nil || nm.Matched != 3 || nm.Release.Title != "Nightmare" || nm.Confidence < 0.9 {
|
|
t.Fatalf("album fix %+v", nm)
|
|
}
|
|
ev.fixAll(t, items)
|
|
f := ev.fields(t, filepath.Join(m, dir, "b.mp3"))
|
|
if f[tags.Title] != "Welcome to the Family" || f[tags.Track] != "2" || f[tags.TrackTotal] != "3" || f[tags.AlbumArtist] != "Avenged Sevenfold" || f[tags.Date] != "2010-07-26" {
|
|
t.Fatalf("tags not updated: %+v", f)
|
|
}
|
|
if f := ev.fields(t, filepath.Join(m, dir, "03 Danger Line.mp3")); f[tags.Title] != "Danger Line" || f[tags.Track] != "3" {
|
|
t.Fatalf("untitled song matched by position: %+v", f)
|
|
}
|
|
|
|
// The slave copy takes its tags from the fixed master, not MusicBrainz.
|
|
mkTagged(t, filepath.Join(s, "A7X/Nightmare/x.mp3"), "title", "Welcome to the family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
_, _, albums = ev.fixCheck(t, FixCheck{Side: SideSlave, Step: StepTags})
|
|
if len(albums) != 1 || albums[0].Source != SourceMaster || albums[0].Files[0].Changes[tags.Track][1] != "2" {
|
|
t.Fatalf("slave from master: %+v", albums)
|
|
}
|
|
// Choosing MusicBrainz instead.
|
|
a, err := ev.e.ChooseRelease(ctx, ev.pair.ID, albums[0].ID, "search")
|
|
if err != nil || a.Source != SourceMusicBrainz || a.Matched != 1 {
|
|
t.Fatalf("switch to MusicBrainz: %+v %v", a, err)
|
|
}
|
|
}
|
|
|
|
func TestFixerStepState(t *testing.T) {
|
|
ev := setup(t, false)
|
|
if err := ev.e.SetStep(ctx, ev.pair.ID, SideMaster, StepTags, "skipped", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ev.e.SetStep(ctx, ev.pair.ID, "", StepLink, "done", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := ev.st.Pair(ctx, ev.pair.ID)
|
|
s := FixerStateOf(p)
|
|
if s.Master[StepTags].State != "skipped" || s.Link.State != "done" || s.Slave[StepTags].State != "" {
|
|
t.Fatalf("%+v", s)
|
|
}
|
|
for _, bad := range [][3]string{{"left", StepTags, "done"}, {SideMaster, "bogus", "done"}, {SideMaster, StepTags, "maybe"}} {
|
|
if ev.e.SetStep(ctx, ev.pair.ID, bad[0], bad[1], bad[2], "") == nil {
|
|
t.Errorf("%v should be refused", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCleanFolderName(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"Nightmare (2010)": "Nightmare",
|
|
"Nightmare [FLAC]": "Nightmare",
|
|
"2010 - Nightmare": "Nightmare",
|
|
"Nightmare (2010) [24-96]": "Nightmare",
|
|
"Hail to the King (Deluxe)": "Hail to the King",
|
|
"(What's the Story) Morning Glory?": "(What's the Story) Morning Glory?",
|
|
"1989": "1989",
|
|
} {
|
|
if got := cleanFolderName(in); got != want {
|
|
t.Errorf("%q: got %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
for in, want := range map[string]int{"CD2": 2, "Disc 01": 1, "disk_3": 3, "Disc 1 - The Beginning": 1, "Vol. 2": 0, "CDs": 0} {
|
|
if got := discNumber(in); got != want {
|
|
t.Errorf("disc %q: got %d, want %d", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A promo single named after the album, with exactly as many tracks as the
|
|
// local (partial) album, must not beat the album itself.
|
|
func TestFixAlbumTagsPrefersTheAlbum(t *testing.T) {
|
|
ev := tagEnv(t, false)
|
|
const promo, album = "11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222"
|
|
tracks := func(titles ...string) string {
|
|
var b []string
|
|
for i, t := range titles {
|
|
b = append(b, fmt.Sprintf(`{"position":%d,"title":%q}`, i+1, t))
|
|
}
|
|
return strings.Join(b, ",")
|
|
}
|
|
release := map[string]string{
|
|
promo: `{"id":"` + promo + `","title":"Nightmare","status":"Promotion","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},
|
|
"media":[{"position":1,"tracks":[` + tracks("Nightmare (album version) (explicit)", "Nightmare (radio edit)", "Nightmare (instrumental)") + `]}]}`,
|
|
album: `{"id":"` + album + `","title":"Nightmare","status":"Official","date":"2010-07-26","artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},
|
|
"media":[{"position":1,"tracks":[` + tracks("Nightmare", "Welcome to the Family", "Danger Line", "Buried Alive", "Natural Born Killer") + `]}]}`,
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ws/2/release/", func(w http.ResponseWriter, r *http.Request) {
|
|
for id, body := range release {
|
|
if strings.HasSuffix(r.URL.Path, id) {
|
|
fmt.Fprint(w, body)
|
|
return
|
|
}
|
|
}
|
|
fmt.Fprint(w, `{"releases":[
|
|
{"id":"`+promo+`","score":100,"title":"Nightmare","status":"Promotion","track-count":3,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Single"},"media":[{"format":"CD-R","track-count":3}]},
|
|
{"id":"`+album+`","score":95,"title":"Nightmare","status":"Official","track-count":5,"artist-credit":[{"name":"Avenged Sevenfold"}],"release-group":{"primary-type":"Album"},"media":[{"format":"CD","track-count":5}]}]}`)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
c := meta.New("test")
|
|
c.HTTP, c.BaseURL, c.Gap = srv.Client(), srv.URL, 0
|
|
ev.e.SetMeta(c)
|
|
dir := filepath.Join(ev.master, "Avenged Sevenfold/Nightmare")
|
|
mkTagged(t, filepath.Join(dir, "01.mp3"), "title", "nightmare", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
mkTagged(t, filepath.Join(dir, "02.mp3"), "title", "Welcome To The Family", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
mkTagged(t, filepath.Join(dir, "03.mp3"), "title", "danger line", "artist", "Avenged Sevenfold", "album", "Nightmare")
|
|
_, _, albums := ev.fixCheck(t, FixCheck{Side: SideMaster, Step: StepTags})
|
|
if len(albums) != 1 || albums[0].Release == nil || albums[0].Release.ID != album || albums[0].Matched != 3 || albums[0].Confidence < 0.75 {
|
|
t.Fatalf("want the album: %+v", albums)
|
|
}
|
|
}
|