fed fixes
This commit is contained in:
+6
-2
@@ -21,8 +21,12 @@ pub enum Action {
|
||||
PlayPause,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
SeekForward { seconds: u32 },
|
||||
SeekBackward { seconds: u32 },
|
||||
SeekForward {
|
||||
seconds: u32,
|
||||
},
|
||||
SeekBackward {
|
||||
seconds: u32,
|
||||
},
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleShuffle,
|
||||
|
||||
@@ -49,6 +49,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
// One-shot commands have no live effect.
|
||||
Command::Quit
|
||||
| Command::Import(_)
|
||||
| Command::Open(_)
|
||||
| Command::Volume(_)
|
||||
| Command::Seek(_)
|
||||
| Command::SeekTo(_)
|
||||
@@ -165,6 +166,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
Command::Search(_) => {}
|
||||
Command::Quit => state.should_quit = true,
|
||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||
Command::Volume(value) => {
|
||||
state.player.volume = value;
|
||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||
@@ -208,6 +210,26 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_frid_link(state: &mut AppState, runtime: &Runtime, link: String) {
|
||||
let Some(content_id) = crate::share::parse_frid_content_id(&link) else {
|
||||
state.status_message = Some("usage: :open frid://<content_id>".into());
|
||||
return;
|
||||
};
|
||||
state.status_message = Some("federation: opening shared track…".into());
|
||||
let federation = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let event = match federation.track_by_content_id(&content_id).await {
|
||||
Ok(track) => AppEvent::EnqueueTracks {
|
||||
tracks: vec![crate::federation::pending_track(&track)],
|
||||
next: false,
|
||||
},
|
||||
Err(err) => AppEvent::StatusMessage(format!("open failed: {err:#}")),
|
||||
};
|
||||
let _ = tx.send(event);
|
||||
});
|
||||
}
|
||||
|
||||
/// Esc: close the line and undo any live effect it had.
|
||||
fn cancel(state: &mut AppState) {
|
||||
retract_live(state);
|
||||
|
||||
@@ -25,6 +25,8 @@ pub enum Command {
|
||||
/// `:import <path>` — import an audio file or a directory into the
|
||||
/// library.
|
||||
Import(String),
|
||||
/// `:open frid://...` — open a shared federation content link.
|
||||
Open(String),
|
||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||
Volume(u8),
|
||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
||||
@@ -82,6 +84,13 @@ pub fn parse(input: &str) -> Parsed {
|
||||
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
|
||||
}
|
||||
}
|
||||
"open" => {
|
||||
let value = input.trim_start().split_once(char::is_whitespace);
|
||||
match value.map(|(_, rest)| rest.trim()) {
|
||||
Some(value) if !value.is_empty() => Parsed::Command(Command::Open(value.into())),
|
||||
_ => Parsed::Invalid("usage: :open frid://<content_id>".to_string()),
|
||||
}
|
||||
}
|
||||
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||
@@ -162,7 +171,17 @@ mod tests {
|
||||
parse("import ~/Music/My Album"),
|
||||
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse(
|
||||
"open frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
|
||||
),
|
||||
Parsed::Command(Command::Open(
|
||||
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
|
||||
.to_string()
|
||||
))
|
||||
);
|
||||
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
||||
assert!(matches!(parse("open"), Parsed::Invalid(_)));
|
||||
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||
|
||||
@@ -119,6 +119,13 @@ pub enum AppEvent {
|
||||
placeholder_id: i64,
|
||||
result: Result<Box<crate::federation::FedPlayable>, String>,
|
||||
},
|
||||
/// Rich metadata for a federated track-info preview arrived without
|
||||
/// downloading the audio file.
|
||||
FedTrackInfoLoaded {
|
||||
placeholder_id: i64,
|
||||
item_id: String,
|
||||
result: Result<TrackItem, String>,
|
||||
},
|
||||
/// This peer's connection ticket, requested from the Federation tab.
|
||||
FedTicket(Result<String, String>),
|
||||
}
|
||||
|
||||
+64
-25
@@ -1,8 +1,8 @@
|
||||
pub mod action;
|
||||
mod cmdline;
|
||||
pub mod input;
|
||||
pub mod command;
|
||||
pub mod event;
|
||||
pub mod input;
|
||||
mod popup;
|
||||
pub mod state;
|
||||
pub mod update;
|
||||
@@ -290,9 +290,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
}
|
||||
for detail in state.release_views.values() {
|
||||
if let state::Loadable::Ready(detail) = detail
|
||||
&& let Some(path) = &detail.cover_path {
|
||||
wanted.push((path.clone(), header.0, header.1));
|
||||
}
|
||||
&& let Some(path) = &detail.cover_path
|
||||
{
|
||||
wanted.push((path.clone(), header.0, header.1));
|
||||
}
|
||||
}
|
||||
if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
|
||||
if let Some(path) = &card.image_path {
|
||||
@@ -501,6 +502,26 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
||||
});
|
||||
}
|
||||
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
|
||||
Effect::FedFetchTrackInfo { tracks } => {
|
||||
for (placeholder_id, fed_track) in tracks {
|
||||
let federation = Arc::clone(&runtime.federation);
|
||||
let tx = runtime.event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut preview = crate::federation::pending_track(&fed_track);
|
||||
preview.id = placeholder_id;
|
||||
let item_id = fed_track.item_id.clone();
|
||||
let result = federation
|
||||
.track_info(preview)
|
||||
.await
|
||||
.map_err(|err| format!("{err:#}"));
|
||||
let _ = tx.send(AppEvent::FedTrackInfoLoaded {
|
||||
placeholder_id,
|
||||
item_id,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Effect::RemoveQueueIndices {
|
||||
restart_paused,
|
||||
stop,
|
||||
@@ -900,9 +921,10 @@ pub(super) fn spawn_import(state: &mut AppState, runtime: &Runtime, path: &str)
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
&& let Some(home) = std::env::home_dir()
|
||||
{
|
||||
return home.join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
@@ -1071,11 +1093,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.federation.status = Some(status);
|
||||
}
|
||||
AppEvent::FedSearchLoaded { seq, result } => {
|
||||
if runtime
|
||||
.search_seq
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
!= seq
|
||||
{
|
||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
state.search.fed_loading = false;
|
||||
@@ -1153,6 +1171,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::FedTrackInfoLoaded {
|
||||
placeholder_id,
|
||||
item_id,
|
||||
result,
|
||||
} => match result {
|
||||
Ok(enriched) => {
|
||||
if let Some(state::Popup::TrackInfo { tracks, .. }) = &mut state.popup
|
||||
&& let Some(slot) = tracks.iter_mut().find(|track| {
|
||||
track.id == placeholder_id
|
||||
&& track.fed.as_ref().is_some_and(|fed| fed.item_id == item_id)
|
||||
})
|
||||
{
|
||||
*slot = enriched;
|
||||
}
|
||||
}
|
||||
Err(message) => {
|
||||
state.status_message = Some(format!("federation metadata: {message}"));
|
||||
}
|
||||
},
|
||||
AppEvent::FedArtistLoaded { name, result } => {
|
||||
if let Some((current, data)) = &mut state.fed_artist_view
|
||||
&& *current == name
|
||||
@@ -1250,21 +1287,23 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.release_views.insert(id, entry);
|
||||
// A Shift-J jump was waiting for this release: focus its track.
|
||||
if let Some((release_id, track_id)) = state.pending_release_focus
|
||||
&& release_id == id {
|
||||
state.pending_release_focus = None;
|
||||
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
||||
let position = detail
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.id == track_id)
|
||||
.unwrap_or(0);
|
||||
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
||||
state.global.stack.last_mut()
|
||||
&& *top == release_id {
|
||||
*cursor = position;
|
||||
}
|
||||
&& release_id == id
|
||||
{
|
||||
state.pending_release_focus = None;
|
||||
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
||||
let position = detail
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.id == track_id)
|
||||
.unwrap_or(0);
|
||||
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
||||
state.global.stack.last_mut()
|
||||
&& *top == release_id
|
||||
{
|
||||
*cursor = position;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SearchLoaded { seq, result } => {
|
||||
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
|
||||
+69
-1
@@ -3,6 +3,8 @@
|
||||
//! the state, handled as an owned value and put back unless the action
|
||||
//! closed it.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
@@ -111,7 +113,10 @@ pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
// Edit form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
reason = "owned popup state passed back in"
|
||||
)]
|
||||
fn handle_edit(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
@@ -342,6 +347,22 @@ fn handle_track_info(
|
||||
scroll: 0,
|
||||
});
|
||||
}
|
||||
KeyCode::Char('c') => {
|
||||
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
|
||||
match crate::share::track_share_link(track) {
|
||||
Some(link) => match copy_to_clipboard(&link) {
|
||||
Ok(()) => state.status_message = Some("frid link copied".into()),
|
||||
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
|
||||
},
|
||||
None => state.status_message = Some("no content id for this track yet".into()),
|
||||
}
|
||||
}
|
||||
state.popup = Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor: cursor.min(len.saturating_sub(1)),
|
||||
scroll,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
state.popup = Some(Popup::TrackInfo {
|
||||
tracks,
|
||||
@@ -352,6 +373,53 @@ fn handle_track_info(
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return run_clipboard_command("pbcopy", &[], text);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return run_clipboard_command("cmd", &["/C", "clip"], text);
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
for (program, args) in [
|
||||
("wl-copy", &[][..]),
|
||||
("xclip", &["-selection", "clipboard"][..]),
|
||||
("xsel", &["--clipboard", "--input"][..]),
|
||||
] {
|
||||
if run_clipboard_command(program, args, text).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err("clipboard command not found (tried wl-copy, xclip, xsel)".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn run_clipboard_command(program: &str, args: &[&str], text: &str) -> Result<(), String> {
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|err| format!("{program}: {err}"))?;
|
||||
let Some(stdin) = child.stdin.as_mut() else {
|
||||
return Err(format!("{program}: stdin unavailable"));
|
||||
};
|
||||
stdin
|
||||
.write_all(text.as_bytes())
|
||||
.map_err(|err| format!("{program}: {err}"))?;
|
||||
drop(child.stdin.take());
|
||||
let status = child.wait().map_err(|err| format!("{program}: {err}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{program}: exited with {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Add-to-playlist picker & new playlist
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+45
-7
@@ -120,24 +120,31 @@ impl Default for GlobalTab {
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases of an artist in display order: grouped by type (albums, EPs,
|
||||
/// singles, compilations, then anything else), keeping server order within a
|
||||
/// Releases of an artist in display order: grouped by type (albums, singles,
|
||||
/// EPs, compilations, then anything else), keeping server order within a
|
||||
/// group. Returns (group label, indices into the original slice). Cursor
|
||||
/// positions use this flattened order, so update() and ui must both go
|
||||
/// through here.
|
||||
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
|
||||
release_type_groups(releases, |release| &release.release_type)
|
||||
}
|
||||
|
||||
fn release_type_groups<T>(
|
||||
items: &[T],
|
||||
release_type: impl Fn(&T) -> &str,
|
||||
) -> Vec<(&'static str, Vec<usize>)> {
|
||||
const GROUPS: [(&str, &str); 4] = [
|
||||
("album", "Albums"),
|
||||
("ep", "EPs"),
|
||||
("single", "Singles"),
|
||||
("ep", "EPs"),
|
||||
("compilation", "Compilations"),
|
||||
];
|
||||
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
||||
for (kind, label) in GROUPS {
|
||||
let indices: Vec<usize> = releases
|
||||
let indices: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
|
||||
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if !indices.is_empty() {
|
||||
@@ -145,7 +152,7 @@ pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>
|
||||
}
|
||||
}
|
||||
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
|
||||
let other: Vec<usize> = (0..releases.len()).filter(|i| !known.contains(i)).collect();
|
||||
let other: Vec<usize> = (0..items.len()).filter(|i| !known.contains(i)).collect();
|
||||
if !other.is_empty() {
|
||||
groups.push(("Other", other));
|
||||
}
|
||||
@@ -164,10 +171,39 @@ pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
|
||||
/// rows, chunked by the column count. Values are display-order positions.
|
||||
/// Vertical cursor movement must follow these rows to match the rendering.
|
||||
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
|
||||
grouped_release_rows(release_groups(releases), columns)
|
||||
}
|
||||
|
||||
pub fn fed_release_groups(
|
||||
releases: &[crate::federation::FedRelease],
|
||||
) -> Vec<(&'static str, Vec<usize>)> {
|
||||
release_type_groups(releases, |release| &release.release_type)
|
||||
}
|
||||
|
||||
/// Flattened display order of federated releases (concatenated groups).
|
||||
pub fn fed_release_display_order(releases: &[crate::federation::FedRelease]) -> Vec<usize> {
|
||||
fed_release_groups(releases)
|
||||
.into_iter()
|
||||
.flat_map(|(_, indices)| indices)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Visual tile-grid rows of the federated releases section.
|
||||
pub fn fed_release_rows(
|
||||
releases: &[crate::federation::FedRelease],
|
||||
columns: usize,
|
||||
) -> Vec<Vec<usize>> {
|
||||
grouped_release_rows(fed_release_groups(releases), columns)
|
||||
}
|
||||
|
||||
fn grouped_release_rows(
|
||||
groups: Vec<(&'static str, Vec<usize>)>,
|
||||
columns: usize,
|
||||
) -> Vec<Vec<usize>> {
|
||||
let columns = columns.max(1);
|
||||
let mut rows = Vec::new();
|
||||
let mut position = 0;
|
||||
for (_, group) in release_groups(releases) {
|
||||
for (_, group) in groups {
|
||||
for chunk in group.chunks(columns) {
|
||||
rows.push((position..position + chunk.len()).collect());
|
||||
position += chunk.len();
|
||||
@@ -212,6 +248,8 @@ pub enum TrackSelectionScope {
|
||||
FedSearch,
|
||||
/// The tracklist of the open federated release view.
|
||||
FedRelease(usize),
|
||||
/// The appears-on track list of the open federated artist card.
|
||||
FedAppearsOn,
|
||||
}
|
||||
|
||||
/// Vim-like Shift-V selection for line-oriented track lists. The selected
|
||||
|
||||
+274
-59
@@ -5,7 +5,8 @@ use crate::library::models::TrackItem;
|
||||
|
||||
use super::state::{
|
||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||
TrackSelectionScope, ViewMode, release_display_order, release_rows,
|
||||
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
|
||||
release_display_order, release_rows,
|
||||
};
|
||||
|
||||
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
|
||||
@@ -54,6 +55,10 @@ pub enum Effect {
|
||||
FedDownload {
|
||||
tracks: Vec<crate::federation::FedTrack>,
|
||||
},
|
||||
/// Fetch richer metadata for federated tracks without downloading audio.
|
||||
FedFetchTrackInfo {
|
||||
tracks: Vec<(i64, crate::federation::FedTrack)>,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
@@ -280,14 +285,25 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
let tracks = selected_tracks(state);
|
||||
if tracks.is_empty() {
|
||||
state.status_message = Some("no track selected".into());
|
||||
None
|
||||
} else {
|
||||
let fed_tracks = tracks
|
||||
.iter()
|
||||
.filter(|track| track_info_needs_fed_metadata(track))
|
||||
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
|
||||
.collect::<Vec<_>>();
|
||||
state.popup = Some(super::state::Popup::TrackInfo {
|
||||
tracks,
|
||||
cursor: 0,
|
||||
scroll: 0,
|
||||
});
|
||||
if fed_tracks.is_empty() {
|
||||
None
|
||||
} else {
|
||||
state.status_message = Some("federation: fetching track metadata…".to_string());
|
||||
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
||||
Action::QueueAddNext => queue_add(state, true),
|
||||
@@ -372,6 +388,17 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
}
|
||||
}
|
||||
|
||||
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
||||
track.fed.is_some()
|
||||
&& (track.featured_artists.is_empty()
|
||||
|| track.audio_format.is_none()
|
||||
|| track.audio_bitrate.is_none()
|
||||
|| track.audio_sample_rate.is_none()
|
||||
|| track.audio_bit_depth.is_none()
|
||||
|| track.file_size_bytes.is_none()
|
||||
|| track.file_path.is_empty())
|
||||
}
|
||||
|
||||
/// `e`: open the metadata edit form for whatever is under the cursor —
|
||||
/// an artist tile, a release, a track or a playlist.
|
||||
fn open_edit_popup(state: &mut AppState) {
|
||||
@@ -402,11 +429,19 @@ fn open_edit_popup(state: &mut AppState) {
|
||||
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
|
||||
return;
|
||||
};
|
||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
||||
state.popup = Some(artist_edit_popup(
|
||||
artist.id,
|
||||
&artist.name,
|
||||
artist.image_path,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Some(artist) = selected_search_artist(state) {
|
||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
||||
state.popup = Some(artist_edit_popup(
|
||||
artist.id,
|
||||
&artist.name,
|
||||
artist.image_path,
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Some(release) = selected_release_card(state) {
|
||||
@@ -433,11 +468,7 @@ fn open_edit_popup(state: &mut AppState) {
|
||||
state.status_message = Some("nothing to edit here".into());
|
||||
}
|
||||
|
||||
fn artist_edit_popup(
|
||||
id: i64,
|
||||
name: &str,
|
||||
image_path: Option<String>,
|
||||
) -> super::state::Popup {
|
||||
fn artist_edit_popup(id: i64, name: &str, image_path: Option<String>) -> super::state::Popup {
|
||||
use super::state::{EditField, EditTarget, Popup};
|
||||
Popup::Edit {
|
||||
target: EditTarget::Artist(id),
|
||||
@@ -469,7 +500,10 @@ fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
|
||||
EditField::new("Featured", join(&track.featured_artists)),
|
||||
EditField::new(
|
||||
"Track #",
|
||||
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
|
||||
track
|
||||
.track_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
EditField::new(
|
||||
"Disc #",
|
||||
@@ -527,8 +561,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
fed_tracks: vec![],
|
||||
});
|
||||
}
|
||||
state.status_message =
|
||||
Some(format!("removing {} track(s) from playlist", track_ids.len()));
|
||||
state.status_message = Some(format!(
|
||||
"removing {} track(s) from playlist",
|
||||
track_ids.len()
|
||||
));
|
||||
return Some(Effect::RemoveFromPlaylist {
|
||||
playlist_id: opened.id,
|
||||
track_ids,
|
||||
@@ -543,14 +579,20 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Artist(artist.id),
|
||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
||||
label: format!(
|
||||
"artist \"{}\" with all their releases and tracks",
|
||||
artist.name
|
||||
),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
if let Some(artist) = selected_search_artist(state) {
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
target: DeleteTarget::Artist(artist.id),
|
||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
||||
label: format!(
|
||||
"artist \"{}\" with all their releases and tracks",
|
||||
artist.name
|
||||
),
|
||||
});
|
||||
return None;
|
||||
}
|
||||
@@ -604,7 +646,9 @@ fn selected_release_card(state: &AppState) -> Option<crate::library::models::Rel
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
results.releases.get(offset).cloned()
|
||||
}
|
||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
|
||||
GlobalView::Release { .. }
|
||||
| GlobalView::FedArtist { .. }
|
||||
| GlobalView::FedRelease { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,9 +723,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
}
|
||||
TrackSelectionScope::Playlist(id) => {
|
||||
if let Some(opened) = &mut state.playlists.opened
|
||||
&& opened.id == *id {
|
||||
opened.cursor = value;
|
||||
}
|
||||
&& opened.id == *id
|
||||
{
|
||||
opened.cursor = value;
|
||||
}
|
||||
}
|
||||
TrackSelectionScope::Queue => {
|
||||
state.queue_tab.cursor = value;
|
||||
@@ -694,6 +739,13 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
TrackSelectionScope::FedRelease(_) => {
|
||||
set_view_cursor(state, value + 1);
|
||||
}
|
||||
TrackSelectionScope::FedAppearsOn => {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return;
|
||||
};
|
||||
let release_count = card.releases.len();
|
||||
set_view_cursor(state, release_count + value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,13 +791,19 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
||||
GlobalView::FedRelease { index, cursor } => {
|
||||
let len = fed_card_release(state, *index)?.tracks.len();
|
||||
let relative = cursor.checked_sub(1)?;
|
||||
(relative < len).then_some((
|
||||
TrackSelectionScope::FedRelease(*index),
|
||||
(relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
|
||||
}
|
||||
GlobalView::FedArtist { cursor } => {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return None;
|
||||
};
|
||||
let relative = cursor.checked_sub(card.releases.len())?;
|
||||
(relative < card.appears_on.len()).then_some((
|
||||
TrackSelectionScope::FedAppearsOn,
|
||||
relative,
|
||||
len,
|
||||
card.appears_on.len(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Tab::Playlists => {
|
||||
let opened = state.playlists.opened.as_ref()?;
|
||||
@@ -990,17 +1048,24 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
}
|
||||
}
|
||||
}
|
||||
GlobalView::FedArtist { .. } => None,
|
||||
GlobalView::FedRelease { index, cursor } => {
|
||||
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
|
||||
let release = fed_card_release(state, *index)?;
|
||||
release
|
||||
.tracks
|
||||
.get(cursor.checked_sub(1)?)
|
||||
.and_then(|track| fed_track_from_card(&name, release, track))
|
||||
GlobalView::FedArtist { cursor } => {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return None;
|
||||
};
|
||||
let index = cursor.checked_sub(card.releases.len())?;
|
||||
card.appears_on
|
||||
.get(index)
|
||||
.and_then(|appearance| {
|
||||
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
||||
})
|
||||
.as_ref()
|
||||
.map(crate::federation::pending_track)
|
||||
}
|
||||
GlobalView::FedRelease { index, cursor } => fed_release_tracks(state, *index)
|
||||
.into_iter()
|
||||
.nth(cursor.checked_sub(1)?)
|
||||
.as_ref()
|
||||
.map(crate::federation::pending_track),
|
||||
},
|
||||
Tab::Playlists => {
|
||||
let opened = state.playlists.opened.as_ref()?;
|
||||
@@ -1040,7 +1105,9 @@ fn selected_release_id(state: &AppState) -> Option<i64> {
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
results.releases.get(offset).map(|r| r.id)
|
||||
}
|
||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
|
||||
GlobalView::Release { .. }
|
||||
| GlobalView::FedArtist { .. }
|
||||
| GlobalView::FedRelease { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1052,6 +1119,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
||||
let count = tracks.len();
|
||||
let title = tracks[0].title.clone();
|
||||
enqueue_tracks(state, tracks, next);
|
||||
state.track_selection.clear();
|
||||
state.status_message = Some(if count == 1 && next {
|
||||
format!("queued next: {title}")
|
||||
} else if count == 1 {
|
||||
@@ -1130,9 +1198,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
||||
player.queue.insert(insert_at + offset, track);
|
||||
}
|
||||
if let Some(prefetched) = &mut player.prefetched_pos
|
||||
&& insert_at <= *prefetched {
|
||||
*prefetched += count;
|
||||
}
|
||||
&& insert_at <= *prefetched
|
||||
{
|
||||
*prefetched += count;
|
||||
}
|
||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||
player.queue_pos += count;
|
||||
}
|
||||
@@ -1310,8 +1379,14 @@ fn page_step(state: &AppState) -> isize {
|
||||
}
|
||||
Some(GlobalView::Release { .. })
|
||||
| Some(GlobalView::Search { .. })
|
||||
| Some(GlobalView::FedArtist { .. })
|
||||
| Some(GlobalView::FedRelease { .. }) => lines,
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
let in_release_tiles = match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) => *cursor < card.releases.len(),
|
||||
_ => false,
|
||||
};
|
||||
if in_release_tiles { tile_rows } else { lines }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1492,14 +1567,39 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
state.track_selection.clear();
|
||||
}
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
// A flat tile grid of releases.
|
||||
let total = fed_card_len(state) as isize;
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return;
|
||||
};
|
||||
let releases = card.releases.len();
|
||||
let appears_on = card.appears_on.len();
|
||||
let total = (releases + appears_on) as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let columns = grid_columns() as isize;
|
||||
let step = if dx != 0 { dx } else { dy * columns };
|
||||
let next = (cursor as isize + step).clamp(0, total - 1);
|
||||
let in_release_tiles = cursor < releases;
|
||||
let next = if !in_release_tiles {
|
||||
(cursor as isize + dy).clamp(0, total - 1) as usize
|
||||
} else {
|
||||
let rows = fed_release_rows(&card.releases, grid_columns());
|
||||
let (row, column) = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(r, items)| items.iter().position(|p| *p == cursor).map(|c| (r, c)))
|
||||
.unwrap_or((0, 0));
|
||||
if dx != 0 {
|
||||
(cursor as isize + dx).clamp(0, releases as isize - 1) as usize
|
||||
} else {
|
||||
let target = row as isize + dy;
|
||||
if target < 0 {
|
||||
cursor
|
||||
} else if target as usize >= rows.len() {
|
||||
if appears_on > 0 { releases } else { cursor }
|
||||
} else {
|
||||
let items = &rows[target as usize];
|
||||
items[column.min(items.len() - 1)]
|
||||
}
|
||||
}
|
||||
};
|
||||
set_view_cursor(state, next as usize);
|
||||
state.track_selection.clear();
|
||||
}
|
||||
@@ -1513,10 +1613,10 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selectable tiles of the open federated artist card (its releases).
|
||||
/// Selectable rows of the open federated artist card: releases, then appearances.
|
||||
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
||||
match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) => card.releases.len(),
|
||||
Some((_, Loadable::Ready(card))) => card.releases.len() + card.appears_on.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -1607,10 +1707,11 @@ fn jump_selection(state: &mut AppState, first: bool) {
|
||||
if first {
|
||||
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
||||
if let Some(buffer) = crate::config::logging::buffer()
|
||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
||||
state.logs.selected_seq = Some(seq);
|
||||
state.logs.follow = false;
|
||||
}
|
||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN)
|
||||
{
|
||||
state.logs.selected_seq = Some(seq);
|
||||
state.logs.follow = false;
|
||||
}
|
||||
} else {
|
||||
state.logs.follow = true;
|
||||
state.logs.selected_seq = None;
|
||||
@@ -1787,18 +1888,42 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
||||
},
|
||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => {
|
||||
let order = fed_release_display_order(&card.releases);
|
||||
let Some(&release_index) = order.get(cursor) else {
|
||||
return None;
|
||||
};
|
||||
// Focus starts on the first track; Up from it reaches the
|
||||
// download-release button (row 0).
|
||||
let start = if card.releases[cursor].tracks.is_empty() {
|
||||
let start = if card.releases[release_index].tracks.is_empty() {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
Outcome::Push(GlobalView::FedRelease {
|
||||
index: cursor,
|
||||
index: release_index,
|
||||
cursor: start,
|
||||
})
|
||||
}
|
||||
Some((_, Loadable::Ready(card))) => {
|
||||
let start = cursor - card.releases.len();
|
||||
if card.appears_on.get(start).is_none() {
|
||||
Outcome::Nothing
|
||||
} else {
|
||||
let tracks: Vec<_> = card
|
||||
.appears_on
|
||||
.iter()
|
||||
.filter_map(|appearance| {
|
||||
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
||||
})
|
||||
.map(|fed| crate::federation::pending_track(&fed))
|
||||
.collect();
|
||||
if tracks.is_empty() {
|
||||
Outcome::Nothing
|
||||
} else {
|
||||
Outcome::Play { tracks, start }
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => Outcome::Nothing,
|
||||
},
|
||||
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
|
||||
@@ -1882,7 +2007,17 @@ pub(crate) fn fed_release_tracks(
|
||||
release
|
||||
.tracks
|
||||
.iter()
|
||||
.filter_map(|track| fed_track_from_card(name, release, track))
|
||||
.filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return Vec::new();
|
||||
};
|
||||
card.appears_on
|
||||
.iter()
|
||||
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1904,7 +2039,19 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
||||
TrackSelectionScope::FedRelease(index) => {
|
||||
let all = fed_release_tracks(state, index);
|
||||
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
|
||||
return indices.into_iter().filter_map(|i| all.get(i).cloned()).collect();
|
||||
return indices
|
||||
.into_iter()
|
||||
.filter_map(|i| all.get(i).cloned())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
TrackSelectionScope::FedAppearsOn => {
|
||||
let all = fed_appears_on_tracks(state);
|
||||
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
|
||||
return indices
|
||||
.into_iter()
|
||||
.filter_map(|i| all.get(i).cloned())
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1926,6 +2073,16 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
||||
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return Vec::new();
|
||||
};
|
||||
cursor
|
||||
.checked_sub(card.releases.len())
|
||||
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -1970,22 +2127,73 @@ fn fed_track_from_card(
|
||||
artist: &str,
|
||||
release: &crate::federation::FedRelease,
|
||||
track: &crate::federation::FedCardTrack,
|
||||
own_owner: Option<&str>,
|
||||
) -> Option<crate::federation::FedTrack> {
|
||||
let (owner, item_id) = track.sources.first()?.clone();
|
||||
let own = own_owner == Some(owner.as_str());
|
||||
Some(crate::federation::FedTrack {
|
||||
item_id,
|
||||
owner,
|
||||
own: false,
|
||||
own,
|
||||
title: track.title.clone(),
|
||||
artist_names: vec![artist.to_string()],
|
||||
artist_names: fed_card_main_artist_names(track, Some(artist)),
|
||||
featured_artist_names: fed_card_featured_artist_names(track),
|
||||
year: release.year,
|
||||
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
||||
content_id: track.content_id.clone(),
|
||||
release_title: Some(release.title.clone()),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn fed_track_from_appearance(
|
||||
appearance: &crate::federation::FedAppearsOn,
|
||||
own_owner: Option<&str>,
|
||||
) -> Option<crate::federation::FedTrack> {
|
||||
let (owner, item_id) = appearance.track.sources.first()?.clone();
|
||||
let own = own_owner == Some(owner.as_str());
|
||||
Some(crate::federation::FedTrack {
|
||||
item_id,
|
||||
owner,
|
||||
own,
|
||||
title: appearance.track.title.clone(),
|
||||
artist_names: fed_card_main_artist_names(&appearance.track, None),
|
||||
featured_artist_names: fed_card_featured_artist_names(&appearance.track),
|
||||
year: appearance.year,
|
||||
duration_seconds: appearance.track.duration_seconds.map(|d| d.round() as i64),
|
||||
content_id: appearance.track.content_id.clone(),
|
||||
release_title: (!appearance.release_title.is_empty())
|
||||
.then(|| appearance.release_title.clone()),
|
||||
track_number: appearance.track.track_number,
|
||||
disc_number: appearance.track.disc_number,
|
||||
})
|
||||
}
|
||||
|
||||
fn fed_card_main_artist_names(
|
||||
track: &crate::federation::FedCardTrack,
|
||||
fallback: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
if track.artists.is_empty() {
|
||||
fallback.into_iter().map(str::to_string).collect()
|
||||
} else {
|
||||
track.artists.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Vec<String> {
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for artist in &track.featured_artists {
|
||||
if !names
|
||||
.iter()
|
||||
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||||
{
|
||||
names.push(artist.clone());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Enter on the Federation tab: toggle switches, open text inputs, run
|
||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||
@@ -2071,12 +2279,13 @@ fn go_back(state: &mut AppState) {
|
||||
// Esc on a view opened by Shift-J from another tab goes back to
|
||||
// that tab, not down the Global stack.
|
||||
if let Some((origin, depth)) = state.jump_origin
|
||||
&& state.global.stack.len() == depth + 1 {
|
||||
state.global.stack.pop();
|
||||
state.jump_origin = None;
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
&& state.global.stack.len() == depth + 1
|
||||
{
|
||||
state.global.stack.pop();
|
||||
state.jump_origin = None;
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
if let Some(popped) = state.global.stack.pop() {
|
||||
if matches!(popped, GlobalView::Search { .. }) {
|
||||
state.search = SearchState::default();
|
||||
@@ -2161,6 +2370,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2370,8 +2580,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn queue_advances_and_respects_repeat() {
|
||||
use crate::library::models::TrackItem;
|
||||
use crate::app::state::RepeatMode;
|
||||
use crate::library::models::TrackItem;
|
||||
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
@@ -2386,6 +2596,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/api/player/stream/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2450,6 +2661,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2541,6 +2753,7 @@ mod tests {
|
||||
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
|
||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(queued, vec![1, 2]);
|
||||
assert!(!state.track_selection.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2615,6 +2828,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2661,6 +2875,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
|
||||
Reference in New Issue
Block a user