From 9a74d591767ab3f8c065bacd7fc673677ac5e6f0 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 19:08:41 +0000 Subject: [PATCH 1/3] smart: read and compare dates as rekordbox does rekordbox reads a date by position from a 10-character value, ignoring the separators, and keeps out-of-range days and months as dates; a blank or malformed date is no date and matches no date rule. Imports no longer give such tracks today's date, and rule values are read the same way. Relative rules follow pastDayToDay and pastMonthToDay: the last n days start n-1 days back, the last n months start on the last day of the month n months back, only the unit month counts months, and a count below one counts as one. dateReleased rules now test the track's release date, which the import now keeps. The web editor lists date created, release date, and the imported date operators, and date rule values may be plain YYYY-MM-DD. --- api/smart_import.go | 75 +++++++++++++++++++++-------------- api/smart_import_date_test.go | 73 ++++++++++++++++++++++++++++++++++ api/smart_playlist.go | 54 ++++++++++++++++++++----- api/web/index.html | 24 ++++++++--- library/import_masterdb.go | 19 ++++----- library/rbdate.go | 47 ++++++++++++++++++++++ library/rbdate_test.go | 55 +++++++++++++++++++++++++ library/track.go | 3 ++ tools/rekordbox_dump.py | 3 +- 9 files changed, 294 insertions(+), 59 deletions(-) create mode 100644 library/rbdate.go create mode 100644 library/rbdate_test.go diff --git a/api/smart_import.go b/api/smart_import.go index 9597c96..ee3f679 100644 --- a/api/smart_import.go +++ b/api/smart_import.go @@ -16,8 +16,7 @@ import ( // rekordbox 7.2.19): it accepts counter (play count), remixedBy, fileName // (or filename), and mixName, and rejects look-alikes such as playCount, // remixer, title, color, and composer. Properties not listed are unsupported -// (or, like albumArtist, producer, grouping, and dateReleased, have no track -// data here); their conditions are dropped during translation. +// (or, like albumArtist, producer, and grouping, have no track data here); their conditions are dropped during translation. var rbFieldMap = map[string]string{ "bpm": "bpm", "genre": "genre", @@ -42,6 +41,7 @@ var rbFieldMap = map[string]string{ "myTag": "tag", "stockDate": "date_added", "dateCreated": "date_created", + "dateReleased": "release_date", } // rbField looks up a SmartList PropertyName, which rekordbox matches in any @@ -68,16 +68,49 @@ func isNumericField(field string) bool { // rbDate converts a rekordbox SmartList date value to the RFC3339 form // dateMatch expects, or "" if rekordbox would not accept it (so the caller -// treats the condition as unsatisfiable). rekordbox is strict -// (smart-date-format-matrix, rekordbox 7.2.19): only YYYY-MM-DD, with no -// surrounding spaces and no time part, nothing on or before 1970-01-01, -// which it treats as no date, and nothing in year 9999. +// treats the condition as unsatisfiable). rekordbox reads rule dates as it +// reads track dates; see library.ParseRekordboxDate. func rbDate(s string) string { - t, err := time.Parse("2006-01-02", s) - if err != nil || !t.After(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)) || t.Year() >= 9999 { + t := library.ParseRekordboxDate(s) + if t.IsZero() { return "" } - return t.UTC().Format(time.RFC3339) + return t.Format(time.RFC3339) +} + +// rbCount reads a relative-date count as rekordbox does (JUCE +// String::getIntValue): an optionally signed run of leading digits, so 1.5 +// is 1 and blank or other text is 0. +func rbCount(s string) int { + s = strings.TrimLeft(s, " \t\r\n") + neg := false + if s != "" && (s[0] == '-' || s[0] == '+') { + neg = s[0] == '-' + s = s[1:] + } + n := 0 + for i := 0; i < len(s) && s[i] >= '0' && s[i] <= '9' && n < 1<<24; i++ { + n = n*10 + int(s[i]-'0') + } + if neg { + return -n + } + return n +} + +// rbRecent translates in-last (6) and not-in-last (7). rekordbox counts +// calendar months only for the unit month, in any case; every other unit, +// week and year included, counts days (smart-relative-date-matrix, +// rekordbox 7.2.19). The right-hand value is ignored. +func rbRecent(field string, c library.RBSmartCond) SmartCond { + op := "in_last_days" + if strings.EqualFold(c.Unit, "month") { + op = "in_last_months" + } + if c.Operator == 7 { + op = "not_" + op + } + return SmartCond{Field: field, Operator: op, Value: float64(rbCount(c.Left))} } // rbInt reads a numeric rule value as rekordbox does (link-spec @@ -141,7 +174,7 @@ func rbCondToSmart(c library.RBSmartCond) (SmartCond, bool) { return SmartCond{}, false } isTag := field == "tag" - isDate := field == "date_added" || field == "date_created" + isDate := field == "date_added" || field == "date_created" || field == "release_date" isNum := isNumericField(field) // num parses a rekordbox numeric value string, scaling BPM down by 100. @@ -231,27 +264,9 @@ func rbCondToSmart(c library.RBSmartCond) (SmartCond, bool) { } return SmartCond{}, false } - case 6: // IN_LAST (relative date) + case 6, 7: // IN_LAST, NOT_IN_LAST (relative date) if isDate { - days := num(c.Left) - switch strings.ToLower(c.Unit) { - case "month": - days *= 30 - case "year": - days *= 365 - } - return SmartCond{Field: field, Operator: "in_last_days", Value: days}, true - } - case 7: // NOT_IN_LAST (relative date) - if isDate { - days := num(c.Left) - switch strings.ToLower(c.Unit) { - case "month": - days *= 30 - case "year": - days *= 365 - } - return SmartCond{Field: field, Operator: "not_in_last_days", Value: days}, true + return rbRecent(field, c), true } case 8: // CONTAINS switch { diff --git a/api/smart_import_date_test.go b/api/smart_import_date_test.go index c82a8cd..b20f2aa 100644 --- a/api/smart_import_date_test.go +++ b/api/smart_import_date_test.go @@ -3,6 +3,7 @@ package api import ( + "fmt" "testing" "time" @@ -37,6 +38,7 @@ func TestSmartImportDateConditions(t *testing.T) { {"equal a blank date — dropped (matches nothing)", library.RBSmartCond{Property: "dateCreated", Operator: 1, Left: ""}, false, false, true}, {"reversed range — dropped (matches nothing)", library.RBSmartCond{Property: "stockDate", Operator: 5, Left: "2025-01-01", Right: "2023-01-01"}, false, false, true}, {"unparseable date — dropped", library.RBSmartCond{Property: "dateCreated", Operator: 3, Left: "not-a-date"}, false, false, true}, + {"other separators read by position", library.RBSmartCond{Property: "dateCreated", Operator: 1, Left: "2024/06.01"}, true, false, false}, {"date with a time part — dropped", library.RBSmartCond{Property: "dateCreated", Operator: 3, Left: "2023-01-01 12:00:00"}, false, false, true}, {"date with spaces — dropped", library.RBSmartCond{Property: "dateCreated", Operator: 3, Left: " 2023-01-01"}, false, false, true}, {"epoch date — dropped", library.RBSmartCond{Property: "dateCreated", Operator: 3, Left: "1970-01-01"}, false, false, true}, @@ -82,3 +84,74 @@ func TestSmartImportDateFieldsAreDistinct(t *testing.T) { t.Errorf("dateCreated after 2023 matched a track with no created date") } } + +// Relative date rules follow rekordbox's pastDayToDay and pastMonthToDay +// (link-spec conformance smart-relative-date-matrix, rekordbox 7.2.19, whose +// clock and dates these are): in the last n days starts n-1 days back, in +// the last n months starts on the last day of the month n months back, only +// the unit month counts months, and a count below one counts as one. +func TestSmartImportRelativeDates(t *testing.T) { + now := time.Date(2032, 3, 31, 12, 0, 0, 0, time.UTC) + dates := []string{"2032-03-31", "2032-03-30", "2032-03-24", "2032-02-29", "2032-02-28", + "2031-03-31", "2031-03-30", "2032-04-01", "", "not-a-date"} + tracks := make([]*library.Track, len(dates)) + for i, d := range dates { + tracks[i] = &library.Track{ID: uint32(i + 1), DateAdded: library.ParseRekordboxDate(d)} + } + cases := []struct { + count, unit string + in []uint32 // tracks in the last count units; every other dated track is not + }{ + {"1", "day", []uint32{1, 8}}, + {"1", "week", []uint32{1, 8}}, + {"1", "year", []uint32{1, 8}}, + {"1", "months", []uint32{1, 8}}, + {"1", "", []uint32{1, 8}}, + {"1", "month", []uint32{1, 2, 3, 4, 8}}, + {"1", "MONTH", []uint32{1, 2, 3, 4, 8}}, + {"2", "day", []uint32{1, 2, 8}}, + {"31", "day", []uint32{1, 2, 3, 8}}, + {"2", "month", []uint32{1, 2, 3, 4, 5, 8}}, + {"0", "day", []uint32{1, 8}}, + {"-1", "day", []uint32{1, 8}}, + {"", "day", []uint32{1, 8}}, + {"not-a-number", "day", []uint32{1, 8}}, + {"1.5", "day", []uint32{1, 8}}, + } + for _, tc := range cases { + for _, op := range []int{6, 7} { + rules, _, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: "stockDate", Operator: op, Left: tc.count, Unit: tc.unit}}}) + var got, want []uint32 + for _, tr := range tracks { + if rules.Match(tr, nil, now) { + got = append(got, tr.ID) + } + if tr.DateAdded.IsZero() { + continue + } + in := false + for _, id := range tc.in { + in = in || id == tr.ID + } + if in == (op == 6) { + want = append(want, tr.ID) + } + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("op %d count %q unit %q: got %v, want %v", op, tc.count, tc.unit, got, want) + } + } + } +} + +// A rule on dateReleased tests the track's release date. +func TestSmartImportReleaseDate(t *testing.T) { + now := time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC) + tr := &library.Track{ID: 1, DateAdded: now, ReleaseDate: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)} + rules, mapped, _ := smartRulesFromRekordbox(&library.RBSmartList{Logical: 1, + Conditions: []library.RBSmartCond{{Property: "dateReleased", Operator: 1, Left: "2024/06/01"}}}) + if mapped != 1 || !rules.Match(tr, nil, now) { + t.Errorf("dateReleased equal 2024/06/01 should match the 2024-06-01 release date (mapped=%d)", mapped) + } +} diff --git a/api/smart_playlist.go b/api/smart_playlist.go index ecbb5f8..70cc297 100644 --- a/api/smart_playlist.go +++ b/api/smart_playlist.go @@ -131,6 +131,8 @@ func evalLeaf(field, op string, value any, t *library.Track, tags TagLookup, now return dateMatch(op, value, t.DateAdded, now) case "date_created": return dateMatch(op, value, t.DateCreated, now) + case "release_date": + return dateMatch(op, value, t.ReleaseDate, now) } return false } @@ -352,8 +354,9 @@ func tagMatch(op string, value any, trackID uint32, tags TagLookup) bool { } // dateMatch only supports "in_last_days" (recency filter) and a few -// equality / range modes for completeness. Value is a number of days -// for in_last_days, or RFC3339 string for the comparison operators. +// equality / range modes for completeness. Value is a number of days (or +// months, for in_last_months) for the recency operators, or RFC3339 string +// for the comparison operators. func dateMatch(op string, value any, date time.Time, now time.Time) bool { if date.IsZero() { // Unset date — only matches "is_empty"/"not_empty" sentinels. @@ -366,7 +369,7 @@ func dateMatch(op string, value any, date time.Time, now time.Time) bool { if !ok { return false } - d, err := time.Parse(time.RFC3339, ts) + d, err := parseRuleDate(ts) if err != nil { return false } @@ -374,19 +377,21 @@ func dateMatch(op string, value any, date time.Time, now time.Time) bool { y2, m2, d2 := d.UTC().Date() same := y1 == y2 && m1 == m2 && d1 == d2 return same == (op == "on") - case "in_last_days", "not_in_last_days": - days, ok := toFloat(value) + case "in_last_days", "not_in_last_days", "in_last_months", "not_in_last_months": + n, ok := toFloat(value) if !ok { return false } - within := now.Sub(date) <= time.Duration(days*24)*time.Hour - return within == (op == "in_last_days") + months := op == "in_last_months" || op == "not_in_last_months" + y, m, d := date.UTC().Date() + within := !time.Date(y, m, d, 0, 0, 0, 0, time.UTC).Before(recentCutoff(int(n), months, now)) + return within == (op == "in_last_days" || op == "in_last_months") case "before": ts, ok := value.(string) if !ok { return false } - t, err := time.Parse(time.RFC3339, ts) + t, err := parseRuleDate(ts) if err != nil { return false } @@ -396,7 +401,7 @@ func dateMatch(op string, value any, date time.Time, now time.Time) bool { if !ok { return false } - t, err := time.Parse(time.RFC3339, ts) + t, err := parseRuleDate(ts) if err != nil { return false } @@ -411,8 +416,8 @@ func dateMatch(op string, value any, date time.Time, now time.Time) bool { if !ok1 || !ok2 { return false } - lo, e1 := time.Parse(time.RFC3339, los) - hi, e2 := time.Parse(time.RFC3339, his) + lo, e1 := parseRuleDate(los) + hi, e2 := parseRuleDate(his) if e1 != nil || e2 != nil { return false } @@ -425,6 +430,33 @@ func dateMatch(op string, value any, date time.Time, now time.Time) bool { return false } +// parseRuleDate reads a date rule's value: RFC3339, as imported rules and +// the API carry it, or a plain YYYY-MM-DD, as the web editor's date input +// gives it. +func parseRuleDate(s string) (time.Time, error) { + if t, err := time.Parse("2006-01-02", s); err == nil { + return t, nil + } + return time.Parse(time.RFC3339, s) +} + +// recentCutoff is the first day "in the last n days" (or months) includes, +// as rekordbox's pastDayToDay and pastMonthToDay compute it (link-spec +// static analysis, smart-relative-date-matrix, rekordbox 7.2.19): the last +// n days end with today, so the cutoff is n-1 days back, and the last n +// months start with the last day of the month n months back. A count +// below one counts as one. Later dates, even future ones, are within. +func recentCutoff(n int, months bool, now time.Time) time.Time { + if n--; n < 0 { + n = 0 + } + y, m, d := now.Date() + if months { + return time.Date(y, m-time.Month(n), 0, 0, 0, 0, 0, time.UTC) + } + return time.Date(y, m, d-n, 0, 0, 0, 0, time.UTC) +} + // ── small helpers ────────────────────────────────────────────────────── func toFloat(v any) (float64, bool) { diff --git a/api/web/index.html b/api/web/index.html index 557e662..139bcd8 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -11125,6 +11125,8 @@

RELATED

key: { label: 'Key', type: 'key' }, tag: { label: 'Tag', type: 'tag' }, date_added: { label: 'Date added', type: 'date' }, + date_created: { label: 'Date created', type: 'date' }, + release_date: { label: 'Release date', type: 'date' }, }; const SMART_OPS = { number: [['eq','equals'], ['ne','is not'], ['lt','<'], ['lte','≤'], ['gt','>'], ['gte','≥'], ['between','between']], @@ -11132,9 +11134,13 @@

RELATED

['starts_with','starts with'], ['ends_with','ends with'], ['is_empty','is empty'], ['not_empty','not empty']], key: [['is','equals'], ['is_not','is not'], ['compatible','is compatible with']], tag: [['has','has tag'], ['not_has','doesn’t have tag'], ['has_any','has any tag'], ['none','has no tags']], - date: [['in_last_days','in the last (days)'], ['before','before'], ['after','after']], + date: [['in_last_days','in the last (days)'], ['not_in_last_days','not in the last (days)'], + ['in_last_months','in the last (months)'], ['not_in_last_months','not in the last (months)'], + ['on','is'], ['not_on','is not'], ['before','before'], ['after','after'], ['between','between']], color: [['eq','is'], ['ne','is not']], }; +// Date operators whose value is a count of days or months, not a date. +const SMART_DATE_COUNT_OPS = ['in_last_days', 'not_in_last_days', 'in_last_months', 'not_in_last_months']; // Camelot key list for key field dropdowns. const SMART_KEYS = (function() { const out = []; @@ -11315,10 +11321,18 @@

RELATED

return ``; } if (meta.type === 'date') { - if (op === 'in_last_days') { - const v = (c.value != null) ? c.value : 7; + if (SMART_DATE_COUNT_OPS.includes(op)) { + const v = (c.value != null && c.value !== '') ? c.value : 7; return ``; } + if (op === 'between') { + const day = x => (typeof x === 'string') ? x.substring(0, 10) : ''; + const lo = Array.isArray(c.value) ? day(c.value[0]) : ''; + const hi = Array.isArray(c.value) ? day(c.value[1]) : ''; + return ` + … + `; + } const v = (typeof c.value === 'string') ? c.value.substring(0, 10) : ''; return ``; } @@ -11407,7 +11421,7 @@

RELATED

const cond = smartAt(path); cond.operator = sel.value; // Reset value shape when switching to/from "between" or no-arg ops. - cond.value = sel.value === 'between' ? [0, 0] : ''; + cond.value = sel.value === 'between' ? [0, 0] : SMART_DATE_COUNT_OPS.includes(sel.value) ? 7 : ''; renderSmartEditor(); }; }); @@ -11419,7 +11433,7 @@

RELATED

const idx = el.getAttribute('data-sm-idx'); let v = el.value; if (meta.type === 'number' || meta.type === 'color' || - (meta.type === 'date' && cond.operator === 'in_last_days')) { + (meta.type === 'date' && SMART_DATE_COUNT_OPS.includes(cond.operator))) { v = v === '' ? null : Number(v); } if (cond.operator === 'between') { diff --git a/library/import_masterdb.go b/library/import_masterdb.go index 2150e6d..b768cba 100644 --- a/library/import_masterdb.go +++ b/library/import_masterdb.go @@ -133,6 +133,7 @@ type masterDBTrack struct { PlayCount int `json:"play_count"` DateAdded string `json:"date_added"` DateCreated string `json:"date_created"` + ReleaseDate string `json:"release_date"` ColorID int `json:"color_id"` // rekordbox track-colour palette ID (1-8) AnalyzePath string `json:"analyze_path"` // /PIONEER/USBANLZ/.../ANLZ0000.DAT, relative to share/ ImagePath string `json:"image_path"` // /PIONEER/Artwork/.../artwork.jpg, relative to share/ @@ -604,18 +605,11 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { if s := fileTypeString(t.FileTypeID); s != "" { ft = s } - dateAdded, _ := time.Parse("2006-01-02", t.DateAdded) - if dateAdded.IsZero() { - // rekordbox dates may include time; try a longer layout - dateAdded, _ = time.Parse("2006-01-02 15:04:05", t.DateAdded) - } - if dateAdded.IsZero() { - dateAdded = time.Now() - } - dateCreated, _ := time.Parse("2006-01-02", t.DateCreated) - if dateCreated.IsZero() { - dateCreated, _ = time.Parse("2006-01-02 15:04:05", t.DateCreated) - } + // rekordbox reads its dates leniently by position and treats a blank or + // malformed one as no date (smart playlists skip such tracks). + dateAdded := ParseRekordboxDate(t.DateAdded) + dateCreated := ParseRekordboxDate(t.DateCreated) + releaseDate := ParseRekordboxDate(t.ReleaseDate) // Preserve the rekordbox djmdContent.ID as the library track ID so served // rows carry the same IDs rekordbox does (addTrackLocked respects a preset // ID; idMap then resolves to it identically). Fall back to auto-assign (0) @@ -641,6 +635,7 @@ func masterDBToLibrary(t *masterDBTrack, path string) *Track { Label: t.Label, DateAdded: dateAdded, DateCreated: dateCreated, + ReleaseDate: releaseDate, Bitrate: t.Bitrate, SampleRate: t.SampleRate, PlayCount: t.PlayCount, diff --git a/library/rbdate.go b/library/rbdate.go new file mode 100644 index 0000000..638f71a --- /dev/null +++ b/library/rbdate.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package library + +import ( + "strings" + "time" +) + +// ParseRekordboxDate reads a rekordbox date (StockDate, DateCreated, +// ReleaseDate, or a smart-playlist rule's date) the way rekordbox does, or +// returns the zero time when rekordbox would treat it as no date +// (link-spec conformance smart-date-format-matrix, rekordbox 7.2.19). +// +// rekordbox reads the year, month, and day from fixed positions of a +// 10-character value and ignores the characters between them, so 2025/01/31 +// and 2025 01 31 are both January 31. Each position's character counts as +// its distance from '0', so a letter makes a large digit. Out-of-range days +// and months still make a date: 2025-02-29 is rekordbox's own date, distinct +// from every other, and is kept here as the day it rolls over to. Anything +// of another length (single-digit fields, a time part, surrounding spaces), +// on or before 1970-01-01, or in year 9999 or later is no date. +func ParseRekordboxDate(s string) time.Time { + if i := strings.IndexByte(s, 0); i >= 0 { + s = s[:i] + } + r := []rune(s) + if len(r) != 10 { + return time.Time{} + } + num := func(from, to int) int { + n := 0 + for _, c := range r[from:to] { + n = n*10 + int(c-'0') + } + return n + } + year := num(0, 4) + if year <= 1969 || year >= 9999 { + return time.Time{} + } + t := time.Date(year, time.Month(num(5, 7)), num(8, 10), 0, 0, 0, 0, time.UTC) + if !t.After(time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)) || t.Year() >= 9999 { + return time.Time{} + } + return t +} diff --git a/library/rbdate_test.go b/library/rbdate_test.go new file mode 100644 index 0000000..7360415 --- /dev/null +++ b/library/rbdate_test.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package library + +import ( + "testing" + "time" +) + +// Values from link-spec conformance smart-date-format-matrix (rekordbox +// 7.2.19): which strings rekordbox reads as a date, and as which day. +func TestParseRekordboxDate(t *testing.T) { + day := func(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) } + cases := []struct { + in string + want time.Time + }{ + {"2025-01-31", day(2025, 1, 31)}, + {"2024-02-29", day(2024, 2, 29)}, + {"2025/01/31", day(2025, 1, 31)}, + {"2025.01.31", day(2025, 1, 31)}, + {"2025 01 31", day(2025, 1, 31)}, + {"2025x01y31", day(2025, 1, 31)}, + {"2025Ω01Ω31", day(2025, 1, 31)}, + {"2025\n01\n31", day(2025, 1, 31)}, + {"2025-02-29", day(2025, 3, 1)}, // rolls over, as the C runtime does + {"2025-13-01", day(2026, 1, 1)}, + {"2025-01-00", day(2024, 12, 31)}, + {"202A-01-31", day(2037, 1, 31)}, // 'A' is 17 past '0' + {"1970-01-02", day(1970, 1, 2)}, + {"1970-01-01", time.Time{}}, + {"1969-12-31", time.Time{}}, + {"0000-01-01", time.Time{}}, + {"9999-12-31", time.Time{}}, + {"2025-1-31", time.Time{}}, + {"02025-01-31", time.Time{}}, + {"2025-01-31T00:00", time.Time{}}, + {"2025-01-31 00:00:00", time.Time{}}, + {" 2025-01-31", time.Time{}}, + {"2025-01-31 ", time.Time{}}, + {"not-a-date", time.Time{}}, + {"abcdefghij", time.Time{}}, + {"0000000000", time.Time{}}, + {"+025-01-31", time.Time{}}, + {"-001-01-01", time.Time{}}, + {"2025-01-31", time.Time{}}, + {"2025-0\x00-31", time.Time{}}, + {"", time.Time{}}, + } + for _, tc := range cases { + if got := ParseRekordboxDate(tc.in); !got.Equal(tc.want) { + t.Errorf("ParseRekordboxDate(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/library/track.go b/library/track.go index 05bd9cf..ee808b4 100644 --- a/library/track.go +++ b/library/track.go @@ -70,6 +70,9 @@ type Track struct { // 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"` + // ReleaseDate is rekordbox's ReleaseDate, from an import; smart playlist + // rules on dateReleased test it (zero when unknown). + ReleaseDate time.Time `json:"release_date,omitempty"` Bitrate int `json:"bitrate"` // kbps SampleRate int `json:"sample_rate"` // Hz SampleDepth int `json:"sample_depth"` // bits diff --git a/tools/rekordbox_dump.py b/tools/rekordbox_dump.py index 0af0b7c..73abbee 100644 --- a/tools/rekordbox_dump.py +++ b/tools/rekordbox_dump.py @@ -313,7 +313,7 @@ def main(): cur.execute(""" SELECT c.ID, c.Title, c.FolderPath, c.FileNameL, c.FileSize, c.BPM, c.Length, c.BitRate, c.SampleRate, c.Rating, c.ReleaseYear, - c.TrackNo, c.DiscNo, c.Commnt, c.DJPlayCount, c.DateCreated, c.StockDate, + c.TrackNo, c.DiscNo, c.Commnt, c.DJPlayCount, c.DateCreated, c.StockDate, c.ReleaseDate, c.ColorID, c.AnalysisDataPath, c.ImagePath, c.FileType, c.HotCueAutoLoad, a.Name AS Artist, al.Name AS Album, g.Name AS Genre, @@ -377,6 +377,7 @@ def main(): # ADDED menu); DateCreated is often empty. "date_added": d.get("StockDate") or d.get("DateCreated") or "", "date_created": d.get("DateCreated") or "", # smart rules' dateCreated + "release_date": d.get("ReleaseDate") or "", # smart rules' dateReleased # ColorID is stored as TEXT ("0".."8"); coerce to the palette int. "color_id": int(d.get("ColorID") or 0), # rekordbox lookup IDs (djmd* row IDs), so served category rows can From fc1607fb1d8945511af35abd8bae8dc5e296cf64 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 20:11:35 +0000 Subject: [PATCH 2/3] api, web: show a track's date created and release date Tracks imported from rekordbox carry its DateCreated and ReleaseDate, which smart playlist rules test but nothing displayed. The track info now includes them as date_created and release_date, the track drawer shows them as CREATED and RELEASED, and the track table offers them as optional columns. --- api/api.go | 8 ++++++++ api/web/index.html | 10 +++++++++- docs/api/openapi.yaml | 2 ++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/api/api.go b/api/api.go index df2d961..1c106d8 100644 --- a/api/api.go +++ b/api/api.go @@ -763,6 +763,8 @@ type TrackInfo struct { ColorID uint8 `json:"color_id,omitempty"` ColorName string `json:"color_name,omitempty"` DateAdded string `json:"date_added,omitempty"` + DateCreated string `json:"date_created,omitempty"` // rekordbox's DateCreated, from an import + ReleaseDate string `json:"release_date,omitempty"` // rekordbox's ReleaseDate, from an import PlayCount int `json:"play_count,omitempty"` Tags []string `json:"tags,omitempty"` ArtID uint32 `json:"art_id,omitempty"` @@ -829,6 +831,12 @@ func (s *Server) libTrackToInfo(t *library.Track) TrackInfo { if !t.DateAdded.IsZero() { info.DateAdded = t.DateAdded.Format("2006-01-02") } + if !t.DateCreated.IsZero() { + info.DateCreated = t.DateCreated.Format("2006-01-02") + } + if !t.ReleaseDate.IsZero() { + info.ReleaseDate = t.ReleaseDate.Format("2006-01-02") + } if s.Tags != nil { for _, tag := range s.Tags.GetTagsForTrack(t.ID) { info.Tags = append(info.Tags, tag.Name) diff --git a/api/web/index.html b/api/web/index.html index 139bcd8..f6ac8cc 100644 --- a/api/web/index.html +++ b/api/web/index.html @@ -4056,7 +4056,7 @@ const DEFAULT_COL_WIDTHS = { waveform: 156, id: 58, title: 280, artist: 180, album: 180, genre: 150, bpm: 74, key: 66, duration: 74, year: 60, rating: 96, color: 70, - date_added: 110, play_count: 64, bitrate: 74, file_type: 70, comment: 200, + date_added: 110, date_created: 110, release_date: 110, play_count: 64, bitrate: 74, file_type: 70, comment: 200, label: 150, remixer: 160, tags: 180, file_path: 300, webui: 70, source: 90, cdj_menu: 90, my_setting: 110, djm_my_setting: 120, dev_setting: 110, }; @@ -6203,6 +6203,12 @@ { key: 'date_added', label: 'ADDED', default: false, sort: t => t.date_added || '', cell: t => `${escape(t.date_added || '')}` }, + { key: 'date_created', label: 'CREATED', default: false, + sort: t => t.date_created || '', + cell: t => `${escape(t.date_created || '')}` }, + { key: 'release_date', label: 'RELEASED', default: false, + sort: t => t.release_date || '', + cell: t => `${escape(t.release_date || '')}` }, { key: 'play_count', label: 'PLAYS', default: false, thClass: 'num', tdClass: 'num', sort: t => t.play_count || 0, cell: t => t.play_count ? String(t.play_count) : '' }, @@ -8388,6 +8394,8 @@ ['COLOR', t.color_name || '—'], ['PLAY COUNT', t.play_count || 0], ['ADDED', t.date_added || '—'], + ['CREATED', t.date_created || '—'], + ['RELEASED', t.release_date || '—'], ['LABEL', t.label || '', 'label'], ['REMIXER', t.remixer || '', 'remixer'], ['ORIG. ARTIST', t.original_artist || '', 'original_artist'], diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 627a108..5109878 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -781,6 +781,8 @@ components: color_id: { type: integer } color_name: { type: string } date_added: { type: string } + date_created: { type: string, description: "rekordbox's date created (YYYY-MM-DD), from an import." } + release_date: { type: string, description: "rekordbox's release date (YYYY-MM-DD), from an import." } play_count: { type: integer } tags: { type: array, items: { type: string } } art_id: { type: integer, format: int64 } From 0a326e3816019d428a006445ac8b803718b896e5 Mon Sep 17 00:00:00 2001 From: Vynull App Date: Sat, 17 Oct 2026 22:10:19 +0000 Subject: [PATCH 3/3] smart: read relative-date counts with rbInt rbCount duplicated the integer reading rbInt now does for numeric rule values; relative-date counts use rbInt. --- api/smart_import.go | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/api/smart_import.go b/api/smart_import.go index ee3f679..5c0e3c6 100644 --- a/api/smart_import.go +++ b/api/smart_import.go @@ -78,26 +78,6 @@ func rbDate(s string) string { return t.Format(time.RFC3339) } -// rbCount reads a relative-date count as rekordbox does (JUCE -// String::getIntValue): an optionally signed run of leading digits, so 1.5 -// is 1 and blank or other text is 0. -func rbCount(s string) int { - s = strings.TrimLeft(s, " \t\r\n") - neg := false - if s != "" && (s[0] == '-' || s[0] == '+') { - neg = s[0] == '-' - s = s[1:] - } - n := 0 - for i := 0; i < len(s) && s[i] >= '0' && s[i] <= '9' && n < 1<<24; i++ { - n = n*10 + int(s[i]-'0') - } - if neg { - return -n - } - return n -} - // rbRecent translates in-last (6) and not-in-last (7). rekordbox counts // calendar months only for the unit month, in any case; every other unit, // week and year included, counts days (smart-relative-date-matrix, @@ -110,13 +90,14 @@ func rbRecent(field string, c library.RBSmartCond) SmartCond { if c.Operator == 7 { op = "not_" + op } - return SmartCond{Field: field, Operator: op, Value: float64(rbCount(c.Left))} + return SmartCond{Field: field, Operator: op, Value: float64(rbInt(c.Left))} } // rbInt reads a numeric rule value as rekordbox does (link-spec // smart-numeric-matrix and smart-numeric-boundaries, rekordbox 7.2.19): an // optionally signed run of leading digits, so 0.5 is 0 and blank or other -// text is 0, saturating at the int32 limits. +// text is 0, saturating at the int32 limits. Relative-date counts read the +// same way. func rbInt(s string) int64 { s = strings.TrimLeft(s, " \t\r\n") neg := false