diff --git a/src/core/config.rs b/src/core/config.rs index 731fbe4..1ec0b8b 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -7,6 +7,8 @@ pub struct Config { pub base_url: String, pub access_token: Option, pub user_id: Option, + pub transcode_mbps: Option, + pub transcode_default_on: Option, } @@ -45,6 +47,8 @@ fn parse_config(s: &str) -> Result { let mut base_url: Option = None; let mut access_token: Option = None; let mut user_id: Option = None; + let mut transcode_mbps: Option = None; + let mut transcode_default_on: Option = None; for (i, line) in s.lines().enumerate() { let line = line.trim(); @@ -68,6 +72,18 @@ fn parse_config(s: &str) -> Result { "base_url" => base_url = Some(val), "access_token" => access_token = Some(val), "user_id" => user_id = Some(val), + + "transcode_mbps" => { + transcode_mbps = val.parse::().ok(); + } + + "transcode_default_on" => { + transcode_default_on = match val.as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }; + } _ => {} } } @@ -78,6 +94,8 @@ fn parse_config(s: &str) -> Result { base_url, access_token, user_id, + transcode_mbps, + transcode_default_on, }) } @@ -89,15 +107,25 @@ pub fn save_config(cfg: &Config) -> Result<(), ConfigError> { let path = dir.join("config.toml"); let mut out = String::new(); + out.push_str(&format!("base_url = \"{}\"\n", cfg.base_url)); if let Some(t) = &cfg.access_token { out.push_str(&format!("access_token = \"{}\"\n", t)); } + if let Some(u) = &cfg.user_id { out.push_str(&format!("user_id = \"{}\"\n", u)); } + if let Some(m) = cfg.transcode_mbps { + out.push_str(&format!("transcode_mbps = {}\n", m)); + } + + if let Some(b) = cfg.transcode_default_on { + out.push_str(&format!("transcode_default_on = {}\n", b)); + } + std::fs::write(path, out)?; Ok(()) } diff --git a/src/core/jellyfin.rs b/src/core/jellyfin.rs index d4e3878..652baeb 100644 --- a/src/core/jellyfin.rs +++ b/src/core/jellyfin.rs @@ -173,7 +173,7 @@ impl JellyfinClient { ("Recursive", recursive), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), - ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists"), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), ("SortBy", sort_by), ("SortOrder", "Ascending"), ]); @@ -226,7 +226,7 @@ impl JellyfinClient { ("UserId", user_id), ("StartIndex", &start_index.to_string()), ("Limit", &limit.to_string()), - ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists"), + ("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear,IndexNumber,ParentIndexNumber,RunTimeTicks,Genres,Studios,Overview,SeriesName,Album,AlbumArtist,Artists,UserData"), ]) .send() .map_err(|e| format!("request failed: {e}"))?; @@ -322,6 +322,22 @@ pub struct JfStudio { pub name: String, } +#[derive(Debug, Clone, Deserialize)] +pub struct JfUserData { + #[serde(rename = "Played")] + pub played: Option, + + #[serde(rename = "PlaybackPositionTicks")] + pub playback_position_ticks: Option, + + #[serde(rename = "PlayedPercentage")] + pub played_percentage: Option, + + #[serde(rename = "UnplayedItemCount")] + pub unplayed_item_count: Option, +} + + #[derive(Debug, Clone, Deserialize)] pub struct JfItem { #[serde(rename = "Id")] @@ -362,6 +378,9 @@ pub struct JfItem { #[serde(rename = "Artists", default)] pub artists: Vec, + + #[serde(rename = "UserData")] + pub user_data: Option, } #[derive(Deserialize)] diff --git a/src/main.rs b/src/main.rs index 7a20909..f404e48 100644 --- a/src/main.rs +++ b/src/main.rs @@ -181,6 +181,10 @@ struct App { autoplay_next_index: Option, autoplay_eof_latched: bool, autoplay_prev_time_pos: Option, + + transcode_on: bool, + transcode_mbps: u32, + } #[derive(Debug)] @@ -193,6 +197,16 @@ impl Default for App { fn default() -> Self { let loaded = core::config::load_config().ok(); + let default_mbps = loaded + .as_ref() + .and_then(|c| c.transcode_mbps) + .unwrap_or(5); + + let default_on = loaded + .as_ref() + .and_then(|c| c.transcode_default_on) + .unwrap_or(false); + let (view_stack, config_status) = match &loaded { Some(cfg) if cfg.access_token.is_some() => ( vec![View::Categories], @@ -246,6 +260,8 @@ impl Default for App { autoplay_next_index: None, autoplay_eof_latched: false, autoplay_prev_time_pos: None, + transcode_on: default_on, + transcode_mbps: default_mbps, } } } @@ -395,6 +411,7 @@ fn handle_key(app: &mut App, code: KeyCode) { } return; } + KeyCode::Char('C') => { reload_config(app); return; } KeyCode::Char(c) => { match app.login_focus { @@ -585,13 +602,21 @@ fn handle_key(app: &mut App, code: KeyCode) { app.mpv_now_playing = Some(item_name); // If mpv running -> replace, else spawn + let url = build_jellyfin_play_url( + &cfg, + &item_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + if let Some(mpv) = &app.mpv { - match mpv_load_item(&cfg, mpv, &item_id) { + match mpv_load_url(&cfg, mpv, &url) { Ok(_) => app.status = "mpv: loaded".into(), Err(e) => app.status = format!("mpv load failed: {e}"), } } else { - match spawn_mpv(&cfg, &item_id) { + match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); @@ -622,13 +647,21 @@ fn handle_key(app: &mut App, code: KeyCode) { app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; // load/replace or spawn + let url = build_jellyfin_play_url( + &cfg, + &item_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + if let Some(mpv) = &app.mpv { - match mpv_load_item(&cfg, mpv, &item_id) { + match mpv_load_url(&cfg, mpv, &url) { Ok(_) => app.status = "mpv: playing (autoplay)".into(), Err(e) => app.status = format!("mpv load failed: {e}"), } } else { - match spawn_mpv(&cfg, &item_id) { + match spawn_mpv_url(&cfg, &url) { Ok(mpv) => { let rx = start_mpv_poller(mpv.socket_path.clone()); app.mpv_state_rx = Some(rx); @@ -640,6 +673,116 @@ fn handle_key(app: &mut App, code: KeyCode) { } return; } + KeyCode::Char('t') => { + app.transcode_on = !app.transcode_on; + app.status = if app.transcode_on { + format!("Transcoding ON ({}Mbps)", app.transcode_mbps) + } else { + "Transcoding OFF".into() + }; + return; + } + KeyCode::Char('r') => { + app.autoplay = false; + app.autoplay_next_index = None; + + let cfg = match &app.config { + Some(c) => c.clone(), + None => { app.status = "No config loaded".into(); return; } + }; + + let (item_id, item_name, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + let url = build_jellyfin_play_url( + &cfg, + &item_id, + start_ticks, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => app.status = "mpv: resumed".into(), + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: resumed".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } + KeyCode::Char('R') => { + // resume + autoplay + let cfg = match &app.config { Some(c) => c.clone(), None => { app.status="No config".into(); return; } }; + + let (item_id, item_name, selected, len, start_ticks) = match app.current_view() { + View::Library { items, selected, .. } if !items.is_empty() => { + let it = &items[*selected]; + let ty = it.item_type.as_deref().unwrap_or(""); + if ty != "Episode" && ty != "Movie" { + app.status = "Resume only works on Episodes/Movies (for now).".into(); + return; + } + (it.id.clone(), it.name.clone(), *selected, items.len(), playback_position_ticks(it)) + } + _ => { app.status = "Nothing to resume here".into(); return; } + }; + + app.mpv_now_playing = Some(item_name); + + app.autoplay = true; + app.autoplay_prev_time_pos = None; + app.autoplay_view_depth = app.view_stack.len(); + app.autoplay_next_index = if selected + 1 < len { Some(selected + 1) } else { None }; + app.autoplay_eof_latched = false; + + let url = build_jellyfin_play_url( + &cfg, + &item_id, + start_ticks, + app.transcode_on, + app.transcode_mbps, + ); + + if let Some(mpv) = &app.mpv { + match mpv_cmd(&mpv.socket_path, &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url)) { + Ok(_) => app.status = "mpv: resumed (autoplay)".into(), + Err(e) => app.status = format!("mpv resume failed: {e}"), + } + } else { + match spawn_mpv_url(&cfg, &url) { + Ok(mpv) => { + let rx = start_mpv_poller(mpv.socket_path.clone()); + app.mpv_state_rx = Some(rx); + app.mpv = Some(mpv); + app.status = "mpv: resumed (autoplay)".into(); + } + Err(e) => app.status = format!("mpv spawn failed: {e}"), + } + } + return; + } _ => {} } } @@ -1066,15 +1209,31 @@ fn format_details(it: &core::jellyfin::JfItem) -> String { overview_str(&it.overview), ), - "Movie" => format!( - "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}\n\nOverview:\n{}", - it.name, - year_str(it.production_year), - ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), - join_genres(&it.genres), - join_studios(&it.studios), - overview_str(&it.overview), - ), + "Movie" => { + let mut header = format!( + "Name: {}\nYear: {}\nRuntime: {}\nGenres: {}\nStudio: {}", + it.name, + year_str(it.production_year), + ticks_to_runtime_str(it.run_time_ticks).unwrap_or_else(|| "-".into()), + join_genres(&it.genres), + join_studios(&it.studios), + ); + + if is_in_progress(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, + overview_str(&it.overview), + ) + } "Episode" => { let series = it.series_name.clone().unwrap_or_else(|| "-".into()); @@ -1083,12 +1242,26 @@ fn format_details(it: &core::jellyfin::JfItem) -> String { _ => "-".into(), }; - format!( - "Series: {}\nEpisode: {} {}\nYear: {}\n\nOverview:\n{}", + let mut header = format!( + "Series: {}\nEpisode: {} {}\nYear: {}", series, se, it.name, year_str(it.production_year), + ); + + if is_in_progress(it) { + let pos = playback_position_ticks(it).unwrap_or(0); + let pos_secs = (pos / 10_000_000) as f64; + header.push_str(&format!( + "\nIn progress ({}): press r/R to resume.", + fmt_hms(pos_secs) + )); + } + + format!( + "{}\n\nOverview:\n{}", + header, overview_str(&it.overview), ) } @@ -1240,8 +1413,14 @@ fn ui(frame: &mut Frame, app: &App) { .map(|c| c.base_url.as_str()) .unwrap_or("-"); + let tx_line = if app.transcode_on { + format!("Transcoding On ({}Mbps)", app.transcode_mbps) + } else { + "Transcoding Off".to_string() + }; + let right_text = format!( - "{connected}\n\np/P: play/autoplay\nd: download\nq: quit\n/help: more help" + "{connected}\n{tx_line}\n\np/P: play/autoplay\nq: quit\n/help: more help" ); frame.render_widget( @@ -1373,13 +1552,14 @@ fn render_library( true }) .map(|it| { + let emoji = emoji_prefix_for(it); let prefix = left_number_prefix(it, show_audio_track_numbers); let year = if show_year_for(it.item_type.as_deref()) { it.production_year.map(|y| format!(" ({y})")).unwrap_or_default() } else { String::new() }; - ListItem::new(format!("{}{}{}", prefix, it.name, year)) + ListItem::new(format!("{}{}{}{}", emoji, prefix, it.name, year)) }) .collect(); @@ -1665,7 +1845,6 @@ fn selected_tracks_from_track_list(track_list: &Value) -> (Option, Optio (audio, subs) } - fn mpv_get_f64_jtui(socket: &PathBuf, prop: &str, request_id: u32) -> Result, String> { Ok(mpv_get_prop_jtui(socket, prop, request_id)?.as_f64()) } @@ -1793,7 +1972,15 @@ fn autoplay_tick(app: &mut App) { }; // Load next item - if let Err(e) = mpv_load_item(&cfg, mpv, &next_id) { + let url = build_jellyfin_play_url( + &cfg, + &next_id, + None, + app.transcode_on, + app.transcode_mbps, + ); + + if let Err(e) = mpv_load_url(&cfg, mpv, &url) { app.autoplay = false; app.autoplay_next_index = None; app.status = format!("autoplay load failed: {e}"); @@ -1847,6 +2034,9 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { let dur = mpv_get_f64_jtui(&socket_path, "duration", request_id).ok().flatten(); request_id = request_id.wrapping_add(1); + + let percent_pos = mpv_get_f64_jtui(&socket_path, "percent-pos", request_id).ok().flatten(); + request_id = request_id.wrapping_add(1); let eof = mpv_get_bool_jtui(&socket_path, "eof-reached", request_id).unwrap_or(false); request_id = request_id.wrapping_add(1); @@ -1882,10 +2072,13 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { st.subs = subs; // compute percent yourself (jtui style) - st.percent = match (tp, dur) { - (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), - _ => None, - }; + st.percent = percent_pos + .filter(|p| p.is_finite()) + .or_else(|| match (tp, dur) { + (Some(t), Some(d)) if t.is_finite() && d.is_finite() && d > 0.0 => Some((t / d) * 100.0), + _ => None, + }); + // Optional: populate last_err if we got nothing useful if st.time_pos.is_none() && st.duration.is_none() { @@ -1903,6 +2096,54 @@ fn start_mpv_poller(socket_path: PathBuf) -> mpsc::Receiver { rx } +fn spawn_mpv_url(cfg: &core::config::Config, url: &str) -> Result { + let token = cfg + .access_token + .as_deref() + .ok_or_else(|| "Missing access_token (login again)".to_string())?; + + let header = format!("X-Emby-Token: {}", token); + let sock = mpv_socket_path(); + + let _ = std::fs::remove_file(&sock); + + let mut child = Command::new("mpv") + .arg(url) + .arg("--force-window=yes") + .arg("--keep-open=yes") + .arg("--idle=yes") + .arg(format!("--http-header-fields={header}")) + .arg(format!("--input-ipc-server={}", sock.display())) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Failed to spawn mpv: {e}"))?; + + let start = Instant::now(); + while start.elapsed() < Duration::from_millis(800) { + if sock.exists() { + return Ok(MpvSession { socket_path: sock, child }); + } + thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + Err("mpv IPC socket did not appear".to_string()) +} + +fn mpv_load_url(cfg: &core::config::Config, mpv: &MpvSession, url: &str) -> Result<(), String> { + // mpv already has the http header from spawn flags + let _ = cfg; // keep signature symmetric; might be useful later + mpv_cmd( + &mpv.socket_path, + &format!(r#"{{"command":["loadfile","{}","replace"]}}"#, url), + ) +} + + + + + fn track_label(title: Option<&str>, lang: Option<&str>, id: Option, kind: &str) -> String { let mut parts: Vec = Vec::new(); @@ -1969,7 +2210,6 @@ fn tracks_from_ids(track_list: &serde_json::Value, aid: Option, sid: Option (audio, subs) } - fn fmt_hms(t: f64) -> String { if !t.is_finite() || t < 0.0 { return "--:--".into(); } let total = t as u64; @@ -2054,32 +2294,25 @@ fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { let prefix = "mpv "; let pct_text = format!("{:>5.1}%", pct.clamp(0.0, 100.0)); // " 43.3%" - let pct_area_min = pct_text.len().saturating_add(2); // a little breathing room + let pct_area_min = pct_text.len() + 2; // breathing room around percent - // If we're super narrow, just show percent. - if w <= prefix.len() + pct_text.len() + 1 { + // If too narrow, just show prefix+percent + if w <= prefix.len() + pct_text.len() { return format!("{prefix}{pct_text}"); } - // Reserve a right-side "pct area" if possible; otherwise shrink it. - let avail_after_prefix = w - prefix.len(); - let pct_area = pct_area_min.min(avail_after_prefix.saturating_sub(3)); // keep some room for a bar + let avail = w - prefix.len(); - // Bar gets the rest. - let mut bar_w = avail_after_prefix.saturating_sub(pct_area); - - // Ensure the bar is at least wide enough for "[]". - if bar_w < 2 { - bar_w = 2; - } + // Give pct a fixed-ish area if we can, but keep at least 2 cols for the bar: "[]" + let pct_area = pct_area_min.min(avail.saturating_sub(2)); + let bar_w = (avail - pct_area).max(2); let bar = unicode_progress_bar(bar_w, ratio); - // Whatever remains after prefix+bar becomes the pct area (actual). - let pct_area_actual = w.saturating_sub(prefix.len() + bar.len()); + // IMPORTANT: compute remaining space using bar_w (columns), not bar.len() (bytes) + let pct_area_actual = avail.saturating_sub(bar_w); let mut pct_buf = vec![' '; pct_area_actual]; - // Center pct_text inside that pct area if pct_area_actual >= pct_text.len() { let start = (pct_area_actual - pct_text.len()) / 2; for (i, ch) in pct_text.chars().enumerate() { @@ -2091,7 +2324,6 @@ fn mpv_bar_line(width: u16, ratio: f64, pct: f64) -> String { format!("{prefix}{bar}{pct_field}") } - fn unicode_progress_bar(width: usize, ratio: f64) -> String { // width includes the brackets: [ .... ] if width < 2 { @@ -2137,6 +2369,146 @@ fn unicode_progress_bar(width: usize, ratio: f64) -> String { s } +// --- watched / in-progress helpers --- + +fn is_music_item(it: &core::jellyfin::JfItem) -> bool { + matches!(it.item_type.as_deref(), Some("Audio" | "MusicAlbum" | "MusicArtist")) +} + +// Placeholder for later +fn is_downloaded(_it: &core::jellyfin::JfItem) -> bool { + false +} + +fn is_played(it: &core::jellyfin::JfItem) -> bool { + if it.user_data.as_ref().and_then(|u| u.played) == Some(true) { + return true; + } + // seasons/series often use percentage + match it.user_data.as_ref().and_then(|u| u.played_percentage) { + Some(p) if p >= 99.9 => true, + _ => false, + } +} + +fn is_in_progress(it: &core::jellyfin::JfItem) -> bool { + if is_played(it) { return false; } + + // movies/episodes + if playback_position_ticks(it).is_some() { + return true; + } + + // seasons/series (some servers expose %) + match it.user_data.as_ref().and_then(|u| u.played_percentage) { + Some(p) if p > 0.1 && p < 99.9 => true, + _ => false, + } +} + +fn playback_position_ticks(it: &core::jellyfin::JfItem) -> Option { + it.user_data + .as_ref() + .and_then(|u| u.playback_position_ticks) + .and_then(|v| if v > 0 { Some(v as u64) } else { None }) +} + +fn emoji_prefix_for(it: &core::jellyfin::JfItem) -> &'static str { + // user asked: no emoji for music + if is_music_item(it) { + return ""; + } + + let dl = is_downloaded(it); + let played = is_played(it); + let prog = is_in_progress(it); + + match (dl, played, prog) { + (true, true, _) => "💾✅ ", + (true, false, true) => "💾▶ ", + (true, false, false) => "💾 ", + (false, true, _) => "✅ ", + (false, false, true) => "▶ ", + (false, false, false) => "", + } +} + +// --- transcoding + resume URL builder (Approach B) --- +fn clamp_mbps_to_bps(mbps: u32) -> u32 { + mbps.saturating_mul(1_000_000) // Mb/s -> bits/s +} + +fn build_jellyfin_play_url( + cfg: &core::config::Config, + item_id: &str, + start_ticks: Option, + transcode_on: bool, + transcode_mbps: u32, +) -> String { + let base = cfg.base_url.trim_end_matches('/'); + + if !transcode_on { + return format!("{}/Items/{}/Download", base, item_id); + } + + let bps = clamp_mbps_to_bps(transcode_mbps); + + // Use maxStreamingBitrate (what Jellyfin UIs commonly use) + // Use static=false (lets server do normal streaming behavior) + let mut url = format!("{}/Videos/{}/stream?static=false", base, item_id); + + url.push_str(&format!("&maxStreamingBitrate={}", bps)); + + // IMPORTANT: + // - enableAutoStreamCopy=true = will NOT transcode if it can direct stream + // - set to false to FORCE transcode behavior (good for testing) + // + // For now: force it so you can verify it works. + url.push_str("&enableAutoStreamCopy=false"); + + // Make transcode more likely / deterministic: + url.push_str("&videoCodec=h264"); + url.push_str("&audioCodec=aac"); + url.push_str("&transcodingContainer=mp4"); + + if let Some(t) = start_ticks { + url.push_str(&format!("&startTimeTicks={}", t)); + } + + url +} + + + + + +fn reload_config(app: &mut App) { + if let Ok(cfg) = core::config::load_config() { + app.transcode_mbps = cfg.transcode_mbps.unwrap_or(5); + app.transcode_on = cfg.transcode_default_on.unwrap_or(false); + app.config = Some(cfg); + app.status = "Reloaded config".into(); + } else { + app.status = "Failed to reload config".into(); + } +} + + + + + + + + + + + + + + + + + struct Panes { top: Rect, left_main: Rect, @@ -2175,7 +2547,6 @@ fn layout(area: Rect) -> Panes { } } - fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { let focus = match app.login_focus { LoginFocus::User => "username", @@ -2205,7 +2576,4 @@ fn render_login(frame: &mut Frame, app: &App, left: Rect, right: Rect) { .block(Block::bordered().title("Info").merge_borders(MergeStrategy::Exact)), right, ); -} - - -//💾 \ No newline at end of file +} \ No newline at end of file