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 @@

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 +11142,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 +11329,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 +11429,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 +11441,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/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 } 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