diff --git a/api/api.go b/api/api.go index c2ad3ef..df2d961 100644 --- a/api/api.go +++ b/api/api.go @@ -1242,7 +1242,7 @@ func (s *Server) applyImportBundle(w http.ResponseWriter, bundle *library.Import // created uncategorized if it doesn't already exist by name, then // assigned to the imported tracks. Existing per-track tag sets are // unioned so a re-import doesn't drop tags applied elsewhere. - if s.Tags != nil && incTags && len(tags) > 0 { + if s.Tags != nil && incTags && (len(tags) > 0 || len(bundle.TagCategories) > 0) { s.importPhase("Importing tags…") byName := make(map[string]TagInfo) for _, t := range s.Tags.GetAllTags() { @@ -1269,6 +1269,10 @@ func (s *Server) applyImportBundle(w http.ResponseWriter, bundle *library.Import catByName[name] = id return id } + // Columns first, in rekordbox's order, so empty ones exist too. + for _, name := range bundle.TagCategories { + ensureCategory(name) + } var tagged int for _, ti := range tags { catID := ensureCategory(ti.Category) diff --git a/api/smart_import.go b/api/smart_import.go index 96c7a35..0b5a80c 100644 --- a/api/smart_import.go +++ b/api/smart_import.go @@ -37,6 +37,8 @@ var rbFieldMap = map[string]string{ "fileName": "file_name", "filename": "file_name", "mixName": "mix_name", + "albumArtist": "album_artist", + "producer": "composer", "myTag": "tag", "stockDate": "date_added", "dateCreated": "date_created", diff --git a/api/smart_playlist.go b/api/smart_playlist.go index 9741fc7..9be9296 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -107,6 +107,10 @@ func evalLeaf(field, op string, value any, t *library.Track, tags TagLookup, now return stringMatch(op, value, filepath.Base(t.FilePath)) case "mix_name": return stringMatch(op, value, t.MixName) + case "album_artist": + return stringMatch(op, value, t.AlbumArtist) + case "composer": + return stringMatch(op, value, t.Composer) case "remixer": return stringMatch(op, value, t.Remixer) case "original_artist": @@ -197,7 +201,8 @@ func stringMatch(op string, value any, s string) bool { case "contains": return strings.Contains(low, qLow) case "not_contains": - return !strings.Contains(low, qLow) + // rekordbox's does-not-contain skips empty and missing values. + return low != "" && !strings.Contains(low, qLow) case "starts_with": return strings.HasPrefix(low, qLow) case "ends_with": @@ -216,10 +221,9 @@ func keyMatch(op string, value any, trackKey string) bool { return false } switch op { - case "is", "eq", "=": - return strings.EqualFold(trackKey, target) - case "is_not", "ne", "!=": - return !strings.EqualFold(trackKey, target) + case "is", "eq", "=", "is_not", "ne", "!=": + // Equality compares the key's name like any other text. + return stringMatch(op, value, trackKey) case "compatible": return camelotCompatible(trackKey, target) case "contains", "not_contains", "starts_with", "ends_with": @@ -230,8 +234,10 @@ func keyMatch(op string, value any, trackKey string) bool { } // foldText prepares text for comparison the way rekordbox's smart playlists -// compare it (link-spec conformance smart text suites): case-insensitive, and -// a precomposed accented letter equals its base letter ("Álpha" is "alpha"). +// compare it (link-spec conformance smart text suites): case-insensitive, a +// precomposed accented letter equals its base letter ("Álpha" is "alpha"), +// fullwidth forms equal ASCII ("Alpha" is "alpha"), katakana equals +// hiragana, and final sigma equals sigma. // Text is not otherwise normalized: a base letter followed by a separate // combining accent ("A\u0301lpha") keeps the accent, and combining marks in a // different order differ, as in rekordbox. @@ -243,9 +249,24 @@ func foldText(s string) string { r = base // precomposed: keep the base letter only } } - b.WriteRune(r) + b.WriteRune(foldWidthKana(r)) } - return strings.ToLower(b.String()) + return strings.ReplaceAll(strings.ToLower(b.String()), "ς", "σ") +} + +// foldWidthKana folds the forms rekordbox's text matching treats as equal +// (SMART_PLAYLIST_ORACLE, "Text collation"): fullwidth ASCII and the +// ideographic space to their ASCII forms, and katakana to hiragana. +func foldWidthKana(r rune) rune { + switch { + case r >= 0xff01 && r <= 0xff5e: + return r - 0xfee0 + case r == 0x3000: + return ' ' + case r >= 0x30a1 && r <= 0x30f6: + return r - 0x60 + } + return r } // camelotCompatible reports whether `a` is a harmonically-compatible diff --git a/api/smart_strings_test.go b/api/smart_strings_test.go new file mode 100644 index 0000000..a3d11d7 --- /dev/null +++ b/api/smart_strings_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package api + +import ( + "slices" + "testing" + "time" + + "github.com/vynulldev/vynull/library" +) + +// rekordbox's smart playlist text rules, from link-spec's +// smart-string-property-matrix: the same value sequence in every string +// property gives the same member sets. +func TestSmartStringPropertySemantics(t *testing.T) { + values := []string{"Alpha", "alpha", "Álpha", "Álpha", "Alpha", "Alpha Beta", "Beta Alpha", "Beta", "", ""} + now := time.Now() + for _, prop := range []string{"artist", "albumArtist", "producer", "mixName", "key", "comments"} { + var tracks []*library.Track + for i, v := range values { + tr := &library.Track{ID: uint32(13001 + i)} + switch prop { + case "artist": + tr.Artist = v + case "albumArtist": + tr.AlbumArtist = v + case "producer": + tr.Composer = v + case "mixName": + tr.MixName = v + case "key": + tr.Key = v + case "comments": + tr.Comment = v + } + tracks = append(tracks, tr) + } + members := func(op int, val string) []uint32 { + rules, mapped, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: prop, Operator: op, Left: val}}}) + if mapped == 0 { + return nil + } + var ids []uint32 + for _, tr := range tracks { + if rules.Match(tr, nil, now) { + ids = append(ids, tr.ID) + } + } + return ids + } + for _, tc := range []struct { + name string + op int + want []uint32 + }{ + {"equals", 1, []uint32{13001, 13002, 13003, 13004, 13005}}, + {"not equal", 2, []uint32{13006, 13007, 13008, 13009, 13010}}, + {"contains", 8, []uint32{13001, 13002, 13003, 13004, 13005, 13006, 13007}}, + {"does not contain", 9, []uint32{13008}}, + {"starts with", 10, []uint32{13001, 13002, 13003, 13004, 13005, 13006}}, + {"ends with", 11, []uint32{13001, 13002, 13003, 13004, 13005, 13007}}, + } { + if got := members(tc.op, "Alpha"); !slices.Equal(got, tc.want) { + t.Errorf("%s %s Alpha = %v, want %v", prop, tc.name, got, tc.want) + } + } + } +} + +func TestFoldText(t *testing.T) { + for in, want := range map[string]string{ + "Alpha": "alpha", "Álpha": "alpha", "カタカナ": "かたかな", "ΟΔΟΣ": "οδοσ", "οδος": "οδοσ", + } { + if got := foldText(in); got != want { + t.Errorf("foldText(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/api/web/index.html b/api/web/index.html index dc57438..557e662 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11146,7 +11146,10 @@

RELATED

// edits. {id, name, rules} where id is null for "create new". let smartEditorState = null; -function openSmartEditor(playlistID) { +async function openSmartEditor(playlistID) { + // Tag rules pick from the tag catalog; load it (fresh: an import may have + // added tags) before rendering, or the tag dropdowns come up empty. + await ensureAllTags(true); ensureSmartEditor(); const existing = playlistID != null ? playlistsCache.find(p => p.id === playlistID) : null; smartEditorState = { @@ -11302,9 +11305,14 @@

RELATED

if (meta.type === 'tag') { const tags = (allTagsCache || []); const v = (c.value || ''); - return ``; + // Rules match tag names case-insensitively; select the rule's tag that + // way, and keep a name that isn't in the catalog as its own option so + // saving never swaps the rule's tag for the first one listed. + const known = tags.some(t => t.name.toLowerCase() === v.toLowerCase()); + const opts = tags.map(t => + ``); + if (v && !known) opts.unshift(``); + return ``; } if (meta.type === 'date') { if (op === 'in_last_days') { diff --git a/dbserver/menuitem.go b/dbserver/menuitem.go index f22c615..f8fd597 100644 --- a/dbserver/menuitem.go +++ b/dbserver/menuitem.go @@ -181,6 +181,13 @@ func (h *Handler) trackToStdItem(t *library.Track) *menuItem { func newKeyID(key string) uint32 { camelot, _ := analysis.KeyNamesFrom(key) if camelot == "" { + // rekordbox reads a key name loosely: a leading root note A-G + // (fullwidth too), an optional sharp or flat, then "m" for minor; + // anything after is ignored ("Alpha" is A major, "Beta" B major), and + // a name that doesn't start with a root note has no index. + if loose := looseKeyName(key); loose != "" && loose != key { + return newKeyID(loose) + } return 0 } letter := camelot[len(camelot)-1] @@ -280,10 +287,9 @@ func (h *Handler) applyTrackDetail(m *menuItem, t *library.Track) { m.Label2 += " - " + t.Key } case "key": - m.ParentID = h.catID("key", t.Key) - if t.Key != "" { - m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, t.BPM) - } + // rekordbox composes " - bpm" even for an empty key. + m.ParentID = h.trackKeyID(t) + m.Label2 = fmt.Sprintf("%s - %.1f bpm", t.Key, t.BPM) case "artist": m.ParentID = h.catID("artist", t.Artist) m.Label2 = t.Artist @@ -468,3 +474,47 @@ func trackInfoTitleID(fileType string) uint32 { return 1 } } + +// trackKeyID is a track's key ID as rekordbox serves it: the imported key row +// ID (which an empty-named key also has), else the KEY menu's ID for the key's +// name, and 0 for a track with no key. +func (h *Handler) trackKeyID(t *library.Track) uint32 { + if t.KeyID != 0 { + return t.KeyID + } + if t.Key == "" { + return 0 + } + return h.catID("key", t.Key) +} + +// looseKeyName reduces a key name to the part rekordbox reads (see +// newKeyID): root note, accidental and minor flag, or "" when it doesn't +// start with a root note. +func looseKeyName(key string) string { + r := []rune(key) + for i, c := range r { + if c >= 0xff01 && c <= 0xff5e { // fullwidth ASCII + r[i] = c - 0xfee0 + } + } + if len(r) == 0 || r[0] < 'A' || r[0] > 'G' { + return "" + } + name := string(r[0]) + rest := r[1:] + if len(rest) > 0 { + switch rest[0] { + case '#', '♯': + name += "#" + rest = rest[1:] + case 'b', '♭': + name += "b" + rest = rest[1:] + } + } + if len(rest) > 0 && rest[0] == 'm' { + name += "m" + } + return name +} diff --git a/dbserver/songinfo_display_test.go b/dbserver/songinfo_display_test.go index 9a3e0e3..9c9f5c8 100644 --- a/dbserver/songinfo_display_test.go +++ b/dbserver/songinfo_display_test.go @@ -146,3 +146,39 @@ func TestTruncateUTF16(t *testing.T) { t.Errorf("under the limit: %q", got) } } + +// rekordbox reads key names loosely for its 1-24 key index (link-spec +// smart-string-property-matrix): a leading root note, sharp or flat, and +// "m"; real key names are unaffected. +func TestNewKeyIDLoose(t *testing.T) { + for key, want := range map[string]uint32{ + "Alpha": 22, "alpha": 0, "Álpha": 0, "Álpha": 22, "Alpha": 22, + "Beta": 2, "": 0, "Am": 15, "F#m": 21, "8A": 15, + } { + if got := newKeyID(key); got != want { + t.Errorf("newKeyID(%q) = %d, want %d", key, got, want) + } + } +} + +// The KEY column carries the track's key ID (an empty-named key keeps its +// row ID, no key is 0) and always composes " - bpm". +func TestKeyColumnEmptyKey(t *testing.T) { + lib := library.New() + lib.AddTrack(&library.Track{ID: 1, BPM: 120, KeyID: 18009}) // empty-named key row + lib.AddTrack(&library.Track{ID: 2, BPM: 120.5}) // no key + h := &Handler{lib: lib, menu: keyColumnMenu{}} + a, b := h.trackToStdItem(lib.Track(1)), h.trackToStdItem(lib.Track(2)) + if a.ParentID != 18009 || a.Label2 != " - 120.0 bpm" { + t.Errorf("empty-named key: %d %q, want 18009 \" - 120.0 bpm\"", a.ParentID, a.Label2) + } + if b.ParentID != 0 || b.Label2 != " - 120.5 bpm" { + t.Errorf("no key: %d %q, want 0 \" - 120.5 bpm\"", b.ParentID, b.Label2) + } +} + +// keyColumnMenu is a MenuSource whose track lists show the KEY column. +type keyColumnMenu struct{} + +func (keyColumnMenu) RootMenu() []RootMenuEntry { return DefaultRootMenu() } +func (keyColumnMenu) TrackDetail() string { return "key" } diff --git a/library/import.go b/library/import.go index 5aa9e27..9246179 100644 --- a/library/import.go +++ b/library/import.go @@ -16,9 +16,12 @@ type ImportBundle struct { Result *ImportResult Playlists []PlaylistImport Tags []TagImport - Colors []ColorImport - Cues []ImportedCue - Assets []ImportedAsset + // TagCategories are the MyTag columns in rekordbox's order, including + // empty ones; created before the tags so they keep that order. + TagCategories []string + Colors []ColorImport + Cues []ImportedCue + Assets []ImportedAsset // ShareRoot is the share/ root for ANLZ + artwork (a backup-zip extract, or // a master.db's own library folder). SettingsDir holds the *SETTING.DAT // blobs. Both are empty when the format carries no such assets. diff --git a/library/import_masterdb.go b/library/import_masterdb.go index 09deea7..2150e6d 100644 --- a/library/import_masterdb.go +++ b/library/import_masterdb.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -151,6 +152,9 @@ type masterDBTrack struct { // the REMIXER and ORIGINAL ARTIST category drills resolve. Remixer string `json:"remixer"` OriginalArtist string `json:"original_artist"` + MixName string `json:"mix_name"` + AlbumArtist string `json:"album_artist"` + Composer string `json:"composer"` RemixerID string `json:"remixer_id"` OrigArtistID string `json:"orig_artist_id"` } @@ -203,8 +207,17 @@ type MasterDBTag struct { Name string `json:"name"` ParentID string `json:"parent_id"` TrackIDs []string `json:"track_ids"` + // Seq orders tags within a column, and columns among themselves. + Seq int `json:"seq"` + // IsCategory marks a column (rekordbox Attribute 1) rather than a tag. + IsCategory bool `json:"is_category"` } +// isCategory reports whether a djmdMyTag row is a column (Genre, +// Components, ...) rather than a tag: its Attribute flag, or, for dumps +// without it, a top-level row. +func (t MasterDBTag) isCategory() bool { return t.IsCategory || t.ParentID == "root" } + // ImportRekordboxMasterDB shells out to tools/rekordbox_dump.py to read // the encrypted master.db and then merges the result into the library. // Requires Python 3 + the `sqlcipher3` package. The key is the SQLCipher @@ -317,27 +330,46 @@ func ImportMasterDBDump(lib *Library, dump *MasterDBDump) (*ImportBundle, error) } } - // Resolve each MyTag's rekordbox ContentIDs to library Track.IDs. - // djmdMyTag is a 2-level tree: category rows (Genre, Components, Mood, …) - // are parents, and the actual tags are children whose ParentID points at - // their category. Category rows carry no track assignments, so they drop - // out of the leaf list below; we use them only to label each tag's - // category. tagsByID lets a tag look up its parent category's name. + // MyTags. djmdMyTag is a 2-level tree: columns (Genre, Components, …) at + // the top and the tags under them. Every live column and tag is imported, + // in rekordbox's order (columns by Seq, then each column's tags by Seq), + // whether or not any track carries it: rekordbox keeps unused tags and + // empty columns, and smart playlists can name them. tagsByID := make(map[string]MasterDBTag, len(dump.Tags)) + var cats, leaves []MasterDBTag for _, mt := range dump.Tags { tagsByID[mt.ID] = mt + if mt.isCategory() { + cats = append(cats, mt) + } else { + leaves = append(leaves, mt) + } } - tags := make([]TagImport, 0, len(dump.Tags)) - for _, mt := range dump.Tags { + sort.SliceStable(cats, func(i, j int) bool { return cats[i].Seq < cats[j].Seq }) + catSeq := func(mt MasterDBTag) int { + if p, ok := tagsByID[mt.ParentID]; ok { + return p.Seq + } + return 1 << 30 // tags outside any column last + } + sort.SliceStable(leaves, func(i, j int) bool { + if a, b := catSeq(leaves[i]), catSeq(leaves[j]); a != b { + return a < b + } + return leaves[i].Seq < leaves[j].Seq + }) + tagCategories := make([]string, 0, len(cats)) + for _, c := range cats { + tagCategories = append(tagCategories, c.Name) + } + tags := make([]TagImport, 0, len(leaves)) + for _, mt := range leaves { var ids []uint32 for _, cid := range mt.TrackIDs { if id, ok := idMap[cid]; ok { ids = append(ids, id) } } - if len(ids) == 0 { - continue // category/parent row, or an unused tag — nothing to apply - } category := "" if parent, ok := tagsByID[mt.ParentID]; ok { category = parent.Name @@ -373,7 +405,7 @@ func ImportMasterDBDump(lib *Library, dump *MasterDBDump) (*ImportBundle, error) } lib.FinalizeBulk() - return &ImportBundle{Result: res, Playlists: playlists, Tags: tags, Colors: colors, Assets: assets, Cues: cues}, nil + return &ImportBundle{Result: res, Playlists: playlists, Tags: tags, TagCategories: tagCategories, Colors: colors, Assets: assets, Cues: cues}, nil } // masterDBPlaylistTree turns the flat djmdPlaylist rows into a nested @@ -617,6 +649,10 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { AutoLoadHotCue: t.HotCueAutoLoad != "", Remixer: t.Remixer, OriginalArtist: t.OriginalArtist, + MixName: t.MixName, + KeyID: parseRBID(t.KeyID), + AlbumArtist: t.AlbumArtist, + Composer: t.Composer, } } diff --git a/library/import_mytags_test.go b/library/import_mytags_test.go new file mode 100644 index 0000000..2f451ca --- /dev/null +++ b/library/import_mytags_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package library + +import ( + "slices" + "testing" +) + +// MyTag import keeps every live column and tag in rekordbox's order (columns +// by Seq, then each column's tags by Seq), including tags no track carries and +// empty columns. Unused tags were dropped: the importer took "no tracks" to +// mean "a column". +func TestImportMyTags(t *testing.T) { + lib := New() + dump := &MasterDBDump{ + Tracks: []masterDBTrack{{ID: "100", Title: "One", FilePath: "/music/one.mp3"}}, + Tags: []MasterDBTag{ + {ID: "2", Name: "Situation", ParentID: "root", Seq: 2, IsCategory: true}, + {ID: "1", Name: "Genre", ParentID: "root", Seq: 1, IsCategory: true}, + {ID: "4", Name: "Untitled Column", ParentID: "root", Seq: 4}, // older dump: no flag + {ID: "11", Name: "Deep House", ParentID: "1", Seq: 4, TrackIDs: []string{"100"}}, + {ID: "12", Name: "Acid House", ParentID: "1", Seq: 3}, // unused + {ID: "13", Name: "Trap", ParentID: "1", Seq: 1, TrackIDs: []string{"100"}}, + {ID: "21", Name: "Intro", ParentID: "2", Seq: 9}, + }, + } + bundle, err := ImportMasterDBDump(lib, dump) + if err != nil { + t.Fatal(err) + } + if want := []string{"Genre", "Situation", "Untitled Column"}; !slices.Equal(bundle.TagCategories, want) { + t.Errorf("columns = %v, want %v", bundle.TagCategories, want) + } + var names, cats []string + for _, tg := range bundle.Tags { + names = append(names, tg.Name) + cats = append(cats, tg.Category) + } + if want := []string{"Trap", "Acid House", "Deep House", "Intro"}; !slices.Equal(names, want) { + t.Errorf("tags = %v, want %v", names, want) + } + if want := []string{"Genre", "Genre", "Genre", "Situation"}; !slices.Equal(cats, want) { + t.Errorf("tag columns = %v, want %v", cats, want) + } + if len(bundle.Tags[1].TrackIDs) != 0 || len(bundle.Tags[0].TrackIDs) != 1 { + t.Errorf("track assignments: %+v", bundle.Tags) + } +} diff --git a/library/track.go b/library/track.go index 3c2bd13..05bd9cf 100644 --- a/library/track.go +++ b/library/track.go @@ -59,7 +59,14 @@ type Track struct { OriginalArtist string `json:"original_artist"` Remixer string `json:"remixer"` MixName string `json:"mix_name"` // mix/version name - DateAdded time.Time `json:"date_added"` + // AlbumArtist and Composer come from a rekordbox import (the album's + // artist; ComposerID, which rekordbox's smart playlists call producer). + AlbumArtist string `json:"album_artist,omitempty"` + Composer string `json:"composer,omitempty"` + // KeyID is the track's rekordbox key row ID, from an import (0 when the + // track has no key relation). It tells an empty-named key from no key. + KeyID uint32 `json:"key_id,omitempty"` + DateAdded time.Time `json:"date_added"` // DateCreated is rekordbox's separate DateCreated, from an import; smart // playlist rules on dateCreated test it (zero when unknown). DateCreated time.Time `json:"date_created,omitempty"` diff --git a/tools/rekordbox_dump.py b/tools/rekordbox_dump.py index 024aeee..ebf03fa 100644 --- a/tools/rekordbox_dump.py +++ b/tools/rekordbox_dump.py @@ -138,7 +138,8 @@ def main(): k.ScaleName AS Key, l.Name AS Label, c.GenreID, c.ArtistID, c.AlbumID, c.KeyID, c.LabelID, rmx.Name AS Remixer, oa.Name AS OrigArtist, - c.RemixerID, c.OrgArtistID + c.RemixerID, c.OrgArtistID, + c.Subtitle AS MixName, aa.Name AS AlbumArtist, cp.Name AS Composer FROM djmdContent c LEFT JOIN djmdArtist a ON c.ArtistID = a.ID LEFT JOIN djmdAlbum al ON c.AlbumID = al.ID @@ -147,6 +148,8 @@ def main(): LEFT JOIN djmdLabel l ON c.LabelID = l.ID LEFT JOIN djmdArtist rmx ON c.RemixerID = rmx.ID LEFT JOIN djmdArtist oa ON c.OrgArtistID = oa.ID + LEFT JOIN djmdArtist aa ON al.AlbumArtistID = aa.ID + LEFT JOIN djmdArtist cp ON c.ComposerID = cp.ID WHERE COALESCE(c.rb_local_deleted, 0) = 0 """) cols = [d[0] for d in cur.description] @@ -199,6 +202,11 @@ def main(): "label_id": str(d.get("LabelID") or ""), "remixer": d.get("Remixer") or "", "original_artist": d.get("OrigArtist") or "", + # Smart playlist text fields: mixName (Subtitle), albumArtist (the + # album's artist) and producer (ComposerID). + "mix_name": d.get("MixName") or "", + "album_artist": d.get("AlbumArtist") or "", + "composer": d.get("Composer") or "", "remixer_id": str(d.get("RemixerID") or ""), "orig_artist_id": str(d.get("OrgArtistID") or ""), # Paths are relative to the rekordbox `share/` root, e.g. @@ -253,20 +261,29 @@ def main(): entry["smart"] = parse_smartlist(smartlist, tags_by_id) out["playlists"].append(entry) - # MyTags + assignments + # MyTags + assignments. djmdMyTag holds both the columns (Attribute 1, + # ParentID "root": Genre, Components, ...) and the tags under them; Seq + # orders each level as rekordbox shows it. Deleted rows are skipped, and a + # track assigned twice to a tag is listed once. try: - cur.execute("SELECT ID, Name, ParentID FROM djmdMyTag") + cur.execute("SELECT ID, Name, ParentID, Seq, Attribute FROM djmdMyTag " + "WHERE COALESCE(rb_local_deleted, 0) = 0") tag_rows = cur.fetchall() - cur.execute("SELECT MyTagID, ContentID FROM djmdSongMyTag") + cur.execute("SELECT MyTagID, ContentID FROM djmdSongMyTag " + "WHERE COALESCE(rb_local_deleted, 0) = 0") tag_tracks = {} for tagid, cid in cur.fetchall(): - tag_tracks.setdefault(str(tagid), []).append(cid) - for tid, name, parent in tag_rows: + ids = tag_tracks.setdefault(str(tagid), []) + if cid not in ids: + ids.append(cid) + for tid, name, parent, seq, attr in tag_rows: out["tags"].append({ - "id": tid, - "name": name, - "parent_id": parent, - "track_ids": tag_tracks.get(str(tid), []), + "id": tid, + "name": name, + "parent_id": parent, + "seq": int(seq or 0), + "is_category": int(attr or 0) == 1, + "track_ids": tag_tracks.get(str(tid), []), }) except sqlcipher3.dbapi2.OperationalError: pass # Older DBs may not have MyTag tables