composite: flatten album and song data

This establishes relation between album and song.

Signed-off-by: Yonah <contrib@gensokyo.uk>
This commit is contained in:
Yonah 2025-09-17 08:31:39 +09:00
parent 2d6b87702d
commit d4789d958b
Signed by: yonah
SSH Key Fingerprint: SHA256:vnQvK8+XXH9Tbni2AV1a/8qdVK/zPcXw52GM0ruQvwA
4 changed files with 2253 additions and 0 deletions

107
composite.go Normal file
View File

@ -0,0 +1,107 @@
package monstersirenfetch
import (
"maps"
"os"
"slices"
"strconv"
"strings"
)
// AlbumConflictError is returned by [Flatten] if two or more albums share the same CID.
type AlbumConflictError struct {
Index int
Previous Album
Current Album
}
func (e *AlbumConflictError) Error() string {
return "album CID conflict on index " + strconv.Itoa(e.Index) + ": " +
"previous album " + e.Previous.Name + ", " +
"current album " + e.Current.Name
}
// SongConflictError is returned by [Flatten] if two or more songs belonging to the same [Album] share the same CID.
type SongConflictError struct {
Index int
Previous *Song
Current Song
}
func (e *SongConflictError) Error() string {
return "song CID conflict on index " + strconv.Itoa(e.Index) + ": " +
"previous song " + e.Previous.Name + ", " +
"current song " + e.Current.Name
}
// CompositeAlbum represents an [Album] with a collection of its associated identifier-only [Song].
type CompositeAlbum struct {
// Songs is a map of [Song.CID] to identifier-only [Song].
Songs SongsMap `json:"songs"`
*Album
}
type (
// CompositeAlbumsMap is a map of [Album.CID] to [CompositeAlbum].
CompositeAlbumsMap map[StringInt]CompositeAlbum
// SongsMap is a map of [Song.CID] to the address of [Song].
SongsMap map[StringInt]*Song
)
func (m CompositeAlbumsMap) String() string {
if len(m) == 0 {
return "<empty>"
}
compAlbums := slices.Collect(maps.Values(m))
slices.SortFunc(compAlbums, func(a, b CompositeAlbum) int { return int(a.CID - b.CID) })
s := make([]string, len(compAlbums))
var buf strings.Builder
for i, ca := range compAlbums {
buf.WriteString(
"Album: " + ca.Name + " (" + ca.CID.String() + ")\n" +
"Cover: " + ca.CoverURL + "\n" +
"Artist(s): " + strings.Join(ca.Artists, ", ") + "\n" +
"Songs:\n")
albumSongs := slices.Collect(maps.Values(ca.Songs))
slices.SortFunc(albumSongs, func(a, b *Song) int { return int(a.CID - b.CID) })
for _, cs := range albumSongs {
buf.WriteString(
" " + strings.Join(cs.Artists, ", ") + " ─ " + cs.Name + "\n")
}
s[i] = buf.String()
buf.Reset()
}
return strings.Join(s, "\n")
}
// Flatten flattens [AlbumsData] and [SongsData] into a [CompositeAlbumsMap].
// All values in [CompositeAlbum.Songs] and the [CompositeAlbum.Album] field are guaranteed to be non-nil.
func Flatten(albumData AlbumsData, songsData SongsData) (CompositeAlbumsMap, error) {
m := make(CompositeAlbumsMap, len(albumData))
for i, a := range albumData {
if c, ok := m[a.CID]; ok {
return nil, &AlbumConflictError{Index: i, Previous: *c.Album, Current: a}
}
m[a.CID] = CompositeAlbum{Songs: make(map[StringInt]*Song), Album: &a}
}
for i, s := range songsData.List {
var c *Song
if a, ok := m[s.AlbumCID]; !ok {
return nil, os.ErrNotExist
} else if c, ok = a.Songs[s.CID]; ok {
return nil, &SongConflictError{Index: i, Previous: c, Current: s}
} else {
a.Songs[s.CID] = &s
}
}
return m, nil
}

157
composite_test.go Normal file
View File

@ -0,0 +1,157 @@
package monstersirenfetch_test
import (
_ "embed"
"encoding/json"
"os"
"reflect"
"testing"
. "git.gensokyo.uk/yonah/monstersirenfetch"
)
func TestFlatten(t *testing.T) {
testCases := []struct {
name string
albumData AlbumsData
songsData SongsData
want CompositeAlbumsMap
wantErr error
}{
{"album conflict", AlbumsData{
{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
{CID: 9378, Name: "\x00"},
}, SongsData{List: []Song{
{CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
{CID: 880313, Name: "(Re)union stream", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
}}, nil, &AlbumConflictError{
Index: 1,
Previous: Album{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
Current: Album{CID: 9378, Name: "\x00"},
}},
{"song conflict", AlbumsData{
{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
}, SongsData{List: []Song{
{CID: 697605, Name: "\x00", AlbumCID: 9378},
{CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
{CID: 880313, Name: "(Re)union stream", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
}}, nil, &SongConflictError{
Index: 1,
Previous: &Song{CID: 697605, Name: "\x00", AlbumCID: 9378},
Current: Song{CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
}},
{"no album", AlbumsData{
{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
}, SongsData{List: []Song{
{CID: 461133, Name: "相见欢", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
{CID: 880313, Name: "(Re)union stream", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
}}, nil, os.ErrNotExist},
{"valid", AlbumsData{
{CID: 230, Name: "相见欢OST", CoverURL: "https://web.hycdn.cn/siren/pic/20250124/c3ece0f3dca2297441e9f788db248be6.png", Artists: []string{"塞壬唱片-MSR"}},
{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
}, SongsData{List: []Song{
{CID: 779470, Name: "天下一白", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 953962, Name: "枯枰竞", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 48781, Name: "火曜门楹", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 880315, Name: "百里灶燃", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 461133, Name: "相见欢", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
{CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
{CID: 880313, Name: "(Re)union stream", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
}}, CompositeAlbumsMap{
230: {
Album: &Album{CID: 230, Name: "相见欢OST", CoverURL: "https://web.hycdn.cn/siren/pic/20250124/c3ece0f3dca2297441e9f788db248be6.png", Artists: []string{"塞壬唱片-MSR"}},
Songs: SongsMap{
779470: {CID: 779470, Name: "天下一白", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
953962: {CID: 953962, Name: "枯枰竞", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
48781: {CID: 48781, Name: "火曜门楹", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
880315: {CID: 880315, Name: "百里灶燃", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
461133: {CID: 461133, Name: "相见欢", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
},
},
9378: {
Album: &Album{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
Songs: SongsMap{
697605: {CID: 697605, Name: "Tower Attack!", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
880313: {CID: 880313, Name: "(Re)union stream", AlbumCID: 9378, Artists: []string{"塞壬唱片-MSR"}},
},
},
}, nil},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := Flatten(tc.albumData, tc.songsData)
if !reflect.DeepEqual(err, tc.wantErr) {
t.Errorf("Flaten: error = %v, want %v", err, tc.wantErr)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Flatten:\n%s\nwant\n%s", got, tc.want)
}
})
}
}
//go:embed testdata/composite
var compositeWant string
func TestCompositeString(t *testing.T) {
t.Run("album conflict", func(t *testing.T) {
const want = "album CID conflict on index 3735928559: previous album 相见欢OST, current album ARKnoNIGHTS"
if got := (&AlbumConflictError{
Index: 0xdeadbeef,
Previous: Album{CID: 230, Name: "相见欢OST", CoverURL: "https://web.hycdn.cn/siren/pic/20250124/c3ece0f3dca2297441e9f788db248be6.png", Artists: []string{"塞壬唱片-MSR"}},
Current: Album{CID: 9378, Name: "ARKnoNIGHTS", CoverURL: "https://web.hycdn.cn/siren/pic/20250401/83fc9e8a75ef3ac003e5d81dbd0f63fd.jpg", Artists: []string{"塞壬唱片-MSR"}},
}).Error(); got != want {
t.Errorf("Error: %q, want %q", got, want)
}
})
t.Run("song conflict", func(t *testing.T) {
const want = "song CID conflict on index 3735928559: previous song 天下一白, current song 枯枰竞"
if got := (&SongConflictError{
Index: 0xdeadbeef,
Previous: &Song{CID: 779470, Name: "天下一白", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
Current: Song{CID: 953962, Name: "枯枰竞", AlbumCID: 230, Artists: []string{"塞壬唱片-MSR"}},
}).Error(); got != want {
t.Errorf("Error: %q, want %q", got, want)
}
})
t.Run("zero", func(t *testing.T) {
got := make(CompositeAlbumsMap).String()
if got != "<empty>" {
t.Errorf("String: %q", got)
}
})
t.Run("sample", func(t *testing.T) {
var (
albumsResp AlbumsResponse
songsResp SongsResponse
)
if err := json.Unmarshal(albumsJSON, &albumsResp); err != nil {
t.Fatalf("Unmarshal: error = %v", err)
}
if err := json.Unmarshal(songsJSON, &songsResp); err != nil {
t.Fatalf("Unmarshal: error = %v", err)
}
var got string
if m, err := Flatten(albumsResp.Data, songsResp.Data); err != nil {
t.Fatalf("Flatten: error = %v", err)
} else {
got = m.String()
}
if got != compositeWant {
t.Errorf("String:\n%s\nwant\n%s", got, compositeWant)
}
})
}

View File

@ -9,6 +9,7 @@ import (
type SongResponse Response[Song] type SongResponse Response[Song]
// Song holds the metadata of a song. // Song holds the metadata of a song.
// Fields marked with omitempty are only populated when the IsFull method returns true.
type Song struct { type Song struct {
CID StringInt `json:"cid"` CID StringInt `json:"cid"`
Name string `json:"name"` Name string `json:"name"`

1988
testdata/composite vendored Normal file

File diff suppressed because it is too large Load Diff