fed fixes
This commit is contained in:
+12
-8
@@ -10,7 +10,7 @@ use lofty::picture::MimeType;
|
||||
use lofty::tag::{Accessor as _, ItemKey};
|
||||
use rusqlite::{OptionalExtension as _, params};
|
||||
|
||||
use super::{Library, find_or_create_artist};
|
||||
use super::{Library, audio_content_id, find_or_create_artist};
|
||||
|
||||
/// Extensions the playback engine can decode (rodio/symphonia feature set).
|
||||
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
|
||||
@@ -213,6 +213,7 @@ pub fn read_file(path: &Path) -> Result<TrackImport> {
|
||||
/// Insert or update one track (matching by file path). Returns the track id
|
||||
/// and whether a new row was created.
|
||||
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
|
||||
let content_id = audio_content_id(&import.file_path);
|
||||
let mut conn = library.lock();
|
||||
let tx = conn.transaction()?;
|
||||
|
||||
@@ -280,7 +281,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
|
||||
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
||||
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
|
||||
file_size_bytes = ?11
|
||||
file_size_bytes = ?11, content_id = ?12
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
@@ -294,6 +295,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
|
||||
@@ -303,8 +305,8 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
tx.execute(
|
||||
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
||||
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
||||
audio_bit_depth, file_size_bytes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
audio_bit_depth, file_size_bytes, content_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
import.title,
|
||||
import.track_number,
|
||||
@@ -317,6 +319,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
(tx.last_insert_rowid(), true)
|
||||
@@ -348,9 +351,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_cover
|
||||
&& let Some(cover_path) = resolve_cover(library, release_id, import)
|
||||
{
|
||||
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
|
||||
tx.execute(
|
||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||
params![release_id, cover_path],
|
||||
@@ -381,7 +382,10 @@ fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Op
|
||||
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
|
||||
);
|
||||
if is_image
|
||||
&& matches!(stem.as_deref(), Some("cover" | "folder" | "front" | "album"))
|
||||
&& matches!(
|
||||
stem.as_deref(),
|
||||
Some("cover" | "folder" | "front" | "album")
|
||||
)
|
||||
{
|
||||
return Some(path.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
+194
-39
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tracks (
|
||||
duration_seconds REAL NOT NULL DEFAULT 0,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
content_id TEXT,
|
||||
audio_format TEXT,
|
||||
audio_bitrate INTEGER,
|
||||
audio_sample_rate INTEGER,
|
||||
@@ -86,8 +87,10 @@ CREATE TABLE IF NOT EXISTS fed_likes (
|
||||
owner TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
artist_names TEXT NOT NULL DEFAULT '',
|
||||
featured_artist_names TEXT NOT NULL DEFAULT '',
|
||||
year INTEGER,
|
||||
duration_seconds REAL,
|
||||
content_id TEXT,
|
||||
release_title TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
@@ -115,6 +118,7 @@ const TRACK_COLUMNS: &str = "
|
||||
t.release_id, r.title, r.year, r.cover_path,
|
||||
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
||||
t.audio_bit_depth, t.file_size_bytes,
|
||||
t.content_id,
|
||||
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
|
||||
";
|
||||
|
||||
@@ -143,6 +147,12 @@ pub struct ExportTrack {
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
pub artist_names: Vec<String>,
|
||||
pub featured_artist_names: Vec<String>,
|
||||
pub release_title: String,
|
||||
pub release_type: String,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub content_id: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Library {
|
||||
@@ -169,6 +179,7 @@ impl Library {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
register_norm_function(&conn)?;
|
||||
conn.execute_batch(SCHEMA).context("applying schema")?;
|
||||
ensure_schema_migrations(&conn)?;
|
||||
let covers_dir = db_path
|
||||
.parent()
|
||||
.map(|dir| dir.join("covers"))
|
||||
@@ -254,7 +265,7 @@ impl Library {
|
||||
JOIN releases r ON r.id = t.release_id
|
||||
JOIN track_artists ta ON ta.track_id = t.id
|
||||
WHERE ta.artist_id = ?1 AND ta.role = 'main'
|
||||
ORDER BY 16 DESC, t.title COLLATE NOCASE
|
||||
ORDER BY 17 DESC, t.title COLLATE NOCASE
|
||||
LIMIT 10"
|
||||
),
|
||||
params![id],
|
||||
@@ -395,11 +406,13 @@ impl Library {
|
||||
),
|
||||
params![pattern, limit],
|
||||
)?;
|
||||
Ok(SearchResults {
|
||||
let mut results = SearchResults {
|
||||
artists,
|
||||
releases,
|
||||
tracks,
|
||||
})
|
||||
};
|
||||
rank_search_results(&mut results, &pattern);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Everything the federation publishes into the DHT: plain rows, so the
|
||||
@@ -417,12 +430,13 @@ impl Library {
|
||||
ORDER BY ra.release_id, ra.position",
|
||||
)?;
|
||||
let mut release_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||
for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? {
|
||||
for row in statement.query_map([], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})? {
|
||||
let (id, name) = row?;
|
||||
release_artists.entry(id).or_default().push(name);
|
||||
}
|
||||
let mut statement =
|
||||
conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
||||
let mut statement = conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
||||
let releases = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ExportRelease {
|
||||
@@ -442,36 +456,76 @@ impl Library {
|
||||
.collect();
|
||||
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT ta.track_id, a.name FROM track_artists ta
|
||||
"SELECT ta.track_id, a.name, ta.role FROM track_artists ta
|
||||
JOIN artists a ON a.id = ta.artist_id
|
||||
ORDER BY ta.track_id, ta.position",
|
||||
WHERE ta.role IN ('main', 'featured')
|
||||
ORDER BY ta.track_id,
|
||||
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||
ta.position",
|
||||
)?;
|
||||
let mut track_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||
for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? {
|
||||
let (id, name) = row?;
|
||||
track_artists.entry(id).or_default().push(name);
|
||||
let mut featured_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||
for row in statement.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})? {
|
||||
let (id, name, role) = row?;
|
||||
if role == "featured" {
|
||||
featured_artists.entry(id).or_default().push(name);
|
||||
} else {
|
||||
track_artists.entry(id).or_default().push(name);
|
||||
}
|
||||
}
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT t.id, t.title, r.year, t.duration_seconds
|
||||
"SELECT t.id, t.title, r.year, t.duration_seconds,
|
||||
r.title, r.release_type, t.track_number, t.disc_number,
|
||||
t.content_id, t.file_path
|
||||
FROM tracks t JOIN releases r ON r.id = t.release_id",
|
||||
)?;
|
||||
let tracks = statement
|
||||
let track_rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ExportTrack {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
year: row.get(2)?,
|
||||
duration_seconds: row.get(3)?,
|
||||
artist_names: Vec::new(),
|
||||
})
|
||||
Ok((
|
||||
ExportTrack {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
year: row.get(2)?,
|
||||
duration_seconds: row.get(3)?,
|
||||
artist_names: Vec::new(),
|
||||
featured_artist_names: Vec::new(),
|
||||
release_title: row.get(4)?,
|
||||
release_type: row.get(5)?,
|
||||
track_number: row.get(6)?,
|
||||
disc_number: row.get(7)?,
|
||||
content_id: None,
|
||||
},
|
||||
row.get::<_, Option<String>>(8)?,
|
||||
row.get::<_, String>(9)?,
|
||||
))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.map(|mut track| {
|
||||
track.artist_names = track_artists.remove(&track.id).unwrap_or_default();
|
||||
track
|
||||
})
|
||||
.collect();
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
drop(statement);
|
||||
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||
for (mut track, raw_content_id, file_path) in track_rows {
|
||||
let mut content_id = raw_content_id
|
||||
.as_deref()
|
||||
.and_then(music_dht::normalize_content_id);
|
||||
if content_id.is_none() {
|
||||
content_id = audio_content_id(&file_path);
|
||||
}
|
||||
if content_id != raw_content_id {
|
||||
conn.execute(
|
||||
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||
params![track.id, content_id.as_deref()],
|
||||
)?;
|
||||
}
|
||||
track.artist_names = track_artists.remove(&track.id).unwrap_or_default();
|
||||
track.featured_artist_names = featured_artists.remove(&track.id).unwrap_or_default();
|
||||
track.content_id = content_id;
|
||||
tracks.push(track);
|
||||
}
|
||||
|
||||
Ok(FederationExport {
|
||||
artists,
|
||||
@@ -649,8 +703,8 @@ impl Library {
|
||||
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT item_id, owner, title, artist_names, year, duration_seconds,
|
||||
release_title, track_number, disc_number
|
||||
"SELECT item_id, owner, title, artist_names, featured_artist_names,
|
||||
year, duration_seconds, content_id, release_title, track_number, disc_number
|
||||
FROM fed_likes ORDER BY liked_at DESC",
|
||||
)?;
|
||||
let rows = statement
|
||||
@@ -666,11 +720,18 @@ impl Library {
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
year: row.get(4)?,
|
||||
duration_seconds: row.get::<_, Option<f64>>(5)?.map(|d| d.round() as i64),
|
||||
release_title: row.get(6)?,
|
||||
track_number: row.get(7)?,
|
||||
disc_number: row.get(8)?,
|
||||
featured_artist_names: row
|
||||
.get::<_, String>(4)?
|
||||
.split("; ")
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
year: row.get(5)?,
|
||||
duration_seconds: row.get::<_, Option<f64>>(6)?.map(|d| d.round() as i64),
|
||||
content_id: row.get(7)?,
|
||||
release_title: row.get(8)?,
|
||||
track_number: row.get(9)?,
|
||||
disc_number: row.get(10)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
@@ -695,16 +756,19 @@ impl Library {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names, year,
|
||||
duration_seconds, release_title, track_number, disc_number)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
||||
featured_artist_names, year, duration_seconds, content_id,
|
||||
release_title, track_number, disc_number)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
fed.item_id,
|
||||
fed.owner,
|
||||
fed.title,
|
||||
fed.artist_names.join("; "),
|
||||
fed.featured_artist_names.join("; "),
|
||||
fed.year,
|
||||
fed.duration_seconds.map(|d| d as f64),
|
||||
fed.content_id,
|
||||
fed.release_title,
|
||||
fed.track_number,
|
||||
fed.disc_number,
|
||||
@@ -985,6 +1049,44 @@ fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result<ReleaseCard> {
|
||||
})
|
||||
}
|
||||
|
||||
fn rank_search_results(results: &mut SearchResults, normalized_query: &str) {
|
||||
results
|
||||
.artists
|
||||
.sort_by_key(|artist| exact_match_rank(&artist.name, normalized_query));
|
||||
results
|
||||
.releases
|
||||
.sort_by_key(|release| exact_match_rank(&release.title, normalized_query));
|
||||
results
|
||||
.tracks
|
||||
.sort_by_key(|track| track_match_rank(track, normalized_query));
|
||||
}
|
||||
|
||||
fn exact_match_rank(value: &str, normalized_query: &str) -> u8 {
|
||||
if music_dht::normalize_name(value) == normalized_query {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn track_match_rank(track: &TrackItem, normalized_query: &str) -> u8 {
|
||||
if music_dht::normalize_name(&track.title) == normalized_query {
|
||||
return 0;
|
||||
}
|
||||
if music_dht::normalize_name(&track.release_title) == normalized_query {
|
||||
return 1;
|
||||
}
|
||||
if track
|
||||
.artists
|
||||
.iter()
|
||||
.chain(track.featured_artists.iter())
|
||||
.any(|artist| music_dht::normalize_name(&artist.name) == normalized_query)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
3
|
||||
}
|
||||
|
||||
/// Run a track query built on TRACK_COLUMNS and attach artist lists.
|
||||
fn query_tracks(
|
||||
conn: &Connection,
|
||||
@@ -1010,7 +1112,11 @@ fn query_tracks(
|
||||
audio_sample_rate: row.get(12)?,
|
||||
audio_bit_depth: row.get(13)?,
|
||||
file_size_bytes: row.get(14)?,
|
||||
play_count: row.get(15)?,
|
||||
content_id: row
|
||||
.get::<_, Option<String>>(15)?
|
||||
.as_deref()
|
||||
.and_then(music_dht::normalize_content_id),
|
||||
play_count: row.get(16)?,
|
||||
artists: Vec::new(),
|
||||
featured_artists: Vec::new(),
|
||||
fed: None,
|
||||
@@ -1064,6 +1170,42 @@ fn register_norm_function(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn audio_content_id(path: &str) -> Option<String> {
|
||||
let mut file = std::fs::File::open(path).ok()?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||
}
|
||||
|
||||
fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
|
||||
let fed_like_columns = table_columns(conn, "fed_likes")?;
|
||||
if !fed_like_columns
|
||||
.iter()
|
||||
.any(|column| column == "featured_artist_names")
|
||||
{
|
||||
conn.execute(
|
||||
"ALTER TABLE fed_likes
|
||||
ADD COLUMN featured_artist_names TEXT NOT NULL DEFAULT ''",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
if !fed_like_columns.iter().any(|column| column == "content_id") {
|
||||
conn.execute("ALTER TABLE fed_likes ADD COLUMN content_id TEXT", [])?;
|
||||
}
|
||||
let track_columns = table_columns(conn, "tracks")?;
|
||||
if !track_columns.iter().any(|column| column == "content_id") {
|
||||
conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table_columns(conn: &Connection, table: &str) -> Result<Vec<String>> {
|
||||
let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||
Ok(statement
|
||||
.query_map([], |row| row.get::<_, String>(1))?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1143,6 +1285,18 @@ mod tests {
|
||||
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_ranks_exact_names_first() {
|
||||
let lib = test_library();
|
||||
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
|
||||
add_track(&lib, "Needle", "Needle", "Needle");
|
||||
|
||||
let results = lib.search("needle", 10).unwrap();
|
||||
assert_eq!(results.artists[0].name, "Needle");
|
||||
assert_eq!(results.releases[0].title, "Needle");
|
||||
assert_eq!(results.tracks[0].title, "Needle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_folds_case_beyond_ascii() {
|
||||
let lib = test_library();
|
||||
@@ -1159,7 +1313,8 @@ mod tests {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
let playlist = lib.create_playlist("Mix").unwrap();
|
||||
lib.add_tracks_to_playlist(playlist.id, &[track_id]).unwrap();
|
||||
lib.add_tracks_to_playlist(playlist.id, &[track_id])
|
||||
.unwrap();
|
||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
||||
|
||||
assert!(lib.toggle_like(track_id).unwrap());
|
||||
|
||||
+59
-5
@@ -31,6 +31,8 @@ pub struct TrackItem {
|
||||
pub release_year: Option<i32>,
|
||||
/// Absolute path to the local audio file.
|
||||
pub file_path: String,
|
||||
/// Stable audio content id (`b3:<64 hex>`) when known.
|
||||
pub content_id: Option<String>,
|
||||
/// Path to a local cover image (the release cover).
|
||||
pub cover_path: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
@@ -53,12 +55,24 @@ impl TrackItem {
|
||||
}
|
||||
|
||||
pub fn artist_line(&self) -> String {
|
||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
if !self.featured_artists.is_empty() {
|
||||
names.push("feat.");
|
||||
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
|
||||
let artists = self
|
||||
.artists
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let featured = self
|
||||
.featured_artists
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
match (artists.is_empty(), featured.is_empty()) {
|
||||
(false, false) => format!("{artists} feat. {featured}"),
|
||||
(false, true) => artists,
|
||||
(true, false) => format!("feat. {featured}"),
|
||||
(true, true) => String::new(),
|
||||
}
|
||||
names.join(", ")
|
||||
}
|
||||
|
||||
pub fn duration_label(&self) -> String {
|
||||
@@ -183,3 +197,43 @@ pub struct ReleaseEdit {
|
||||
pub year: Option<i32>,
|
||||
pub artists: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn artist(name: &str) -> ArtistRef {
|
||||
ArtistRef {
|
||||
id: 1,
|
||||
name: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_line_formats_featured_artists() {
|
||||
let track = TrackItem {
|
||||
id: 1,
|
||||
title: "Track".into(),
|
||||
track_number: None,
|
||||
disc_number: None,
|
||||
duration_seconds: 1.0,
|
||||
artists: vec![artist("Main")],
|
||||
featured_artists: vec![artist("Guest"), artist("Other")],
|
||||
release_id: 1,
|
||||
release_title: "Release".into(),
|
||||
release_year: None,
|
||||
file_path: "/tmp/track.mp3".into(),
|
||||
content_id: None,
|
||||
cover_path: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
audio_bit_depth: None,
|
||||
file_size_bytes: None,
|
||||
play_count: 0,
|
||||
fed: None,
|
||||
};
|
||||
|
||||
assert_eq!(track.artist_line(), "Main feat. Guest, Other");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user