Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 27 additions & 31 deletions api/smart_import.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
Expand All @@ -68,22 +68,36 @@ 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)
}

// 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(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
Expand Down Expand Up @@ -141,7 +155,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.
Expand Down Expand Up @@ -231,27 +245,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 {
Expand Down
73 changes: 73 additions & 0 deletions api/smart_import_date_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package api

import (
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -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},
Expand Down Expand Up @@ -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)
}
}
54 changes: 43 additions & 11 deletions api/smart_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -366,27 +369,29 @@ 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
}
y1, m1, d1 := date.UTC().Date()
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
}
Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down
Loading
Loading