dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

+116 -20
View File
@@ -93,11 +93,12 @@ pub struct PlaylistItem {
pub artist: String,
pub album: Option<String>,
pub duration_secs: Option<u32>,
/// The playable local file, when resolution found one. An unresolved
/// import row keeps its metadata and order with no path at all.
/// The playable local file, when resolution found one. A stream or
/// unresolved row keeps its metadata and order with no path at all.
pub path: Option<String>,
pub art_path: Option<String>,
/// "local" (has a playable path) | "unresolved" (import placeholder).
/// "local" (a file on disk) | "stream" (full identity, plays through the
/// resolver) | "unresolved" (import placeholder awaiting repair).
pub kind: String,
/// Sanitized provenance: the raw locator or pasted line this row came
/// from. Repair tools read it; nothing ever executes it.
@@ -106,6 +107,11 @@ pub struct PlaylistItem {
/// begins, milliseconds.
pub start_ms: Option<i64>,
pub note: Option<String>,
/// A stream row's identity anchor: the catalog track id (exact id, never
/// a name match — the same idiom as plays.catalog_album_id).
pub track_id: Option<String>,
/// A stream row's cover, which has no local file to be read from.
pub art_url: Option<String>,
}
/// Insert payload for one playlist row (`add_playlist_items` / `import_playlist`).
@@ -117,11 +123,34 @@ pub struct NewPlaylistItem {
pub duration_secs: Option<u32>,
pub path: Option<String>,
pub art_path: Option<String>,
/// "local" | "unresolved"; empty defaults to "local" when a path exists.
/// "local" | "stream" | "unresolved"; empty lets the insert classify:
/// a path is local, a full identity streams, anything weaker is a
/// placeholder (see `pathless_kind`).
pub kind: String,
pub source_line: Option<String>,
pub start_ms: Option<i64>,
pub note: Option<String>,
pub track_id: Option<String>,
pub art_url: Option<String>,
}
/// Classify a row that names no local file. A full identity — an artist and
/// a real title, never the DJ unknown-token "ID" — plays through the normal
/// resolver exactly like a catalog track, so it is a first-class 'stream'
/// row. Anything weaker stays an 'unresolved' placeholder for the repair
/// tools. (The same gate the playlist page always used to decide whether a
/// click streams or opens repair.)
fn pathless_kind(artist: &str, title: &str) -> &'static str {
let t = title.trim();
if !artist.trim().is_empty()
&& !t.is_empty()
&& !t.eq_ignore_ascii_case("id")
&& !t.eq_ignore_ascii_case("i.d.")
{
"stream"
} else {
"unresolved"
}
}
/// One row of the whole-library matching index (playlist import resolution:
@@ -292,7 +321,9 @@ impl Library {
source_line TEXT,
start_ms INTEGER,
note TEXT,
added_at INTEGER NOT NULL DEFAULT 0
added_at INTEGER NOT NULL DEFAULT 0,
track_id TEXT,
art_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_playlist_items
ON playlist_items(playlist_id, position);
@@ -392,6 +423,22 @@ impl Library {
"ALTER TABLE playlists ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
[],
);
// Playlist rows split into local vs stream (2026-08-18): a row with a
// full identity and no file is a STREAMING track, not an error state.
// Its anchor is the catalog track id (exact id, never a name match —
// the plays.catalog_album_id idiom) with the cover URL riding along,
// since a stream row has no local file to read artwork from.
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN track_id TEXT", []);
let _ = conn.execute("ALTER TABLE playlist_items ADD COLUMN art_url TEXT", []);
// Reclassify rows stored before the split: anything 'unresolved' that
// carries an artist and a real title already streamed on click — the
// kind now says so. Idempotent, so it simply runs on every open.
let _ = conn.execute(
"UPDATE playlist_items SET kind = 'stream'
WHERE kind = 'unresolved' AND TRIM(artist) <> '' AND TRIM(title) <> ''
AND LOWER(TRIM(title)) NOT IN ('id', 'i.d.')",
[],
);
migrate_playlist_rows(&mut conn)?;
Ok(Library {
conn: Mutex::new(conn),
@@ -1126,7 +1173,7 @@ impl Library {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, position, title, artist, album, duration_secs, path, art_path,
kind, source_line, start_ms, note
kind, source_line, start_ms, note, track_id, art_url
FROM playlist_items WHERE playlist_id = ?1 ORDER BY position",
)?;
let rows = stmt.query_map([playlist_id], |r| {
@@ -1143,6 +1190,8 @@ impl Library {
source_line: r.get(9)?,
start_ms: r.get(10)?,
note: r.get(11)?,
track_id: r.get(12)?,
art_url: r.get(13)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
@@ -1198,12 +1247,17 @@ impl Library {
let mut ins = tx.prepare(
"INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
path, art_path, kind, source_line, start_ms, note, added_at,
track_id, art_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;
for (offset, item) in items.iter().enumerate() {
let kind = if item.kind.is_empty() {
if item.path.is_some() { "local" } else { "unresolved" }
if item.path.is_some() {
"local"
} else {
pathless_kind(&item.artist, &item.title)
}
} else {
&item.kind
};
@@ -1221,6 +1275,8 @@ impl Library {
item.start_ms,
item.note,
now,
item.track_id,
item.art_url,
])?;
}
}
@@ -1286,7 +1342,8 @@ impl Library {
}
/// Repair one row: attach a playable local file (or correct its display
/// metadata). Passing `path` Some flips the row to "local".
/// metadata). Passing `path` Some flips the row to "local". `track_id`
/// and `art_url` enrich a stream row's catalog anchor and cover.
#[allow(clippy::too_many_arguments)]
pub fn relink_playlist_item(
&self,
@@ -1298,6 +1355,8 @@ impl Library {
duration_secs: Option<u32>,
path: Option<&str>,
art_path: Option<&str>,
track_id: Option<&str>,
art_url: Option<&str>,
now: i64,
) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
@@ -1310,9 +1369,11 @@ impl Library {
duration_secs = COALESCE(?6, duration_secs),
path = COALESCE(?7, path),
art_path = COALESCE(?8, art_path),
track_id = COALESCE(?9, track_id),
art_url = COALESCE(?10, art_url),
kind = CASE WHEN ?7 IS NOT NULL THEN 'local' ELSE kind END
WHERE playlist_id = ?1 AND id = ?2",
rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path],
rusqlite::params![playlist_id, item_id, title, artist, album, duration_secs, path, art_path, track_id, art_url],
)?;
Self::touch_playlist(&tx, playlist_id, now)?;
tx.commit()?;
@@ -1337,9 +1398,11 @@ impl Library {
tx.execute(
"INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at)
path, art_path, kind, source_line, start_ms, note, added_at,
track_id, art_url)
SELECT ?2, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, ?3
path, art_path, kind, source_line, start_ms, note, ?3,
track_id, art_url
FROM playlist_items WHERE playlist_id = ?1",
rusqlite::params![id, new_id, now],
)?;
@@ -1369,12 +1432,17 @@ impl Library {
let mut ins = tx.prepare(
"INSERT INTO playlist_items
(playlist_id, position, title, artist, album, duration_secs,
path, art_path, kind, source_line, start_ms, note, added_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
path, art_path, kind, source_line, start_ms, note, added_at,
track_id, art_url)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
)?;
for (position, item) in items.iter().enumerate() {
let kind = if item.kind.is_empty() {
if item.path.is_some() { "local" } else { "unresolved" }
if item.path.is_some() {
"local"
} else {
pathless_kind(&item.artist, &item.title)
}
} else {
&item.kind
};
@@ -1392,6 +1460,8 @@ impl Library {
item.start_ms,
item.note,
now,
item.track_id,
item.art_url,
])?;
}
}
@@ -2261,7 +2331,9 @@ mod tests {
art_path: art.map(String::from),
..Default::default()
};
// Two copies of the same file, on purpose, plus an unresolved import row.
// Two copies of the same file, on purpose, plus a pathless row with a
// full identity (a streaming track, first-class) and a genuinely
// unknown one (the DJ token "ID" stays a placeholder for repair).
lib.add_playlist_items(
id,
&[
@@ -2274,10 +2346,31 @@ mod tests {
110,
)
.unwrap();
lib.add_playlist_items(
id,
&[NewPlaylistItem {
title: "ID".into(),
artist: "Artist".into(),
..Default::default()
}],
None,
111,
)
.unwrap();
let items = lib.playlist_items(id).unwrap();
assert_eq!(items.len(), 5);
assert_eq!(
items[3].kind, "stream",
"a pathless row with full identity streams — never dropped, never an error"
);
assert_eq!(
items[4].kind, "unresolved",
"the DJ unknown-token stays a placeholder for repair"
);
lib.remove_playlist_items(id, &[items[4].id], 112).unwrap();
let items = lib.playlist_items(id).unwrap();
assert_eq!(items.len(), 4);
assert_eq!(items[3].kind, "unresolved", "no path means placeholder, never dropped");
// Reorder: move the SECOND "One" first; the two copies stay distinct.
let ids: Vec<i64> = items.iter().map(|i| i.id).collect();
@@ -2308,9 +2401,10 @@ mod tests {
assert_eq!(items.len(), 4);
// Aggregates: unresolved count, duration sum, distinct art seeds.
// "Ghost" is a stream row (full identity), so nothing is unresolved.
let meta = lib.playlist_meta(id).unwrap().unwrap();
assert_eq!(meta.track_count, 4);
assert_eq!(meta.unresolved_count, 1);
assert_eq!(meta.unresolved_count, 0);
assert_eq!(meta.duration_secs, 400, "all four rows carry 100s");
assert_eq!(meta.art_seeds, vec!["C:\\m\\a.jpg".to_string(), "C:\\m\\b.jpg".into()]);
assert!(meta.revision > 0);
@@ -2349,7 +2443,7 @@ mod tests {
.unwrap();
let rows = lib.playlist_items(imported).unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].kind, "unresolved");
assert_eq!(rows[0].kind, "stream", "full identity, no file: streams");
assert_eq!(rows[1].kind, "local");
assert_eq!(rows[1].start_ms, Some(3_600_000));
let meta = lib.playlist_meta(imported).unwrap().unwrap();
@@ -2365,6 +2459,8 @@ mod tests {
Some(240),
Some("C:\\m\\opener.mp3"),
None,
None,
None,
170,
)
.unwrap();