Connected Devices: fixed UI settings. fixed pairing protocol
This commit is contained in:
+2
-1
@@ -56,10 +56,11 @@ pub enum AppEvent {
|
|||||||
track_id: i64,
|
track_id: i64,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
},
|
},
|
||||||
/// Liked federated item ids for the ♥ markers.
|
/// Liked federated item ids and content ids for the ♥ markers.
|
||||||
FedLikesLoaded(Result<Vec<String>, String>),
|
FedLikesLoaded(Result<Vec<String>, String>),
|
||||||
FedLikeToggled {
|
FedLikeToggled {
|
||||||
item_id: String,
|
item_id: String,
|
||||||
|
content_id: Option<String>,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
},
|
},
|
||||||
/// A release fetched for queueing (a / shift-a on a release).
|
/// A release fetched for queueing (a / shift-a on a release).
|
||||||
|
|||||||
+28
-4
@@ -459,13 +459,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
for fed in fed_tracks {
|
for fed in fed_tracks {
|
||||||
match library.toggle_fed_like(&fed) {
|
match library.toggle_fed_like(&fed) {
|
||||||
Ok(liked) => {
|
Ok(liked) => {
|
||||||
if let Err(err) =
|
if let Err(err) = devices.record_fed_like(&fed, liked) {
|
||||||
devices.record_fed_like(fed.content_id.as_deref(), liked)
|
|
||||||
{
|
|
||||||
tracing::warn!(%err, title = %fed.title, "recording synced federated like failed");
|
tracing::warn!(%err, title = %fed.title, "recording synced federated like failed");
|
||||||
}
|
}
|
||||||
let _ = tx.send(AppEvent::FedLikeToggled {
|
let _ = tx.send(AppEvent::FedLikeToggled {
|
||||||
item_id: fed.item_id.clone(),
|
item_id: fed.item_id.clone(),
|
||||||
|
content_id: fed.content_id.clone(),
|
||||||
liked,
|
liked,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -524,6 +523,15 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
let _ = tx.send(AppEvent::FedTicket(result));
|
let _ = tx.send(AppEvent::FedTicket(result));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Effect::DeviceShowInvite
|
||||||
|
| Effect::DeviceConnectInvite(_)
|
||||||
|
| Effect::DeviceSyncNow
|
||||||
|
| Effect::DeviceSetName(_)
|
||||||
|
| Effect::DeviceRevoke(_)
|
||||||
|
if !state.connected_devices_enabled() =>
|
||||||
|
{
|
||||||
|
state.status_message = Some("enable federation before using connected devices".into());
|
||||||
|
}
|
||||||
Effect::DeviceShowInvite => {
|
Effect::DeviceShowInvite => {
|
||||||
let fed = Arc::clone(&runtime.federation);
|
let fed = Arc::clone(&runtime.federation);
|
||||||
let devices = Arc::clone(&runtime.devices);
|
let devices = Arc::clone(&runtime.devices);
|
||||||
@@ -1393,6 +1401,8 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
device_id: request.device_id,
|
device_id: request.device_id,
|
||||||
name: request.name,
|
name: request.name,
|
||||||
client_version: request.client_version,
|
client_version: request.client_version,
|
||||||
|
requester_group_id: request.requester_group_id,
|
||||||
|
requester_group_active_devices: request.requester_group_active_devices,
|
||||||
});
|
});
|
||||||
state.federation.devices = Some(runtime.devices.status());
|
state.federation.devices = Some(runtime.devices.status());
|
||||||
}
|
}
|
||||||
@@ -1703,11 +1713,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
|
Ok(ids) => state.fed_likes = ids.into_iter().collect(),
|
||||||
Err(message) => tracing::warn!(%message, "federated likes load failed"),
|
Err(message) => tracing::warn!(%message, "federated likes load failed"),
|
||||||
},
|
},
|
||||||
AppEvent::FedLikeToggled { item_id, liked } => {
|
AppEvent::FedLikeToggled {
|
||||||
|
item_id,
|
||||||
|
content_id,
|
||||||
|
liked,
|
||||||
|
} => {
|
||||||
if liked {
|
if liked {
|
||||||
state.fed_likes.insert(item_id);
|
state.fed_likes.insert(item_id);
|
||||||
|
if let Some(content_id) =
|
||||||
|
content_id.and_then(|id| music_dht::normalize_content_id(&id))
|
||||||
|
{
|
||||||
|
state.fed_likes.insert(content_id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.fed_likes.remove(&item_id);
|
state.fed_likes.remove(&item_id);
|
||||||
|
if let Some(content_id) =
|
||||||
|
content_id.and_then(|id| music_dht::normalize_content_id(&id))
|
||||||
|
{
|
||||||
|
state.fed_likes.remove(&content_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The virtual Likes playlist is stale now; refetch on next open.
|
// The virtual Likes playlist is stale now; refetch on next open.
|
||||||
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
state.playlist_views.remove(&state::LIKES_PLAYLIST_ID);
|
||||||
|
|||||||
+28
-3
@@ -66,6 +66,8 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
device_id,
|
device_id,
|
||||||
name,
|
name,
|
||||||
client_version,
|
client_version,
|
||||||
|
requester_group_id,
|
||||||
|
requester_group_active_devices,
|
||||||
} => handle_device_pairing(
|
} => handle_device_pairing(
|
||||||
state,
|
state,
|
||||||
runtime,
|
runtime,
|
||||||
@@ -73,6 +75,8 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) {
|
|||||||
device_id,
|
device_id,
|
||||||
name,
|
name,
|
||||||
client_version,
|
client_version,
|
||||||
|
requester_group_id,
|
||||||
|
requester_group_active_devices,
|
||||||
key,
|
key,
|
||||||
),
|
),
|
||||||
Popup::ConfirmDeviceRevoke { device_id, name } => {
|
Popup::ConfirmDeviceRevoke { device_id, name } => {
|
||||||
@@ -165,11 +169,14 @@ fn handle_device_pairing(
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
client_version: String,
|
client_version: String,
|
||||||
|
requester_group_id: Option<String>,
|
||||||
|
requester_group_active_devices: usize,
|
||||||
key: KeyEvent,
|
key: KeyEvent,
|
||||||
) {
|
) {
|
||||||
|
let group_conflict = requester_group_id.is_some() && requester_group_active_devices > 1;
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {
|
||||||
if let Err(err) = runtime.devices.answer_pairing(&request_id, false) {
|
if let Err(err) = runtime.devices.answer_pairing(&request_id, false, false) {
|
||||||
state.status_message = Some(format!("pairing: {err:#}"));
|
state.status_message = Some(format!("pairing: {err:#}"));
|
||||||
} else {
|
} else {
|
||||||
state.status_message = Some("device pairing denied".to_string());
|
state.status_message = Some("device pairing denied".to_string());
|
||||||
@@ -177,10 +184,26 @@ fn handle_device_pairing(
|
|||||||
state.federation.devices = Some(runtime.devices.status());
|
state.federation.devices = Some(runtime.devices.status());
|
||||||
}
|
}
|
||||||
KeyCode::Char('y') => {
|
KeyCode::Char('y') => {
|
||||||
if let Err(err) = runtime.devices.answer_pairing(&request_id, true) {
|
if let Err(err) = runtime
|
||||||
|
.devices
|
||||||
|
.answer_pairing(&request_id, true, group_conflict)
|
||||||
|
{
|
||||||
state.status_message = Some(format!("pairing: {err:#}"));
|
state.status_message = Some(format!("pairing: {err:#}"));
|
||||||
} else {
|
} else {
|
||||||
state.status_message = Some(format!("device \"{name}\" accepted"));
|
state.status_message = Some(if group_conflict {
|
||||||
|
format!("device \"{name}\" accepted; joining its sync group")
|
||||||
|
} else {
|
||||||
|
format!("device \"{name}\" accepted")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.federation.devices = Some(runtime.devices.status());
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') if group_conflict => {
|
||||||
|
if let Err(err) = runtime.devices.answer_pairing(&request_id, true, false) {
|
||||||
|
state.status_message = Some(format!("pairing: {err:#}"));
|
||||||
|
} else {
|
||||||
|
state.status_message =
|
||||||
|
Some(format!("device \"{name}\" accepted into this sync group"));
|
||||||
}
|
}
|
||||||
state.federation.devices = Some(runtime.devices.status());
|
state.federation.devices = Some(runtime.devices.status());
|
||||||
}
|
}
|
||||||
@@ -190,6 +213,8 @@ fn handle_device_pairing(
|
|||||||
device_id,
|
device_id,
|
||||||
name,
|
name,
|
||||||
client_version,
|
client_version,
|
||||||
|
requester_group_id,
|
||||||
|
requester_group_active_devices,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-2
@@ -536,6 +536,8 @@ pub enum Popup {
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
name: String,
|
name: String,
|
||||||
client_version: String,
|
client_version: String,
|
||||||
|
requester_group_id: Option<String>,
|
||||||
|
requester_group_active_devices: usize,
|
||||||
},
|
},
|
||||||
/// Confirmation before revoking a trusted device.
|
/// Confirmation before revoking a trusted device.
|
||||||
ConfirmDeviceRevoke { device_id: String, name: String },
|
ConfirmDeviceRevoke { device_id: String, name: String },
|
||||||
@@ -819,8 +821,8 @@ pub struct AppState {
|
|||||||
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
pub playlist_views: HashMap<i64, Loadable<PlaylistDetail>>,
|
||||||
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
/// Liked track ids, for the ♥ markers everywhere tracks are shown.
|
||||||
pub likes: std::collections::HashSet<i64>,
|
pub likes: std::collections::HashSet<i64>,
|
||||||
/// Liked federated tracks (DHT item ids) — likes that reference peers'
|
/// Liked federated tracks (DHT item ids and content ids) — likes that
|
||||||
/// tracks without downloading them.
|
/// reference peers' tracks without downloading them.
|
||||||
pub fed_likes: std::collections::HashSet<String>,
|
pub fed_likes: std::collections::HashSet<String>,
|
||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
@@ -848,3 +850,30 @@ pub struct AppState {
|
|||||||
/// every view that shows artwork.
|
/// every view that shows artwork.
|
||||||
pub art: HashMap<String, ArtState>,
|
pub art: HashMap<String, ArtState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn connected_devices_enabled(&self) -> bool {
|
||||||
|
self.federation.settings.enabled && !self.federation.settings.network_id.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fed_track_liked(&self, fed: &crate::federation::FedTrack) -> bool {
|
||||||
|
self.fed_likes.contains(&fed.item_id)
|
||||||
|
|| fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fed_card_track_liked(&self, track: &crate::federation::FedCardTrack) -> bool {
|
||||||
|
track
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
.is_some_and(|content_id| self.fed_likes.contains(&content_id))
|
||||||
|
|| track
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.any(|(_, item_id)| self.fed_likes.contains(item_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+32
-6
@@ -292,16 +292,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
let should_like = track_ids.iter().any(|id| !state.likes.contains(id))
|
let should_like = track_ids.iter().any(|id| !state.likes.contains(id))
|
||||||
|| fed_tracks
|
|| fed_tracks.iter().any(|fed| !state.fed_track_liked(fed));
|
||||||
.iter()
|
|
||||||
.any(|fed| !state.fed_likes.contains(&fed.item_id));
|
|
||||||
let toggles: Vec<i64> = track_ids
|
let toggles: Vec<i64> = track_ids
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|id| state.likes.contains(id) != should_like)
|
.filter(|id| state.likes.contains(id) != should_like)
|
||||||
.collect();
|
.collect();
|
||||||
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
|
let fed_toggles: Vec<crate::federation::FedTrack> = fed_tracks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|fed| state.fed_likes.contains(&fed.item_id) != should_like)
|
.filter(|fed| state.fed_track_liked(fed) != should_like)
|
||||||
.collect();
|
.collect();
|
||||||
let total = toggles.len() + fed_toggles.len();
|
let total = toggles.len() + fed_toggles.len();
|
||||||
state.status_message = Some(if should_like {
|
state.status_message = Some(if should_like {
|
||||||
@@ -2344,6 +2342,9 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
SettingsRow::DeviceName => {
|
SettingsRow::DeviceName => {
|
||||||
|
if !require_connected_devices_enabled(state) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let name = state
|
let name = state
|
||||||
.federation
|
.federation
|
||||||
.devices
|
.devices
|
||||||
@@ -2356,16 +2357,32 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
});
|
});
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
SettingsRow::DeviceInvite => Some(Effect::DeviceShowInvite),
|
SettingsRow::DeviceInvite => {
|
||||||
|
if !require_connected_devices_enabled(state) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Effect::DeviceShowInvite)
|
||||||
|
}
|
||||||
SettingsRow::DeviceConnect => {
|
SettingsRow::DeviceConnect => {
|
||||||
|
if !require_connected_devices_enabled(state) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
state.popup = Some(Popup::FedInput {
|
state.popup = Some(Popup::FedInput {
|
||||||
field: FedInputField::ConnectInvite,
|
field: FedInputField::ConnectInvite,
|
||||||
input: crate::app::input::LineEdit::default(),
|
input: crate::app::input::LineEdit::default(),
|
||||||
});
|
});
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
SettingsRow::DeviceSyncNow => Some(Effect::DeviceSyncNow),
|
SettingsRow::DeviceSyncNow => {
|
||||||
|
if !require_connected_devices_enabled(state) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Effect::DeviceSyncNow)
|
||||||
|
}
|
||||||
SettingsRow::Device(index) => {
|
SettingsRow::Device(index) => {
|
||||||
|
if !require_connected_devices_enabled(state) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let Some(device) = state
|
let Some(device) = state
|
||||||
.federation
|
.federation
|
||||||
.devices
|
.devices
|
||||||
@@ -2433,6 +2450,15 @@ fn federation_select(state: &mut AppState) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn require_connected_devices_enabled(state: &mut AppState) -> bool {
|
||||||
|
if state.connected_devices_enabled() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
state.status_message = Some("enable federation before using connected devices".to_string());
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
|
/// A freshly created play context: drop the stale pre-shuffle snapshot and,
|
||||||
/// if shuffle is on, shuffle everything after the chosen track right away.
|
/// if shuffle is on, shuffle everything after the chosen track right away.
|
||||||
pub(super) fn on_new_queue(state: &mut AppState) {
|
pub(super) fn on_new_queue(state: &mut AppState) {
|
||||||
|
|||||||
+407
-81
@@ -37,6 +37,8 @@ pub struct PendingPairing {
|
|||||||
pub device_id: String,
|
pub device_id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub client_version: String,
|
pub client_version: String,
|
||||||
|
pub requester_group_id: Option<String>,
|
||||||
|
pub requester_group_active_devices: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
@@ -132,6 +134,8 @@ pub enum SyncOpPayload {
|
|||||||
TrackLikeSet {
|
TrackLikeSet {
|
||||||
content_id: String,
|
content_id: String,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
fed: Option<SyncedFedTrack>,
|
||||||
},
|
},
|
||||||
PlaylistCreated {
|
PlaylistCreated {
|
||||||
playlist_id: String,
|
playlist_id: String,
|
||||||
@@ -193,6 +197,8 @@ struct SnapshotLike {
|
|||||||
content_id: String,
|
content_id: String,
|
||||||
hlc_ms: i64,
|
hlc_ms: i64,
|
||||||
op_id: String,
|
op_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
fed: Option<SyncedFedTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -219,6 +225,67 @@ enum PairAttempt {
|
|||||||
Denied(String),
|
Denied(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PairingStatus {
|
||||||
|
status: String,
|
||||||
|
use_requester_group: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SyncedFedTrack {
|
||||||
|
item_id: String,
|
||||||
|
owner: String,
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
artist_names: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
featured_artist_names: Vec<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
duration_seconds: Option<i64>,
|
||||||
|
content_id: String,
|
||||||
|
release_title: Option<String>,
|
||||||
|
track_number: Option<i32>,
|
||||||
|
disc_number: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncedFedTrack {
|
||||||
|
fn from_fed(fed: &crate::federation::FedTrack) -> Option<Self> {
|
||||||
|
let content_id = fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)?;
|
||||||
|
Some(Self {
|
||||||
|
item_id: fed.item_id.clone(),
|
||||||
|
owner: fed.owner.clone(),
|
||||||
|
title: fed.title.clone(),
|
||||||
|
artist_names: fed.artist_names.clone(),
|
||||||
|
featured_artist_names: fed.featured_artist_names.clone(),
|
||||||
|
year: fed.year,
|
||||||
|
duration_seconds: fed.duration_seconds,
|
||||||
|
content_id,
|
||||||
|
release_title: fed.release_title.clone(),
|
||||||
|
track_number: fed.track_number,
|
||||||
|
disc_number: fed.disc_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_fed_track(&self) -> crate::federation::FedTrack {
|
||||||
|
crate::federation::FedTrack {
|
||||||
|
item_id: self.item_id.clone(),
|
||||||
|
owner: self.owner.clone(),
|
||||||
|
own: false,
|
||||||
|
title: self.title.clone(),
|
||||||
|
artist_names: self.artist_names.clone(),
|
||||||
|
featured_artist_names: self.featured_artist_names.clone(),
|
||||||
|
year: self.year,
|
||||||
|
duration_seconds: self.duration_seconds,
|
||||||
|
content_id: Some(self.content_id.clone()),
|
||||||
|
release_title: self.release_title.clone(),
|
||||||
|
track_number: self.track_number,
|
||||||
|
disc_number: self.disc_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
enum WireMessage {
|
enum WireMessage {
|
||||||
@@ -226,6 +293,12 @@ enum WireMessage {
|
|||||||
invite_id: String,
|
invite_id: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
profile: DeviceProfileWire,
|
profile: DeviceProfileWire,
|
||||||
|
#[serde(default)]
|
||||||
|
group_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
group_active_devices: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
devices: Vec<DeviceProfileWire>,
|
||||||
vector: BTreeMap<String, i64>,
|
vector: BTreeMap<String, i64>,
|
||||||
ops: Vec<SyncOpWire>,
|
ops: Vec<SyncOpWire>,
|
||||||
snapshot: SyncSnapshot,
|
snapshot: SyncSnapshot,
|
||||||
@@ -433,7 +506,10 @@ impl DeviceSync {
|
|||||||
) -> Result<PairAttempt> {
|
) -> Result<PairAttempt> {
|
||||||
let peer = service.connect(ticket).await?;
|
let peer = service.connect(ticket).await?;
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
|
let identity = self.ensure_identity()?;
|
||||||
|
let group_active_devices = self.active_device_count()?;
|
||||||
let profile = self.own_profile(&own_ticket)?;
|
let profile = self.own_profile(&own_ticket)?;
|
||||||
|
let devices = self.device_profiles()?;
|
||||||
let vector = self.vector()?;
|
let vector = self.vector()?;
|
||||||
let ops = self.ops_for_peer(&invite.device_id)?;
|
let ops = self.ops_for_peer(&invite.device_id)?;
|
||||||
let snapshot = self.snapshot()?;
|
let snapshot = self.snapshot()?;
|
||||||
@@ -444,6 +520,9 @@ impl DeviceSync {
|
|||||||
invite_id: invite.invite_id.clone(),
|
invite_id: invite.invite_id.clone(),
|
||||||
secret: invite.secret.clone(),
|
secret: invite.secret.clone(),
|
||||||
profile,
|
profile,
|
||||||
|
group_id: Some(identity.group_id),
|
||||||
|
group_active_devices,
|
||||||
|
devices,
|
||||||
vector,
|
vector,
|
||||||
ops,
|
ops,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -499,27 +578,37 @@ impl DeviceSync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn answer_pairing(&self, request_id: &str, accept: bool) -> Result<()> {
|
pub fn answer_pairing(
|
||||||
let profile = {
|
&self,
|
||||||
|
request_id: &str,
|
||||||
|
accept: bool,
|
||||||
|
use_requester_group: bool,
|
||||||
|
) -> Result<()> {
|
||||||
|
let pending = {
|
||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT device_id, name, client_version, endpoint_id,
|
"SELECT device_id, name, client_version, endpoint_id,
|
||||||
endpoint_ticket, created_at_ms
|
endpoint_ticket, created_at_ms,
|
||||||
|
requester_group_id, requester_group_devices_json
|
||||||
FROM sync_pending_pairing
|
FROM sync_pending_pairing
|
||||||
WHERE request_id = ?1",
|
WHERE request_id = ?1",
|
||||||
[request_id],
|
[request_id],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(DeviceProfileWire {
|
Ok((
|
||||||
device_id: row.get(0)?,
|
DeviceProfileWire {
|
||||||
name: row.get(1)?,
|
device_id: row.get(0)?,
|
||||||
client_version: row.get(2)?,
|
name: row.get(1)?,
|
||||||
protocol_version: PROTOCOL_VERSION,
|
client_version: row.get(2)?,
|
||||||
endpoint_id: row.get(3)?,
|
protocol_version: PROTOCOL_VERSION,
|
||||||
endpoint_ticket: row.get(4)?,
|
endpoint_id: row.get(3)?,
|
||||||
revoked: false,
|
endpoint_ticket: row.get(4)?,
|
||||||
revoke_cutoff_seq: None,
|
revoked: false,
|
||||||
updated_at_ms: row.get(5)?,
|
revoke_cutoff_seq: None,
|
||||||
})
|
updated_at_ms: row.get(5)?,
|
||||||
|
},
|
||||||
|
row.get::<_, Option<String>>(6)?,
|
||||||
|
row.get::<_, String>(7)?,
|
||||||
|
))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.optional()?
|
.optional()?
|
||||||
@@ -528,17 +617,29 @@ impl DeviceSync {
|
|||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sync_pending_pairing
|
"UPDATE sync_pending_pairing
|
||||||
SET status = ?2, answered_at_ms = ?3
|
SET status = ?2, answered_at_ms = ?3, use_requester_group = ?4
|
||||||
WHERE request_id = ?1 AND status = 'pending'",
|
WHERE request_id = ?1 AND status = 'pending'",
|
||||||
params![
|
params![
|
||||||
request_id,
|
request_id,
|
||||||
if accept { "accepted" } else { "denied" },
|
if accept { "accepted" } else { "denied" },
|
||||||
now_ms()
|
now_ms(),
|
||||||
|
i64::from(use_requester_group),
|
||||||
],
|
],
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
if accept && changed > 0 {
|
if accept && changed > 0 {
|
||||||
if let Some(profile) = profile {
|
if let Some((profile, requester_group_id, requester_group_devices_json)) = pending {
|
||||||
|
if use_requester_group {
|
||||||
|
if let Some(group_id) = requester_group_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|id| !id.trim().is_empty())
|
||||||
|
{
|
||||||
|
self.set_group_id(group_id)?;
|
||||||
|
}
|
||||||
|
let requester_devices: Vec<DeviceProfileWire> =
|
||||||
|
serde_json::from_str(&requester_group_devices_json)?;
|
||||||
|
self.apply_device_profiles(&requester_devices)?;
|
||||||
|
}
|
||||||
self.apply_device_profile(&profile, true)?;
|
self.apply_device_profile(&profile, true)?;
|
||||||
self.record_local_op(SyncOpPayload::DeviceTrusted {
|
self.record_local_op(SyncOpPayload::DeviceTrusted {
|
||||||
target_device_id: profile.device_id,
|
target_device_id: profile.device_id,
|
||||||
@@ -569,16 +670,28 @@ impl DeviceSync {
|
|||||||
|
|
||||||
pub fn record_track_like(&self, track_id: i64, liked: bool) -> Result<()> {
|
pub fn record_track_like(&self, track_id: i64, liked: bool) -> Result<()> {
|
||||||
if let Some(content_id) = self.library.track_content_id_by_id(track_id)? {
|
if let Some(content_id) = self.library.track_content_id_by_id(track_id)? {
|
||||||
self.record_local_op(SyncOpPayload::TrackLikeSet { content_id, liked })?;
|
self.record_local_op(SyncOpPayload::TrackLikeSet {
|
||||||
|
content_id,
|
||||||
|
liked,
|
||||||
|
fed: None,
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_fed_like(&self, content_id: Option<&str>, liked: bool) -> Result<()> {
|
pub fn record_fed_like(&self, fed: &crate::federation::FedTrack, liked: bool) -> Result<()> {
|
||||||
let Some(content_id) = content_id.and_then(music_dht::normalize_content_id) else {
|
let Some(content_id) = fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
self.record_local_op(SyncOpPayload::TrackLikeSet { content_id, liked })?;
|
self.record_local_op(SyncOpPayload::TrackLikeSet {
|
||||||
|
content_id,
|
||||||
|
liked,
|
||||||
|
fed: liked.then(|| SyncedFedTrack::from_fed(fed)).flatten(),
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -940,9 +1053,11 @@ impl DeviceSync {
|
|||||||
|
|
||||||
fn apply_op(&self, op: &SyncOpWire) -> Result<bool> {
|
fn apply_op(&self, op: &SyncOpWire) -> Result<bool> {
|
||||||
let changed = match &op.payload {
|
let changed = match &op.payload {
|
||||||
SyncOpPayload::TrackLikeSet { content_id, liked } => {
|
SyncOpPayload::TrackLikeSet {
|
||||||
self.apply_like_state(content_id, *liked, op.hlc_ms, &op.op_id)?
|
content_id,
|
||||||
}
|
liked,
|
||||||
|
fed,
|
||||||
|
} => self.apply_like_state(content_id, *liked, fed.as_ref(), op.hlc_ms, &op.op_id)?,
|
||||||
SyncOpPayload::PlaylistCreated { playlist_id, title } => {
|
SyncOpPayload::PlaylistCreated { playlist_id, title } => {
|
||||||
self.apply_playlist_state(playlist_id, title, false, op.hlc_ms, &op.op_id)?
|
self.apply_playlist_state(playlist_id, title, false, op.hlc_ms, &op.op_id)?
|
||||||
}
|
}
|
||||||
@@ -1108,26 +1223,34 @@ impl DeviceSync {
|
|||||||
&self,
|
&self,
|
||||||
content_id: &str,
|
content_id: &str,
|
||||||
liked: bool,
|
liked: bool,
|
||||||
|
fed: Option<&SyncedFedTrack>,
|
||||||
hlc_ms: i64,
|
hlc_ms: i64,
|
||||||
op_id: &str,
|
op_id: &str,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let apply = {
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
let conn = lock(&self.conn);
|
|
||||||
let current: Option<(i64, String)> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT hlc_ms, op_id FROM sync_state_likes WHERE content_id = ?1",
|
|
||||||
[content_id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.optional()?;
|
|
||||||
current.as_ref().is_none_or(|(current_hlc, current_op)| {
|
|
||||||
(hlc_ms, op_id) > (*current_hlc, current_op.as_str())
|
|
||||||
})
|
|
||||||
};
|
|
||||||
if !apply {
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
};
|
||||||
{
|
let current = {
|
||||||
|
let conn = lock(&self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT liked, hlc_ms, op_id
|
||||||
|
FROM sync_state_likes
|
||||||
|
WHERE content_id = ?1",
|
||||||
|
[&content_id],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, i64>(0)? != 0,
|
||||||
|
row.get::<_, i64>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
};
|
||||||
|
let apply = current.as_ref().is_none_or(|(_, current_hlc, current_op)| {
|
||||||
|
(hlc_ms, op_id) > (*current_hlc, current_op.as_str())
|
||||||
|
});
|
||||||
|
if apply {
|
||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO sync_state_likes (content_id, liked, hlc_ms, op_id)
|
"INSERT INTO sync_state_likes (content_id, liked, hlc_ms, op_id)
|
||||||
@@ -1139,10 +1262,25 @@ impl DeviceSync {
|
|||||||
params![content_id, i64::from(liked), hlc_ms, op_id],
|
params![content_id, i64::from(liked), hlc_ms, op_id],
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
if let Some(track_id) = self.library.track_id_by_content_id(content_id)? {
|
let effective_liked = if apply {
|
||||||
self.library.set_like(track_id, liked)?;
|
liked
|
||||||
|
} else {
|
||||||
|
current.map(|(liked, _, _)| liked).unwrap_or(false)
|
||||||
|
};
|
||||||
|
let mut changed = apply;
|
||||||
|
if let Some(track_id) = self.library.track_id_by_content_id(&content_id)? {
|
||||||
|
changed |= self.library.set_like(track_id, effective_liked)?;
|
||||||
|
if effective_liked {
|
||||||
|
changed |= self.library.remove_fed_like_by_content_id(&content_id)?;
|
||||||
|
}
|
||||||
|
} else if effective_liked {
|
||||||
|
if let Some(fed) = fed {
|
||||||
|
changed |= self.library.upsert_synced_fed_like(&fed.to_fed_track())?;
|
||||||
|
}
|
||||||
|
} else if apply {
|
||||||
|
changed |= self.library.remove_fed_like_by_content_id(&content_id)?;
|
||||||
}
|
}
|
||||||
Ok(true)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_playlist_state(
|
fn apply_playlist_state(
|
||||||
@@ -1252,7 +1390,13 @@ impl DeviceSync {
|
|||||||
fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> {
|
fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for like in snapshot.likes {
|
for like in snapshot.likes {
|
||||||
changed |= self.apply_like_state(&like.content_id, true, like.hlc_ms, &like.op_id)?;
|
changed |= self.apply_like_state(
|
||||||
|
&like.content_id,
|
||||||
|
true,
|
||||||
|
like.fed.as_ref(),
|
||||||
|
like.hlc_ms,
|
||||||
|
&like.op_id,
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
for playlist in snapshot.playlists {
|
for playlist in snapshot.playlists {
|
||||||
changed |= self.apply_playlist_state(
|
changed |= self.apply_playlist_state(
|
||||||
@@ -1415,23 +1559,40 @@ impl DeviceSync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&self) -> Result<SyncSnapshot> {
|
fn snapshot(&self) -> Result<SyncSnapshot> {
|
||||||
let conn = lock(&self.conn);
|
let like_rows = {
|
||||||
let mut likes_stmt = conn.prepare(
|
let conn = lock(&self.conn);
|
||||||
"SELECT content_id, hlc_ms, op_id
|
let mut likes_stmt = conn.prepare(
|
||||||
FROM sync_state_likes
|
"SELECT content_id, hlc_ms, op_id
|
||||||
WHERE liked = 1
|
FROM sync_state_likes
|
||||||
ORDER BY content_id",
|
WHERE liked = 1
|
||||||
)?;
|
ORDER BY content_id",
|
||||||
let likes = likes_stmt
|
)?;
|
||||||
.query_map([], |row| {
|
likes_stmt
|
||||||
Ok(SnapshotLike {
|
.query_map([], |row| {
|
||||||
content_id: row.get(0)?,
|
Ok((
|
||||||
hlc_ms: row.get(1)?,
|
row.get::<_, String>(0)?,
|
||||||
op_id: row.get(2)?,
|
row.get::<_, i64>(1)?,
|
||||||
})
|
row.get::<_, String>(2)?,
|
||||||
})?
|
))
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
|
};
|
||||||
|
let mut likes = Vec::with_capacity(like_rows.len());
|
||||||
|
for (content_id, hlc_ms, op_id) in like_rows {
|
||||||
|
let fed = self
|
||||||
|
.library
|
||||||
|
.fed_like_by_content_id(&content_id)?
|
||||||
|
.as_ref()
|
||||||
|
.and_then(SyncedFedTrack::from_fed);
|
||||||
|
likes.push(SnapshotLike {
|
||||||
|
content_id,
|
||||||
|
hlc_ms,
|
||||||
|
op_id,
|
||||||
|
fed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let conn = lock(&self.conn);
|
||||||
let mut playlist_stmt = conn.prepare(
|
let mut playlist_stmt = conn.prepare(
|
||||||
"SELECT playlist_id, title, hlc_ms, op_id
|
"SELECT playlist_id, title, hlc_ms, op_id
|
||||||
FROM sync_state_playlists
|
FROM sync_state_playlists
|
||||||
@@ -1523,6 +1684,16 @@ impl DeviceSync {
|
|||||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn active_device_count(&self) -> Result<usize> {
|
||||||
|
let conn = lock(&self.conn);
|
||||||
|
Ok(conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sync_devices
|
||||||
|
WHERE trusted_at_ms IS NOT NULL AND revoked_at_ms IS NULL",
|
||||||
|
[],
|
||||||
|
|row| row.get::<_, i64>(0),
|
||||||
|
)? as usize)
|
||||||
|
}
|
||||||
|
|
||||||
fn status_inner(&self) -> Result<DeviceSyncStatus> {
|
fn status_inner(&self) -> Result<DeviceSyncStatus> {
|
||||||
let identity = self.ensure_identity()?;
|
let identity = self.ensure_identity()?;
|
||||||
let conn = lock(&self.conn);
|
let conn = lock(&self.conn);
|
||||||
@@ -1746,12 +1917,26 @@ async fn serve_one(
|
|||||||
invite_id,
|
invite_id,
|
||||||
secret,
|
secret,
|
||||||
profile,
|
profile,
|
||||||
|
group_id,
|
||||||
|
group_active_devices,
|
||||||
|
devices,
|
||||||
vector,
|
vector,
|
||||||
ops,
|
ops,
|
||||||
snapshot,
|
snapshot,
|
||||||
} => {
|
} => {
|
||||||
handle_pair_request(
|
handle_pair_request(
|
||||||
stream, sync, service, invite_id, secret, profile, vector, ops, snapshot,
|
stream,
|
||||||
|
sync,
|
||||||
|
service,
|
||||||
|
invite_id,
|
||||||
|
secret,
|
||||||
|
profile,
|
||||||
|
group_id,
|
||||||
|
group_active_devices,
|
||||||
|
devices,
|
||||||
|
vector,
|
||||||
|
ops,
|
||||||
|
snapshot,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
@@ -1780,6 +1965,9 @@ async fn handle_pair_request(
|
|||||||
invite_id: String,
|
invite_id: String,
|
||||||
secret: String,
|
secret: String,
|
||||||
mut profile: DeviceProfileWire,
|
mut profile: DeviceProfileWire,
|
||||||
|
requester_group_id: Option<String>,
|
||||||
|
requester_group_active_devices: usize,
|
||||||
|
requester_group_devices: Vec<DeviceProfileWire>,
|
||||||
vector: BTreeMap<String, i64>,
|
vector: BTreeMap<String, i64>,
|
||||||
ops: Vec<SyncOpWire>,
|
ops: Vec<SyncOpWire>,
|
||||||
snapshot: SyncSnapshot,
|
snapshot: SyncSnapshot,
|
||||||
@@ -1810,13 +1998,24 @@ async fn handle_pair_request(
|
|||||||
finish_response(&mut stream).await?;
|
finish_response(&mut stream).await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let identity = sync.ensure_identity()?;
|
||||||
|
let requester_group_id = requester_group_id.filter(|id| !id.trim().is_empty());
|
||||||
|
let requester_group_active_devices = requester_group_active_devices.max(1);
|
||||||
|
let requester_group_conflict = requester_group_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|group_id| group_id != identity.group_id)
|
||||||
|
&& requester_group_active_devices > 1;
|
||||||
|
let requester_group_devices_json = serde_json::to_string(&requester_group_devices)?;
|
||||||
let inserted = {
|
let inserted = {
|
||||||
let conn = lock(&sync.conn);
|
let conn = lock(&sync.conn);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO sync_pending_pairing
|
"INSERT OR IGNORE INTO sync_pending_pairing
|
||||||
(request_id, device_id, name, client_version, endpoint_id,
|
(request_id, device_id, name, client_version, endpoint_id,
|
||||||
endpoint_ticket, invite_id, created_at_ms, status)
|
endpoint_ticket, invite_id, created_at_ms, status,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending')",
|
requester_group_id, requester_group_active_devices,
|
||||||
|
requester_group_devices_json, use_requester_group)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending',
|
||||||
|
?9, ?10, ?11, 0)",
|
||||||
params![
|
params![
|
||||||
request_id,
|
request_id,
|
||||||
profile.device_id,
|
profile.device_id,
|
||||||
@@ -1826,6 +2025,9 @@ async fn handle_pair_request(
|
|||||||
profile.endpoint_ticket,
|
profile.endpoint_ticket,
|
||||||
invite_id,
|
invite_id,
|
||||||
now_ms(),
|
now_ms(),
|
||||||
|
requester_group_id.as_deref(),
|
||||||
|
requester_group_active_devices as i64,
|
||||||
|
requester_group_devices_json,
|
||||||
],
|
],
|
||||||
)?
|
)?
|
||||||
};
|
};
|
||||||
@@ -1837,9 +2039,16 @@ async fn handle_pair_request(
|
|||||||
device_id: profile.device_id.clone(),
|
device_id: profile.device_id.clone(),
|
||||||
name: profile.name.clone(),
|
name: profile.name.clone(),
|
||||||
client_version: profile.client_version.clone(),
|
client_version: profile.client_version.clone(),
|
||||||
|
requester_group_id: requester_group_conflict
|
||||||
|
.then(|| requester_group_id.clone())
|
||||||
|
.flatten(),
|
||||||
|
requester_group_active_devices: requester_group_conflict
|
||||||
|
.then_some(requester_group_active_devices)
|
||||||
|
.unwrap_or(0),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
match pairing_status(&sync, &request_id)?.as_deref() {
|
let pairing = pairing_status(&sync, &request_id)?;
|
||||||
|
match pairing.as_ref().map(|status| status.status.as_str()) {
|
||||||
Some("pending") => {
|
Some("pending") => {
|
||||||
write_msg(
|
write_msg(
|
||||||
&mut stream,
|
&mut stream,
|
||||||
@@ -1881,9 +2090,21 @@ async fn handle_pair_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let use_requester_group = pairing
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|status| status.use_requester_group);
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
let own_profile = sync.own_profile(&own_ticket)?;
|
let own_profile = sync.own_profile(&own_ticket)?;
|
||||||
let identity = sync.ensure_identity()?;
|
let mut response_group_id = identity.group_id;
|
||||||
|
if use_requester_group
|
||||||
|
&& let Some(group_id) = requester_group_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|group_id| !group_id.trim().is_empty())
|
||||||
|
{
|
||||||
|
sync.set_group_id(group_id)?;
|
||||||
|
response_group_id = group_id.to_string();
|
||||||
|
sync.apply_device_profiles(&requester_group_devices)?;
|
||||||
|
}
|
||||||
sync.apply_device_profile(&profile, true)?;
|
sync.apply_device_profile(&profile, true)?;
|
||||||
sync.apply_snapshot(snapshot)?;
|
sync.apply_snapshot(snapshot)?;
|
||||||
sync.apply_ops(ops)?;
|
sync.apply_ops(ops)?;
|
||||||
@@ -1906,7 +2127,7 @@ async fn handle_pair_request(
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
pending: false,
|
pending: false,
|
||||||
error: None,
|
error: None,
|
||||||
group_id: Some(identity.group_id),
|
group_id: Some(response_group_id),
|
||||||
profile: Some(own_profile),
|
profile: Some(own_profile),
|
||||||
devices,
|
devices,
|
||||||
vector,
|
vector,
|
||||||
@@ -2000,13 +2221,20 @@ async fn handle_hello(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pairing_status(sync: &DeviceSync, request_id: &str) -> Result<Option<String>> {
|
fn pairing_status(sync: &DeviceSync, request_id: &str) -> Result<Option<PairingStatus>> {
|
||||||
let conn = lock(&sync.conn);
|
let conn = lock(&sync.conn);
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT status FROM sync_pending_pairing WHERE request_id = ?1",
|
"SELECT status, use_requester_group
|
||||||
|
FROM sync_pending_pairing
|
||||||
|
WHERE request_id = ?1",
|
||||||
[request_id],
|
[request_id],
|
||||||
|row| row.get(0),
|
|row| {
|
||||||
|
Ok(PairingStatus {
|
||||||
|
status: row.get(0)?,
|
||||||
|
use_requester_group: row.get::<_, i64>(1)? != 0,
|
||||||
|
})
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
@@ -2099,16 +2327,20 @@ CREATE TABLE IF NOT EXISTS sync_invites (
|
|||||||
used_at_ms INTEGER
|
used_at_ms INTEGER
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS sync_pending_pairing (
|
CREATE TABLE IF NOT EXISTS sync_pending_pairing (
|
||||||
request_id TEXT PRIMARY KEY,
|
request_id TEXT PRIMARY KEY,
|
||||||
device_id TEXT NOT NULL,
|
device_id TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
client_version TEXT NOT NULL,
|
client_version TEXT NOT NULL,
|
||||||
endpoint_id TEXT NOT NULL,
|
endpoint_id TEXT NOT NULL,
|
||||||
endpoint_ticket TEXT NOT NULL,
|
endpoint_ticket TEXT NOT NULL,
|
||||||
invite_id TEXT NOT NULL,
|
invite_id TEXT NOT NULL,
|
||||||
created_at_ms INTEGER NOT NULL,
|
created_at_ms INTEGER NOT NULL,
|
||||||
answered_at_ms INTEGER,
|
answered_at_ms INTEGER,
|
||||||
status TEXT NOT NULL
|
status TEXT NOT NULL,
|
||||||
|
requester_group_id TEXT,
|
||||||
|
requester_group_active_devices INTEGER NOT NULL DEFAULT 1,
|
||||||
|
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
use_requester_group INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS sync_ops (
|
CREATE TABLE IF NOT EXISTS sync_ops (
|
||||||
op_id TEXT PRIMARY KEY,
|
op_id TEXT PRIMARY KEY,
|
||||||
@@ -2161,9 +2393,56 @@ CREATE TABLE IF NOT EXISTS sync_state_playlist_items (
|
|||||||
);
|
);
|
||||||
"#,
|
"#,
|
||||||
)?;
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"sync_pending_pairing",
|
||||||
|
"requester_group_id",
|
||||||
|
"ALTER TABLE sync_pending_pairing ADD COLUMN requester_group_id TEXT",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"sync_pending_pairing",
|
||||||
|
"requester_group_active_devices",
|
||||||
|
"ALTER TABLE sync_pending_pairing
|
||||||
|
ADD COLUMN requester_group_active_devices INTEGER NOT NULL DEFAULT 1",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"sync_pending_pairing",
|
||||||
|
"requester_group_devices_json",
|
||||||
|
"ALTER TABLE sync_pending_pairing
|
||||||
|
ADD COLUMN requester_group_devices_json TEXT NOT NULL DEFAULT '[]'",
|
||||||
|
)?;
|
||||||
|
ensure_column(
|
||||||
|
conn,
|
||||||
|
"sync_pending_pairing",
|
||||||
|
"use_requester_group",
|
||||||
|
"ALTER TABLE sync_pending_pairing
|
||||||
|
ADD COLUMN use_requester_group INTEGER NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_column(conn: &Connection, table: &str, column: &str, ddl: &str) -> Result<()> {
|
||||||
|
if table_has_column(conn, table, column)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
conn.execute(ddl, [])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_has_column(conn: &Connection, table: &str, column: &str) -> Result<bool> {
|
||||||
|
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
if name == column {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
fn get_meta(conn: &Connection, key: &str) -> Result<Option<String>> {
|
fn get_meta(conn: &Connection, key: &str) -> Result<Option<String>> {
|
||||||
Ok(conn
|
Ok(conn
|
||||||
.query_row("SELECT value FROM sync_meta WHERE key = ?1", [key], |row| {
|
.query_row("SELECT value FROM sync_meta WHERE key = ?1", [key], |row| {
|
||||||
@@ -2469,6 +2748,23 @@ mod tests {
|
|||||||
!= 0
|
!= 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_fed_track(content_id: &str) -> crate::federation::FedTrack {
|
||||||
|
crate::federation::FedTrack {
|
||||||
|
item_id: "fed_item_1".to_string(),
|
||||||
|
owner: "fed_owner_1".to_string(),
|
||||||
|
own: false,
|
||||||
|
title: "Remote Song".to_string(),
|
||||||
|
artist_names: vec!["Remote Artist".to_string()],
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
year: Some(2026),
|
||||||
|
duration_seconds: Some(123),
|
||||||
|
content_id: Some(content_id.to_string()),
|
||||||
|
release_title: Some("Remote Release".to_string()),
|
||||||
|
track_number: Some(1),
|
||||||
|
disc_number: Some(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn base64url_round_trip_without_padding() {
|
fn base64url_round_trip_without_padding() {
|
||||||
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] {
|
for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] {
|
||||||
@@ -2483,14 +2779,16 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
SyncOpPayload::TrackLikeSet {
|
SyncOpPayload::TrackLikeSet {
|
||||||
content_id: "b3:0".into(),
|
content_id: "b3:0".into(),
|
||||||
liked: false
|
liked: false,
|
||||||
|
fed: None,
|
||||||
}
|
}
|
||||||
.is_tombstone()
|
.is_tombstone()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!SyncOpPayload::TrackLikeSet {
|
!SyncOpPayload::TrackLikeSet {
|
||||||
content_id: "b3:0".into(),
|
content_id: "b3:0".into(),
|
||||||
liked: true
|
liked: true,
|
||||||
|
fed: None,
|
||||||
}
|
}
|
||||||
.is_tombstone()
|
.is_tombstone()
|
||||||
);
|
);
|
||||||
@@ -2532,4 +2830,32 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!device_revoked(&sync, device_id));
|
assert!(!device_revoked(&sync, device_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn synced_fed_like_metadata_repairs_existing_like_state() {
|
||||||
|
let sync = test_sync();
|
||||||
|
let content_id = format!("b3:{}", "a".repeat(64));
|
||||||
|
let fed = test_fed_track(&content_id);
|
||||||
|
let synced = SyncedFedTrack::from_fed(&fed).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
sync.apply_like_state(&content_id, true, None, 10, "dev_remote:1")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
sync.apply_like_state(&content_id, true, Some(&synced), 10, "dev_remote:1")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let keys = sync.library.fed_like_ids().unwrap();
|
||||||
|
assert!(keys.contains(&fed.item_id));
|
||||||
|
assert!(keys.contains(&content_id));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
sync.apply_like_state(&content_id, false, None, 11, "dev_remote:2")
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(sync.library.fed_like_ids().unwrap().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-36
@@ -261,10 +261,6 @@ fn unix_time_ms() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn personal_sync_network_name(group_id: &str) -> String {
|
|
||||||
format!("furumi-device-sync:{group_id}")
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64> {
|
async fn dht_record_payload_bytes(data_dir: PathBuf, now_ms: u64) -> Result<u64> {
|
||||||
tokio::task::spawn_blocking(move || -> Result<u64> {
|
tokio::task::spawn_blocking(move || -> Result<u64> {
|
||||||
let path = data_dir.join("state.sqlite3");
|
let path = data_dir.join("state.sqlite3");
|
||||||
@@ -350,7 +346,7 @@ impl Federation {
|
|||||||
if settings.enabled {
|
if settings.enabled {
|
||||||
self.start(settings.network_id.trim().to_string()).await?;
|
self.start(settings.network_id.trim().to_string()).await?;
|
||||||
self.spawn_sync_soon().await;
|
self.spawn_sync_soon().await;
|
||||||
} else if !self.start_personal_sync_if_needed().await? {
|
} else {
|
||||||
self.stop().await;
|
self.stop().await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -365,23 +361,9 @@ impl Federation {
|
|||||||
} else {
|
} else {
|
||||||
self.spawn_sync_soon().await;
|
self.spawn_sync_soon().await;
|
||||||
}
|
}
|
||||||
} else if let Err(err) = self.start_personal_sync_if_needed().await {
|
|
||||||
tracing::error!("device-sync autostart failed: {err:#}");
|
|
||||||
self.set_error(Some(format!("device sync autostart failed: {err}")));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_personal_sync_if_needed(self: &Arc<Self>) -> Result<bool> {
|
|
||||||
let status = self.devices.status();
|
|
||||||
if status.active_devices <= 1 {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let network_name = personal_sync_network_name(&status.group_id);
|
|
||||||
self.start_with_network_id(NetworkId::from_name(&network_name), "device-sync".into())
|
|
||||||
.await?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Starts the DHT node. Idempotent per network name.
|
/// Starts the DHT node. Idempotent per network name.
|
||||||
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
async fn start(self: &Arc<Self>, network_name: String) -> Result<()> {
|
||||||
self.start_with_network_id(NetworkId::from_name(&network_name), network_name)
|
self.start_with_network_id(NetworkId::from_name(&network_name), network_name)
|
||||||
@@ -509,6 +491,24 @@ impl Federation {
|
|||||||
.context("federation is not running")
|
.context("federation is not running")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_connected_devices_enabled(&self) -> Result<()> {
|
||||||
|
let settings = self.settings();
|
||||||
|
anyhow::ensure!(
|
||||||
|
settings.enabled && !settings.network_id.trim().is_empty(),
|
||||||
|
"enable federation before using connected devices"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn running_service_and_network(&self) -> Result<(Arc<MusicDhtService>, NetworkId)> {
|
||||||
|
self.running
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.map(|running| (Arc::clone(&running.service), running.network_id))
|
||||||
|
.context("federation is not running")
|
||||||
|
}
|
||||||
|
|
||||||
/// Publishes the library immediately (used right after start/settings).
|
/// Publishes the library immediately (used right after start/settings).
|
||||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
if let Ok(service) = self.service().await {
|
if let Ok(service) = self.service().await {
|
||||||
@@ -924,33 +924,24 @@ impl Federation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn device_invite(self: &Arc<Self>) -> Result<String> {
|
pub async fn device_invite(self: &Arc<Self>) -> Result<String> {
|
||||||
if self.running.lock().await.is_none() {
|
self.ensure_connected_devices_enabled()?;
|
||||||
let status = self.devices.status();
|
|
||||||
let network_name = personal_sync_network_name(&status.group_id);
|
|
||||||
self.start_with_network_id(NetworkId::from_name(&network_name), "device-sync".into())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
let service = self.service().await?;
|
let service = self.service().await?;
|
||||||
self.devices.create_invite(service).await
|
self.devices.create_invite(service).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn device_connect(self: &Arc<Self>, invite: &str) -> Result<String> {
|
pub async fn device_connect(self: &Arc<Self>, invite: &str) -> Result<String> {
|
||||||
|
self.ensure_connected_devices_enabled()?;
|
||||||
let network_id = crate::devices::invite_network_id(invite)?;
|
let network_id = crate::devices::invite_network_id(invite)?;
|
||||||
let needs_start = self
|
let (service, running_network_id) = self.running_service_and_network().await?;
|
||||||
.running
|
anyhow::ensure!(
|
||||||
.lock()
|
running_network_id == network_id,
|
||||||
.await
|
"device invite belongs to a different federation network"
|
||||||
.as_ref()
|
);
|
||||||
.is_none_or(|running| running.network_id != network_id);
|
|
||||||
if needs_start {
|
|
||||||
self.start_with_network_id(network_id, "device-invite".to_string())
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
let service = self.service().await?;
|
|
||||||
self.devices.connect_invite(service, invite).await
|
self.devices.connect_invite(service, invite).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn device_sync_now(self: &Arc<Self>) -> Result<()> {
|
pub async fn device_sync_now(self: &Arc<Self>) -> Result<()> {
|
||||||
|
self.ensure_connected_devices_enabled()?;
|
||||||
let service = self.service().await?;
|
let service = self.service().await?;
|
||||||
self.devices.sync_once(service).await
|
self.devices.sync_once(service).await
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-13
@@ -841,17 +841,17 @@ impl Library {
|
|||||||
.optional()?)
|
.optional()?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_like(&self, track_id: i64, liked: bool) -> Result<()> {
|
pub fn set_like(&self, track_id: i64, liked: bool) -> Result<bool> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
if liked {
|
let changed = if liked {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
|
"INSERT OR IGNORE INTO likes (track_id) VALUES (?1)",
|
||||||
[track_id],
|
[track_id],
|
||||||
)?;
|
)?
|
||||||
} else {
|
} else {
|
||||||
conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?;
|
conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?
|
||||||
}
|
};
|
||||||
Ok(())
|
Ok(changed > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_content_id_to_synced_playlist(
|
pub fn add_content_id_to_synced_playlist(
|
||||||
@@ -951,23 +951,51 @@ impl Library {
|
|||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Item ids of every liked federated track (for the ♥ markers).
|
/// Item ids and content ids of every liked federated track (for the ♥ markers).
|
||||||
pub fn fed_like_ids(&self) -> Result<Vec<String>> {
|
pub fn fed_like_ids(&self) -> Result<Vec<String>> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let mut statement = conn.prepare("SELECT item_id FROM fed_likes")?;
|
let mut statement = conn.prepare("SELECT item_id, content_id FROM fed_likes")?;
|
||||||
let rows = statement
|
let rows = statement
|
||||||
.query_map([], |row| row.get(0))?
|
.query_map([], |row| {
|
||||||
.collect::<rusqlite::Result<Vec<String>>>()?;
|
Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
|
||||||
Ok(rows)
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
let mut keys = Vec::new();
|
||||||
|
for (item_id, content_id) in rows {
|
||||||
|
keys.push(item_id);
|
||||||
|
if let Some(content_id) = content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
{
|
||||||
|
keys.push(content_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggles a like on a federated track; returns the resulting state.
|
/// Toggles a like on a federated track; returns the resulting state.
|
||||||
pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let removed = conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?;
|
let content_id = fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
let removed = match content_id.as_deref() {
|
||||||
|
Some(content_id) => conn.execute(
|
||||||
|
"DELETE FROM fed_likes WHERE item_id = ?1 OR content_id = ?2",
|
||||||
|
params![fed.item_id, content_id],
|
||||||
|
)?,
|
||||||
|
None => conn.execute("DELETE FROM fed_likes WHERE item_id = ?1", [&fed.item_id])?,
|
||||||
|
};
|
||||||
if removed > 0 {
|
if removed > 0 {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
if let Some(content_id) = content_id.as_deref() {
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2",
|
||||||
|
params![content_id, fed.item_id],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
||||||
featured_artist_names, year, duration_seconds, content_id,
|
featured_artist_names, year, duration_seconds, content_id,
|
||||||
@@ -981,7 +1009,7 @@ impl Library {
|
|||||||
fed.featured_artist_names.join("; "),
|
fed.featured_artist_names.join("; "),
|
||||||
fed.year,
|
fed.year,
|
||||||
fed.duration_seconds.map(|d| d as f64),
|
fed.duration_seconds.map(|d| d as f64),
|
||||||
fed.content_id,
|
content_id,
|
||||||
fed.release_title,
|
fed.release_title,
|
||||||
fed.track_number,
|
fed.track_number,
|
||||||
fed.disc_number,
|
fed.disc_number,
|
||||||
@@ -990,6 +1018,153 @@ impl Library {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fed_like_by_content_id(
|
||||||
|
&self,
|
||||||
|
content_id: &str,
|
||||||
|
) -> Result<Option<crate::federation::FedTrack>> {
|
||||||
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
"SELECT item_id, owner, title, artist_names, featured_artist_names,
|
||||||
|
year, duration_seconds, content_id, release_title, track_number, disc_number
|
||||||
|
FROM fed_likes
|
||||||
|
WHERE content_id = ?1
|
||||||
|
ORDER BY liked_at DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)?;
|
||||||
|
let track = statement
|
||||||
|
.query_row([content_id], |row| {
|
||||||
|
let artists: String = row.get(3)?;
|
||||||
|
Ok(crate::federation::FedTrack {
|
||||||
|
item_id: row.get(0)?,
|
||||||
|
owner: row.get(1)?,
|
||||||
|
own: false,
|
||||||
|
title: row.get(2)?,
|
||||||
|
artist_names: artists
|
||||||
|
.split("; ")
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
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)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.optional()?;
|
||||||
|
Ok(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsert_synced_fed_like(&self, fed: &crate::federation::FedTrack) -> Result<bool> {
|
||||||
|
let Some(content_id) = fed
|
||||||
|
.content_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
let duplicate_rows = conn.execute(
|
||||||
|
"DELETE FROM fed_likes WHERE content_id = ?1 AND item_id != ?2",
|
||||||
|
params![content_id, fed.item_id],
|
||||||
|
)?;
|
||||||
|
let existing: Option<(
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
Option<i32>,
|
||||||
|
Option<i64>,
|
||||||
|
Option<String>,
|
||||||
|
Option<i32>,
|
||||||
|
Option<i32>,
|
||||||
|
)> = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT owner, title, artist_names, featured_artist_names,
|
||||||
|
year, duration_seconds, release_title, track_number, disc_number
|
||||||
|
FROM fed_likes
|
||||||
|
WHERE item_id = ?1",
|
||||||
|
[&fed.item_id],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get(0)?,
|
||||||
|
row.get(1)?,
|
||||||
|
row.get(2)?,
|
||||||
|
row.get(3)?,
|
||||||
|
row.get(4)?,
|
||||||
|
row.get::<_, Option<f64>>(5)?.map(|d| d.round() as i64),
|
||||||
|
row.get(6)?,
|
||||||
|
row.get(7)?,
|
||||||
|
row.get(8)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
let incoming = (
|
||||||
|
fed.owner.clone(),
|
||||||
|
fed.title.clone(),
|
||||||
|
fed.artist_names.join("; "),
|
||||||
|
fed.featured_artist_names.join("; "),
|
||||||
|
fed.year,
|
||||||
|
fed.duration_seconds,
|
||||||
|
fed.release_title.clone(),
|
||||||
|
fed.track_number,
|
||||||
|
fed.disc_number,
|
||||||
|
);
|
||||||
|
if duplicate_rows == 0 && existing.as_ref() == Some(&incoming) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
conn.execute(
|
||||||
|
"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)
|
||||||
|
ON CONFLICT(item_id) DO UPDATE SET
|
||||||
|
owner = excluded.owner,
|
||||||
|
title = excluded.title,
|
||||||
|
artist_names = excluded.artist_names,
|
||||||
|
featured_artist_names = excluded.featured_artist_names,
|
||||||
|
year = excluded.year,
|
||||||
|
duration_seconds = excluded.duration_seconds,
|
||||||
|
content_id = excluded.content_id,
|
||||||
|
release_title = excluded.release_title,
|
||||||
|
track_number = excluded.track_number,
|
||||||
|
disc_number = excluded.disc_number",
|
||||||
|
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),
|
||||||
|
content_id,
|
||||||
|
fed.release_title,
|
||||||
|
fed.track_number,
|
||||||
|
fed.disc_number,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_fed_like_by_content_id(&self, content_id: &str) -> Result<bool> {
|
||||||
|
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let conn = self.lock();
|
||||||
|
Ok(conn.execute("DELETE FROM fed_likes WHERE content_id = ?1", [content_id])? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
/// Moves a federated like onto a freshly imported local track. Returns
|
/// Moves a federated like onto a freshly imported local track. Returns
|
||||||
/// whether a transfer happened.
|
/// whether a transfer happened.
|
||||||
pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result<bool> {
|
pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result<bool> {
|
||||||
|
|||||||
+63
-15
@@ -67,48 +67,70 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
y = y.saturating_add(1);
|
y = y.saturating_add(1);
|
||||||
|
let connected_devices_enabled = state.connected_devices_enabled();
|
||||||
draw_section(frame, area, &mut y, "Connected Devices");
|
draw_section(frame, area, &mut y, "Connected Devices");
|
||||||
|
let disabled_value = "enable federation first".to_string();
|
||||||
let devices = state.federation.devices.as_ref();
|
let devices = state.federation.devices.as_ref();
|
||||||
draw_row(
|
draw_row_enabled(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&mut y,
|
&mut y,
|
||||||
cursor,
|
cursor,
|
||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
"This device name",
|
"This device name",
|
||||||
devices
|
if connected_devices_enabled {
|
||||||
.map(|status| status.this_device_name.clone())
|
devices
|
||||||
.unwrap_or_else(|| "loading…".to_string()),
|
.map(|status| status.this_device_name.clone())
|
||||||
|
.unwrap_or_else(|| "loading…".to_string())
|
||||||
|
} else {
|
||||||
|
disabled_value.clone()
|
||||||
|
},
|
||||||
|
connected_devices_enabled,
|
||||||
);
|
);
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
draw_row(
|
draw_row_enabled(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&mut y,
|
&mut y,
|
||||||
cursor,
|
cursor,
|
||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
"Generate device invite",
|
"Generate device invite",
|
||||||
"↵".to_string(),
|
if connected_devices_enabled {
|
||||||
|
"↵".to_string()
|
||||||
|
} else {
|
||||||
|
disabled_value.clone()
|
||||||
|
},
|
||||||
|
connected_devices_enabled,
|
||||||
);
|
);
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
draw_row(
|
draw_row_enabled(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&mut y,
|
&mut y,
|
||||||
cursor,
|
cursor,
|
||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
"Connect device by invite…",
|
"Connect device by invite…",
|
||||||
"↵".to_string(),
|
if connected_devices_enabled {
|
||||||
|
"↵".to_string()
|
||||||
|
} else {
|
||||||
|
disabled_value.clone()
|
||||||
|
},
|
||||||
|
connected_devices_enabled,
|
||||||
);
|
);
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
draw_row(
|
draw_row_enabled(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&mut y,
|
&mut y,
|
||||||
cursor,
|
cursor,
|
||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
"Sync devices now",
|
"Sync devices now",
|
||||||
"↵".to_string(),
|
if connected_devices_enabled {
|
||||||
|
"↵".to_string()
|
||||||
|
} else {
|
||||||
|
disabled_value.clone()
|
||||||
|
},
|
||||||
|
connected_devices_enabled,
|
||||||
);
|
);
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
if let Some(status) = devices {
|
if let Some(status) = devices {
|
||||||
@@ -125,12 +147,15 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
} else {
|
} else {
|
||||||
format!("v{}", device.client_version)
|
format!("v{}", device.client_version)
|
||||||
};
|
};
|
||||||
let value = if device.is_self || device.revoked {
|
let can_revoke = connected_devices_enabled && !device.is_self && !device.revoked;
|
||||||
|
let value = if can_revoke {
|
||||||
|
format!("{version} · revoke ↵")
|
||||||
|
} else if connected_devices_enabled {
|
||||||
version
|
version
|
||||||
} else {
|
} else {
|
||||||
format!("{version} · revoke ↵")
|
disabled_value.clone()
|
||||||
};
|
};
|
||||||
draw_row(
|
draw_row_enabled(
|
||||||
frame,
|
frame,
|
||||||
area,
|
area,
|
||||||
&mut y,
|
&mut y,
|
||||||
@@ -138,6 +163,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
state.settings_cursor,
|
state.settings_cursor,
|
||||||
&label,
|
&label,
|
||||||
value,
|
value,
|
||||||
|
connected_devices_enabled,
|
||||||
);
|
);
|
||||||
cursor += 1;
|
cursor += 1;
|
||||||
}
|
}
|
||||||
@@ -234,6 +260,19 @@ fn draw_row(
|
|||||||
cursor: usize,
|
cursor: usize,
|
||||||
label: &str,
|
label: &str,
|
||||||
value: String,
|
value: String,
|
||||||
|
) {
|
||||||
|
draw_row_enabled(frame, area, y, row_index, cursor, label, value, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_row_enabled(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
y: &mut u16,
|
||||||
|
row_index: usize,
|
||||||
|
cursor: usize,
|
||||||
|
label: &str,
|
||||||
|
value: String,
|
||||||
|
enabled: bool,
|
||||||
) {
|
) {
|
||||||
if *y >= area.y + area.height {
|
if *y >= area.y + area.height {
|
||||||
return;
|
return;
|
||||||
@@ -248,10 +287,19 @@ fn draw_row(
|
|||||||
let marker = if selected { "▶ " } else { " " };
|
let marker = if selected { "▶ " } else { " " };
|
||||||
let label_width = 48usize;
|
let label_width = 48usize;
|
||||||
let line = Line::from(vec![
|
let line = Line::from(vec![
|
||||||
Span::styled(marker, theme::accent()),
|
Span::styled(
|
||||||
|
marker,
|
||||||
|
if enabled {
|
||||||
|
theme::accent()
|
||||||
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
},
|
||||||
|
),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{label:<label_width$}"),
|
format!("{label:<label_width$}"),
|
||||||
if selected {
|
if !enabled {
|
||||||
|
theme::dim()
|
||||||
|
} else if selected {
|
||||||
theme::accent()
|
theme::accent()
|
||||||
} else {
|
} else {
|
||||||
ratatui::style::Style::default()
|
ratatui::style::Style::default()
|
||||||
|
|||||||
+3
-9
@@ -847,7 +847,7 @@ fn draw_search(frame: &mut Frame, area: Rect, state: &AppState, cursor: usize) {
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
for fed in &state.search.fed_tracks {
|
for fed in &state.search.fed_tracks {
|
||||||
let heart = if state.fed_likes.contains(&fed.item_id) {
|
let heart = if state.fed_track_liked(fed) {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
Span::raw(" ")
|
Span::raw(" ")
|
||||||
@@ -1090,10 +1090,7 @@ fn draw_fed_appearance_row(
|
|||||||
visual_selected: bool,
|
visual_selected: bool,
|
||||||
) {
|
) {
|
||||||
let track = &appearance.track;
|
let track = &appearance.track;
|
||||||
let liked = track
|
let liked = state.fed_card_track_liked(track);
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.any(|(_, item_id)| state.fed_likes.contains(item_id));
|
|
||||||
let heart = if liked {
|
let heart = if liked {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
@@ -1276,10 +1273,7 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz
|
|||||||
duration
|
duration
|
||||||
};
|
};
|
||||||
let in_selection = state.track_selection.contains(&scope, position);
|
let in_selection = state.track_selection.contains(&scope, position);
|
||||||
let liked = track
|
let liked = state.fed_card_track_liked(track);
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.any(|(_, item_id)| state.fed_likes.contains(item_id));
|
|
||||||
let heart = if liked {
|
let heart = if liked {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ pub(crate) fn track_row(
|
|||||||
let fed_liked = track
|
let fed_liked = track
|
||||||
.fed
|
.fed
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|fed| state.fed_likes.contains(&fed.item_id));
|
.is_some_and(|fed| state.fed_track_liked(fed));
|
||||||
let heart = if state.likes.contains(&track.id) || fed_liked {
|
let heart = if state.likes.contains(&track.id) || fed_liked {
|
||||||
Span::styled("♥ ", theme::accent())
|
Span::styled("♥ ", theme::accent())
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+59
-6
@@ -40,8 +40,17 @@ pub fn draw(frame: &mut Frame, state: &AppState) {
|
|||||||
device_id,
|
device_id,
|
||||||
name,
|
name,
|
||||||
client_version,
|
client_version,
|
||||||
|
requester_group_id,
|
||||||
|
requester_group_active_devices,
|
||||||
..
|
..
|
||||||
}) => draw_device_pairing(frame, device_id, name, client_version),
|
}) => draw_device_pairing(
|
||||||
|
frame,
|
||||||
|
device_id,
|
||||||
|
name,
|
||||||
|
client_version,
|
||||||
|
requester_group_id.as_deref(),
|
||||||
|
*requester_group_active_devices,
|
||||||
|
),
|
||||||
Some(Popup::ConfirmDeviceRevoke { device_id, name }) => {
|
Some(Popup::ConfirmDeviceRevoke { device_id, name }) => {
|
||||||
draw_device_revoke(frame, device_id, name)
|
draw_device_revoke(frame, device_id, name)
|
||||||
}
|
}
|
||||||
@@ -136,8 +145,20 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_device_pairing(frame: &mut Frame, device_id: &str, name: &str, client_version: &str) {
|
fn draw_device_pairing(
|
||||||
let area = centered(frame.area(), 64, 8);
|
frame: &mut Frame,
|
||||||
|
device_id: &str,
|
||||||
|
name: &str,
|
||||||
|
client_version: &str,
|
||||||
|
requester_group_id: Option<&str>,
|
||||||
|
requester_group_active_devices: usize,
|
||||||
|
) {
|
||||||
|
let group_conflict = requester_group_id.is_some() && requester_group_active_devices > 1;
|
||||||
|
let area = centered(
|
||||||
|
frame.area(),
|
||||||
|
if group_conflict { 76 } else { 64 },
|
||||||
|
if group_conflict { 12 } else { 8 },
|
||||||
|
);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(" Pair device ")
|
.title(" Pair device ")
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
@@ -145,7 +166,7 @@ fn draw_device_pairing(frame: &mut Frame, device_id: &str, name: &str, client_ve
|
|||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
frame.render_widget(Clear, area);
|
frame.render_widget(Clear, area);
|
||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
let lines = vec![
|
let mut lines = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::styled("Name ", theme::dim()),
|
Span::styled("Name ", theme::dim()),
|
||||||
Span::raw(name.to_string()),
|
Span::raw(name.to_string()),
|
||||||
@@ -158,9 +179,41 @@ fn draw_device_pairing(frame: &mut Frame, device_id: &str, name: &str, client_ve
|
|||||||
Span::styled("Device ", theme::dim()),
|
Span::styled("Device ", theme::dim()),
|
||||||
Span::raw(device_id.chars().take(24).collect::<String>()),
|
Span::raw(device_id.chars().take(24).collect::<String>()),
|
||||||
]),
|
]),
|
||||||
Line::default(),
|
|
||||||
Line::styled("y accept · n/esc deny", theme::dim()),
|
|
||||||
];
|
];
|
||||||
|
if let Some(group_id) = requester_group_id.filter(|_| group_conflict) {
|
||||||
|
lines.extend([
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("Group ", theme::dim()),
|
||||||
|
Span::raw(format!(
|
||||||
|
"{} · {requester_group_active_devices} active devices",
|
||||||
|
group_id.chars().take(24).collect::<String>()
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
Line::default(),
|
||||||
|
Line::styled(
|
||||||
|
"Recommended joins that group and keeps its peers syncing.",
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
Line::styled(
|
||||||
|
"Cancel keeps this group; that device will switch groups and its peers may stop syncing.",
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
|
Line::default(),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(" Recommended ", theme::tab_active()),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Cancel ", theme::danger_button()),
|
||||||
|
])
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
Line::styled("y recommended · c cancel · n/esc deny", theme::dim())
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
lines.extend([
|
||||||
|
Line::default(),
|
||||||
|
Line::styled("y accept · n/esc deny", theme::dim()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
frame.render_widget(Paragraph::new(lines), inner);
|
frame.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user