dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
+116
-20
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user