diff --git a/src/app/event.rs b/src/app/event.rs index 7495dd4..58a4f5a 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -56,10 +56,11 @@ pub enum AppEvent { track_id: i64, liked: bool, }, - /// Liked federated item ids for the ♥ markers. + /// Liked federated item ids and content ids for the ♥ markers. FedLikesLoaded(Result, String>), FedLikeToggled { item_id: String, + content_id: Option, liked: bool, }, /// A release fetched for queueing (a / shift-a on a release). diff --git a/src/app/mod.rs b/src/app/mod.rs index d2f3bea..2f43e80 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -459,13 +459,12 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { for fed in fed_tracks { match library.toggle_fed_like(&fed) { Ok(liked) => { - if let Err(err) = - devices.record_fed_like(fed.content_id.as_deref(), liked) - { + if let Err(err) = devices.record_fed_like(&fed, liked) { tracing::warn!(%err, title = %fed.title, "recording synced federated like failed"); } let _ = tx.send(AppEvent::FedLikeToggled { item_id: fed.item_id.clone(), + content_id: fed.content_id.clone(), liked, }); } @@ -524,6 +523,15 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) { 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 => { let fed = Arc::clone(&runtime.federation); 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, name: request.name, 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()); } @@ -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(), Err(message) => tracing::warn!(%message, "federated likes load failed"), }, - AppEvent::FedLikeToggled { item_id, liked } => { + AppEvent::FedLikeToggled { + item_id, + content_id, + liked, + } => { if liked { 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 { 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. state.playlist_views.remove(&state::LIKES_PLAYLIST_ID); diff --git a/src/app/popup.rs b/src/app/popup.rs index 0a213b5..10e3e73 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -66,6 +66,8 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { device_id, name, client_version, + requester_group_id, + requester_group_active_devices, } => handle_device_pairing( state, runtime, @@ -73,6 +75,8 @@ pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { device_id, name, client_version, + requester_group_id, + requester_group_active_devices, key, ), Popup::ConfirmDeviceRevoke { device_id, name } => { @@ -165,11 +169,14 @@ fn handle_device_pairing( device_id: String, name: String, client_version: String, + requester_group_id: Option, + requester_group_active_devices: usize, key: KeyEvent, ) { + let group_conflict = requester_group_id.is_some() && requester_group_active_devices > 1; match key.code { 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:#}")); } else { state.status_message = Some("device pairing denied".to_string()); @@ -177,10 +184,26 @@ fn handle_device_pairing( state.federation.devices = Some(runtime.devices.status()); } 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:#}")); } 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()); } @@ -190,6 +213,8 @@ fn handle_device_pairing( device_id, name, client_version, + requester_group_id, + requester_group_active_devices, }); } } diff --git a/src/app/state.rs b/src/app/state.rs index 4493fd8..f93e647 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -536,6 +536,8 @@ pub enum Popup { device_id: String, name: String, client_version: String, + requester_group_id: Option, + requester_group_active_devices: usize, }, /// Confirmation before revoking a trusted device. ConfirmDeviceRevoke { device_id: String, name: String }, @@ -819,8 +821,8 @@ pub struct AppState { pub playlist_views: HashMap>, /// Liked track ids, for the ♥ markers everywhere tracks are shown. pub likes: std::collections::HashSet, - /// Liked federated tracks (DHT item ids) — likes that reference peers' - /// tracks without downloading them. + /// Liked federated tracks (DHT item ids and content ids) — likes that + /// reference peers' tracks without downloading them. pub fed_likes: std::collections::HashSet, pub likes_loaded: bool, pub logs: LogsTab, @@ -848,3 +850,30 @@ pub struct AppState { /// every view that shows artwork. pub art: HashMap, } + +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)) + } +} diff --git a/src/app/update.rs b/src/app/update.rs index 9ddc4bb..fa8dc8c 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -292,16 +292,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option { None } else { let should_like = track_ids.iter().any(|id| !state.likes.contains(id)) - || fed_tracks - .iter() - .any(|fed| !state.fed_likes.contains(&fed.item_id)); + || fed_tracks.iter().any(|fed| !state.fed_track_liked(fed)); let toggles: Vec = track_ids .into_iter() .filter(|id| state.likes.contains(id) != should_like) .collect(); let fed_toggles: Vec = fed_tracks .into_iter() - .filter(|fed| state.fed_likes.contains(&fed.item_id) != should_like) + .filter(|fed| state.fed_track_liked(fed) != should_like) .collect(); let total = toggles.len() + fed_toggles.len(); state.status_message = Some(if should_like { @@ -2344,6 +2342,9 @@ fn federation_select(state: &mut AppState) -> Option { None } SettingsRow::DeviceName => { + if !require_connected_devices_enabled(state) { + return None; + } let name = state .federation .devices @@ -2356,16 +2357,32 @@ fn federation_select(state: &mut AppState) -> Option { }); None } - SettingsRow::DeviceInvite => Some(Effect::DeviceShowInvite), + SettingsRow::DeviceInvite => { + if !require_connected_devices_enabled(state) { + return None; + } + Some(Effect::DeviceShowInvite) + } SettingsRow::DeviceConnect => { + if !require_connected_devices_enabled(state) { + return None; + } state.popup = Some(Popup::FedInput { field: FedInputField::ConnectInvite, input: crate::app::input::LineEdit::default(), }); None } - SettingsRow::DeviceSyncNow => Some(Effect::DeviceSyncNow), + SettingsRow::DeviceSyncNow => { + if !require_connected_devices_enabled(state) { + return None; + } + Some(Effect::DeviceSyncNow) + } SettingsRow::Device(index) => { + if !require_connected_devices_enabled(state) { + return None; + } let Some(device) = state .federation .devices @@ -2433,6 +2450,15 @@ fn federation_select(state: &mut AppState) -> Option { } } +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, /// if shuffle is on, shuffle everything after the chosen track right away. pub(super) fn on_new_queue(state: &mut AppState) { diff --git a/src/devices.rs b/src/devices.rs index ddbfedc..13050fc 100644 --- a/src/devices.rs +++ b/src/devices.rs @@ -37,6 +37,8 @@ pub struct PendingPairing { pub device_id: String, pub name: String, pub client_version: String, + pub requester_group_id: Option, + pub requester_group_active_devices: usize, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -132,6 +134,8 @@ pub enum SyncOpPayload { TrackLikeSet { content_id: String, liked: bool, + #[serde(default)] + fed: Option, }, PlaylistCreated { playlist_id: String, @@ -193,6 +197,8 @@ struct SnapshotLike { content_id: String, hlc_ms: i64, op_id: String, + #[serde(default)] + fed: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -219,6 +225,67 @@ enum PairAttempt { 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, + #[serde(default)] + featured_artist_names: Vec, + year: Option, + duration_seconds: Option, + content_id: String, + release_title: Option, + track_number: Option, + disc_number: Option, +} + +impl SyncedFedTrack { + fn from_fed(fed: &crate::federation::FedTrack) -> Option { + 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)] #[serde(tag = "type", rename_all = "snake_case")] enum WireMessage { @@ -226,6 +293,12 @@ enum WireMessage { invite_id: String, secret: String, profile: DeviceProfileWire, + #[serde(default)] + group_id: Option, + #[serde(default)] + group_active_devices: usize, + #[serde(default)] + devices: Vec, vector: BTreeMap, ops: Vec, snapshot: SyncSnapshot, @@ -433,7 +506,10 @@ impl DeviceSync { ) -> Result { let peer = service.connect(ticket).await?; 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 devices = self.device_profiles()?; let vector = self.vector()?; let ops = self.ops_for_peer(&invite.device_id)?; let snapshot = self.snapshot()?; @@ -444,6 +520,9 @@ impl DeviceSync { invite_id: invite.invite_id.clone(), secret: invite.secret.clone(), profile, + group_id: Some(identity.group_id), + group_active_devices, + devices, vector, ops, snapshot, @@ -499,27 +578,37 @@ impl DeviceSync { } } - pub fn answer_pairing(&self, request_id: &str, accept: bool) -> Result<()> { - let profile = { + pub fn answer_pairing( + &self, + request_id: &str, + accept: bool, + use_requester_group: bool, + ) -> Result<()> { + let pending = { let conn = lock(&self.conn); conn.query_row( "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 WHERE request_id = ?1", [request_id], |row| { - Ok(DeviceProfileWire { - device_id: row.get(0)?, - name: row.get(1)?, - client_version: row.get(2)?, - protocol_version: PROTOCOL_VERSION, - endpoint_id: row.get(3)?, - endpoint_ticket: row.get(4)?, - revoked: false, - revoke_cutoff_seq: None, - updated_at_ms: row.get(5)?, - }) + Ok(( + DeviceProfileWire { + device_id: row.get(0)?, + name: row.get(1)?, + client_version: row.get(2)?, + protocol_version: PROTOCOL_VERSION, + endpoint_id: row.get(3)?, + endpoint_ticket: row.get(4)?, + revoked: false, + revoke_cutoff_seq: None, + updated_at_ms: row.get(5)?, + }, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + )) }, ) .optional()? @@ -528,17 +617,29 @@ impl DeviceSync { let conn = lock(&self.conn); conn.execute( "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'", params![ request_id, if accept { "accepted" } else { "denied" }, - now_ms() + now_ms(), + i64::from(use_requester_group), ], )? }; 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 = + serde_json::from_str(&requester_group_devices_json)?; + self.apply_device_profiles(&requester_devices)?; + } self.apply_device_profile(&profile, true)?; self.record_local_op(SyncOpPayload::DeviceTrusted { target_device_id: profile.device_id, @@ -569,16 +670,28 @@ impl DeviceSync { 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)? { - self.record_local_op(SyncOpPayload::TrackLikeSet { content_id, liked })?; + self.record_local_op(SyncOpPayload::TrackLikeSet { + content_id, + liked, + fed: None, + })?; } Ok(()) } - pub fn record_fed_like(&self, content_id: Option<&str>, liked: bool) -> Result<()> { - let Some(content_id) = content_id.and_then(music_dht::normalize_content_id) else { + pub fn record_fed_like(&self, fed: &crate::federation::FedTrack, liked: bool) -> Result<()> { + let Some(content_id) = fed + .content_id + .as_deref() + .and_then(music_dht::normalize_content_id) + else { 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(()) } @@ -940,9 +1053,11 @@ impl DeviceSync { fn apply_op(&self, op: &SyncOpWire) -> Result { let changed = match &op.payload { - SyncOpPayload::TrackLikeSet { content_id, liked } => { - self.apply_like_state(content_id, *liked, op.hlc_ms, &op.op_id)? - } + SyncOpPayload::TrackLikeSet { + 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 } => { self.apply_playlist_state(playlist_id, title, false, op.hlc_ms, &op.op_id)? } @@ -1108,26 +1223,34 @@ impl DeviceSync { &self, content_id: &str, liked: bool, + fed: Option<&SyncedFedTrack>, hlc_ms: i64, op_id: &str, ) -> Result { - let apply = { - 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 { + let Some(content_id) = music_dht::normalize_content_id(content_id) else { 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); conn.execute( "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], )?; } - if let Some(track_id) = self.library.track_id_by_content_id(content_id)? { - self.library.set_like(track_id, liked)?; + let effective_liked = if apply { + 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( @@ -1252,7 +1390,13 @@ impl DeviceSync { fn apply_snapshot(&self, snapshot: SyncSnapshot) -> Result<()> { let mut changed = false; 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 { changed |= self.apply_playlist_state( @@ -1415,23 +1559,40 @@ impl DeviceSync { } fn snapshot(&self) -> Result { - let conn = lock(&self.conn); - let mut likes_stmt = conn.prepare( - "SELECT content_id, hlc_ms, op_id - FROM sync_state_likes - WHERE liked = 1 - ORDER BY content_id", - )?; - let likes = likes_stmt - .query_map([], |row| { - Ok(SnapshotLike { - content_id: row.get(0)?, - hlc_ms: row.get(1)?, - op_id: row.get(2)?, - }) - })? - .collect::>>()?; + let like_rows = { + let conn = lock(&self.conn); + let mut likes_stmt = conn.prepare( + "SELECT content_id, hlc_ms, op_id + FROM sync_state_likes + WHERE liked = 1 + ORDER BY content_id", + )?; + likes_stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::>>()? + }; + 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( "SELECT playlist_id, title, hlc_ms, op_id FROM sync_state_playlists @@ -1523,6 +1684,16 @@ impl DeviceSync { Ok(rows.collect::>>()?) } + fn active_device_count(&self) -> Result { + 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 { let identity = self.ensure_identity()?; let conn = lock(&self.conn); @@ -1746,12 +1917,26 @@ async fn serve_one( invite_id, secret, profile, + group_id, + group_active_devices, + devices, vector, ops, snapshot, } => { 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 } @@ -1780,6 +1965,9 @@ async fn handle_pair_request( invite_id: String, secret: String, mut profile: DeviceProfileWire, + requester_group_id: Option, + requester_group_active_devices: usize, + requester_group_devices: Vec, vector: BTreeMap, ops: Vec, snapshot: SyncSnapshot, @@ -1810,13 +1998,24 @@ async fn handle_pair_request( finish_response(&mut stream).await?; 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 conn = lock(&sync.conn); conn.execute( "INSERT OR IGNORE INTO sync_pending_pairing (request_id, device_id, name, client_version, endpoint_id, - endpoint_ticket, invite_id, created_at_ms, status) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending')", + endpoint_ticket, invite_id, created_at_ms, status, + 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![ request_id, profile.device_id, @@ -1826,6 +2025,9 @@ async fn handle_pair_request( profile.endpoint_ticket, invite_id, 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(), name: profile.name.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") => { write_msg( &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_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_snapshot(snapshot)?; sync.apply_ops(ops)?; @@ -1906,7 +2127,7 @@ async fn handle_pair_request( accepted: true, pending: false, error: None, - group_id: Some(identity.group_id), + group_id: Some(response_group_id), profile: Some(own_profile), devices, vector, @@ -2000,13 +2221,20 @@ async fn handle_hello( Ok(()) } -fn pairing_status(sync: &DeviceSync, request_id: &str) -> Result> { +fn pairing_status(sync: &DeviceSync, request_id: &str) -> Result> { let conn = lock(&sync.conn); Ok(conn .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], - |row| row.get(0), + |row| { + Ok(PairingStatus { + status: row.get(0)?, + use_requester_group: row.get::<_, i64>(1)? != 0, + }) + }, ) .optional()?) } @@ -2099,16 +2327,20 @@ CREATE TABLE IF NOT EXISTS sync_invites ( used_at_ms INTEGER ); CREATE TABLE IF NOT EXISTS sync_pending_pairing ( - request_id TEXT PRIMARY KEY, - device_id TEXT NOT NULL, - name TEXT NOT NULL, - client_version TEXT NOT NULL, - endpoint_id TEXT NOT NULL, - endpoint_ticket TEXT NOT NULL, - invite_id TEXT NOT NULL, - created_at_ms INTEGER NOT NULL, - answered_at_ms INTEGER, - status TEXT NOT NULL + request_id TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + name TEXT NOT NULL, + client_version TEXT NOT NULL, + endpoint_id TEXT NOT NULL, + endpoint_ticket TEXT NOT NULL, + invite_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + answered_at_ms INTEGER, + 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 ( 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(()) } +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 { + 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> { Ok(conn .query_row("SELECT value FROM sync_meta WHERE key = ?1", [key], |row| { @@ -2469,6 +2748,23 @@ mod tests { != 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] fn base64url_round_trip_without_padding() { for input in [b"".as_slice(), b"a", b"ab", b"abc", b"abcdef"] { @@ -2483,14 +2779,16 @@ mod tests { assert!( SyncOpPayload::TrackLikeSet { content_id: "b3:0".into(), - liked: false + liked: false, + fed: None, } .is_tombstone() ); assert!( !SyncOpPayload::TrackLikeSet { content_id: "b3:0".into(), - liked: true + liked: true, + fed: None, } .is_tombstone() ); @@ -2532,4 +2830,32 @@ mod tests { .unwrap(); 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()); + } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index e6bdcae..a4351a4 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -261,10 +261,6 @@ fn unix_time_ms() -> u64 { .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 { tokio::task::spawn_blocking(move || -> Result { let path = data_dir.join("state.sqlite3"); @@ -350,7 +346,7 @@ impl Federation { if settings.enabled { self.start(settings.network_id.trim().to_string()).await?; self.spawn_sync_soon().await; - } else if !self.start_personal_sync_if_needed().await? { + } else { self.stop().await; } Ok(()) @@ -365,23 +361,9 @@ impl Federation { } else { 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) -> Result { - 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. async fn start(self: &Arc, network_name: String) -> Result<()> { self.start_with_network_id(NetworkId::from_name(&network_name), network_name) @@ -509,6 +491,24 @@ impl Federation { .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, 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). async fn spawn_sync_soon(self: &Arc) { if let Ok(service) = self.service().await { @@ -924,33 +924,24 @@ impl Federation { } pub async fn device_invite(self: &Arc) -> Result { - if self.running.lock().await.is_none() { - 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?; - } + self.ensure_connected_devices_enabled()?; let service = self.service().await?; self.devices.create_invite(service).await } pub async fn device_connect(self: &Arc, invite: &str) -> Result { + self.ensure_connected_devices_enabled()?; let network_id = crate::devices::invite_network_id(invite)?; - let needs_start = self - .running - .lock() - .await - .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?; + let (service, running_network_id) = self.running_service_and_network().await?; + anyhow::ensure!( + running_network_id == network_id, + "device invite belongs to a different federation network" + ); self.devices.connect_invite(service, invite).await } pub async fn device_sync_now(self: &Arc) -> Result<()> { + self.ensure_connected_devices_enabled()?; let service = self.service().await?; self.devices.sync_once(service).await } diff --git a/src/library/mod.rs b/src/library/mod.rs index 30972a8..a85c4dc 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -841,17 +841,17 @@ impl Library { .optional()?) } - pub fn set_like(&self, track_id: i64, liked: bool) -> Result<()> { + pub fn set_like(&self, track_id: i64, liked: bool) -> Result { let conn = self.lock(); - if liked { + let changed = if liked { conn.execute( "INSERT OR IGNORE INTO likes (track_id) VALUES (?1)", [track_id], - )?; + )? } else { - conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])?; - } - Ok(()) + conn.execute("DELETE FROM likes WHERE track_id = ?1", [track_id])? + }; + Ok(changed > 0) } pub fn add_content_id_to_synced_playlist( @@ -951,23 +951,51 @@ impl Library { 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> { 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 - .query_map([], |row| row.get(0))? - .collect::>>()?; - Ok(rows) + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?)) + })? + .collect::>>()?; + 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. pub fn toggle_fed_like(&self, fed: &crate::federation::FedTrack) -> Result { 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 { 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( "INSERT INTO fed_likes (item_id, owner, title, artist_names, featured_artist_names, year, duration_seconds, content_id, @@ -981,7 +1009,7 @@ impl Library { fed.featured_artist_names.join("; "), fed.year, fed.duration_seconds.map(|d| d as f64), - fed.content_id, + content_id, fed.release_title, fed.track_number, fed.disc_number, @@ -990,6 +1018,153 @@ impl Library { Ok(true) } + pub fn fed_like_by_content_id( + &self, + content_id: &str, + ) -> Result> { + 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>(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 { + 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, + Option, + Option, + Option, + Option, + )> = 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>(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 { + 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 /// whether a transfer happened. pub fn transfer_fed_like(&self, item_id: &str, track_id: i64) -> Result { diff --git a/src/ui/federation.rs b/src/ui/federation.rs index ef0e12a..943256f 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -67,48 +67,70 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { } y = y.saturating_add(1); + let connected_devices_enabled = state.connected_devices_enabled(); draw_section(frame, area, &mut y, "Connected Devices"); + let disabled_value = "enable federation first".to_string(); let devices = state.federation.devices.as_ref(); - draw_row( + draw_row_enabled( frame, area, &mut y, cursor, state.settings_cursor, "This device name", - devices - .map(|status| status.this_device_name.clone()) - .unwrap_or_else(|| "loading…".to_string()), + if connected_devices_enabled { + devices + .map(|status| status.this_device_name.clone()) + .unwrap_or_else(|| "loading…".to_string()) + } else { + disabled_value.clone() + }, + connected_devices_enabled, ); cursor += 1; - draw_row( + draw_row_enabled( frame, area, &mut y, cursor, state.settings_cursor, "Generate device invite", - "↵".to_string(), + if connected_devices_enabled { + "↵".to_string() + } else { + disabled_value.clone() + }, + connected_devices_enabled, ); cursor += 1; - draw_row( + draw_row_enabled( frame, area, &mut y, cursor, state.settings_cursor, "Connect device by invite…", - "↵".to_string(), + if connected_devices_enabled { + "↵".to_string() + } else { + disabled_value.clone() + }, + connected_devices_enabled, ); cursor += 1; - draw_row( + draw_row_enabled( frame, area, &mut y, cursor, state.settings_cursor, "Sync devices now", - "↵".to_string(), + if connected_devices_enabled { + "↵".to_string() + } else { + disabled_value.clone() + }, + connected_devices_enabled, ); cursor += 1; if let Some(status) = devices { @@ -125,12 +147,15 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { } else { 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 } else { - format!("{version} · revoke ↵") + disabled_value.clone() }; - draw_row( + draw_row_enabled( frame, area, &mut y, @@ -138,6 +163,7 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) { state.settings_cursor, &label, value, + connected_devices_enabled, ); cursor += 1; } @@ -234,6 +260,19 @@ fn draw_row( cursor: usize, label: &str, 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 { return; @@ -248,10 +287,19 @@ fn draw_row( let marker = if selected { "▶ " } else { " " }; let label_width = 48usize; let line = Line::from(vec![ - Span::styled(marker, theme::accent()), + Span::styled( + marker, + if enabled { + theme::accent() + } else { + theme::dim() + }, + ), Span::styled( format!("{label: 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 }) => { 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) { - let area = centered(frame.area(), 64, 8); +fn draw_device_pairing( + 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() .title(" Pair device ") .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); frame.render_widget(Clear, area); frame.render_widget(block, area); - let lines = vec![ + let mut lines = vec![ Line::from(vec![ Span::styled("Name ", theme::dim()), 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::raw(device_id.chars().take(24).collect::()), ]), - 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::() + )), + ]), + 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); }