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/smart_import.go b/api/smart_import.go index 9597c96..5c0e3c6 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,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 @@ -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. @@ -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 { 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..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'], @@ -11125,6 +11133,8 @@