feat: manage subscriptions in the app, and act from the menu bar
Takeout is still the way a subscription list arrives. These are the things it cannot do. Hovering a channel in the sidebar reveals a delete, sitting where the count is — the least useful thing on that row at the moment you reach for it. It asks first, and says what goes: the channel, its videos, and how many downloaded files will be deleted. It also says plainly that YouTube is not touched; only this app forgets the channel. A + beside the cog adds one channel from any link — its page, its @handle, or one of its videos. The channel id and name are read off the page, which is the same pair the CSV carries, and the feed is fetched straight away so it is not sitting there empty. A menu bar item acts on whatever a browser is showing without leaving it: save that video, or subscribe to its channel, while it plays on. The address comes from whichever running browser has a YouTube page — asked with AppleScript, guarded so no browser is launched to answer, and reported honestly when macOS has not granted the permission yet. Answers come back as notifications rather than by raising the window. Saving a video is not subscribing to its channel, so a channel can now exist as a parent row without being a subscription: out of the sidebar, skipped by refreshes, and left alone when a CSV import replaces the subscription list. Also removes a stale duplicate of the file header that had been sitting inside run()'s setup block. Items are legal inside a block, so it compiled and nobody noticed.
This commit is contained in:
@@ -5,9 +5,11 @@ use crate::downloader::{self, Progress};
|
||||
use crate::feed;
|
||||
use crate::models::{
|
||||
Channel, ChannelWithCount, DownloadState, FeedFilter, FeedItem, ImportPreview, Prereqs,
|
||||
Video,
|
||||
};
|
||||
use crate::net;
|
||||
use crate::playlist_server::PlaylistServer;
|
||||
use crate::resolve;
|
||||
use crate::takeout;
|
||||
use crate::thumbs;
|
||||
|
||||
@@ -47,6 +49,9 @@ pub struct AppState {
|
||||
/// Python interpreter followed by the zipapp. Mutable so an in-app update
|
||||
/// takes effect without a restart.
|
||||
pub yt_dlp_argv: Arc<Mutex<Vec<String>>>,
|
||||
/// Quality and subtitle language the window is set to, so the menu bar
|
||||
/// can start the same work without the front end being open.
|
||||
pub download_defaults: Arc<Mutex<(String, String)>>,
|
||||
/// Value for yt-dlp's --cookies-from-browser, when signed in.
|
||||
pub cookies_from: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
@@ -227,6 +232,14 @@ pub struct RefreshSummary {
|
||||
/// `Contents/MacOS/`, which is checked first. A copy on PATH still wins nothing
|
||||
/// — but the Homebrew fallbacks remain for `cargo run` during development,
|
||||
/// where there is no bundle.
|
||||
/// Unix seconds. The database has its own copy; this is for rows built here.
|
||||
fn now_secs() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn bin(name: &str) -> String {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
@@ -989,6 +1002,31 @@ pub async fn refresh_feeds(
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetches one channel's feed and stores it. Used when a channel is added by
|
||||
/// hand, so it is populated before the user sees it.
|
||||
async fn refresh_one(
|
||||
app: &AppHandle,
|
||||
state: &State<'_, AppState>,
|
||||
channel_id: &str,
|
||||
) -> Result<usize, String> {
|
||||
let res = feed::fetch_channel(&state.http, channel_id).await;
|
||||
let mut db = state.db.lock().await;
|
||||
match res {
|
||||
Ok(videos) => {
|
||||
let n = if videos.is_empty() { 0 } else { db.upsert_videos(&videos)? };
|
||||
db.set_channel_result(channel_id, None)?;
|
||||
drop(db);
|
||||
cache_thumbnails(state).await;
|
||||
let _ = app.emit("feed:changed", ());
|
||||
Ok(n)
|
||||
}
|
||||
Err(e) => {
|
||||
db.set_channel_result(channel_id, Some(&e))?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors thumbnails to disk so the feed still renders with no network.
|
||||
async fn cache_thumbnails(state: &State<'_, AppState>) {
|
||||
let pending = match state.db.lock().await.videos_missing_thumbs(THUMB_BATCH) {
|
||||
@@ -1522,6 +1560,154 @@ pub async fn fetch_subtitles(
|
||||
Ok(read_vtt_dir(&dir).await)
|
||||
}
|
||||
|
||||
/// Records one video from its URL and returns its id and title, ready to
|
||||
/// download. Its channel is stored as a parent row only — saving a video is not
|
||||
/// subscribing to the channel, which is a separate choice.
|
||||
pub async fn save_video(state: &AppState, url: &str) -> Result<(String, String), String> {
|
||||
let Some(video_id) = resolve::video_id_from_url(url) else {
|
||||
return Err("That link is not a video.".into());
|
||||
};
|
||||
let html = fetch_page(state, url).await?;
|
||||
let Some((channel_id, channel_title)) = resolve::parse_channel(&html) else {
|
||||
return Err("Could not read that video's page.".into());
|
||||
};
|
||||
let meta = resolve::parse_video(&html);
|
||||
|
||||
let channel = Channel {
|
||||
id: channel_id.clone(),
|
||||
title: channel_title,
|
||||
url: resolve::channel_url(&channel_id),
|
||||
};
|
||||
let video = Video {
|
||||
id: video_id.clone(),
|
||||
channel_id,
|
||||
title: meta.title.clone(),
|
||||
description: String::new(),
|
||||
published: meta
|
||||
.published
|
||||
.as_deref()
|
||||
.and_then(resolve::iso_date_to_unix)
|
||||
.unwrap_or_else(now_secs),
|
||||
thumb_url: format!("https://i.ytimg.com/vi/{video_id}/hqdefault.jpg"),
|
||||
views: 0,
|
||||
is_short: url.contains("/shorts/"),
|
||||
};
|
||||
|
||||
{
|
||||
let mut db = state.db.lock().await;
|
||||
db.ensure_channel(&channel)?;
|
||||
db.upsert_videos(&[video])?;
|
||||
}
|
||||
Ok((video_id, meta.title))
|
||||
}
|
||||
|
||||
/// The quality and subtitle language the app is set to, so work started from
|
||||
/// the menu bar matches work started from the window.
|
||||
#[tauri::command]
|
||||
pub async fn set_download_defaults(
|
||||
quality: String,
|
||||
sub_lang: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
*state.download_defaults.lock().await = (quality, sub_lang);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What deleting one subscription would take with it.
|
||||
#[derive(Serialize)]
|
||||
pub struct RemovalPreview {
|
||||
pub title: String,
|
||||
pub videos: i64,
|
||||
pub downloaded: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn preview_delete_channel(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<RemovalPreview, String> {
|
||||
let db = state.db.lock().await;
|
||||
let (videos, paths) = db.channel_removal(&channel_id)?;
|
||||
let title = db
|
||||
.list_channels()?
|
||||
.into_iter()
|
||||
.find(|c| c.id == channel_id)
|
||||
.map(|c| c.title)
|
||||
.unwrap_or_else(|| "this channel".to_string());
|
||||
Ok(RemovalPreview { title, videos, downloaded: paths.len() })
|
||||
}
|
||||
|
||||
/// Unsubscribes in the app only. Nothing is touched on YouTube.
|
||||
#[tauri::command]
|
||||
pub async fn delete_channel(
|
||||
channel_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let paths = {
|
||||
let db = state.db.lock().await;
|
||||
db.channel_removal(&channel_id)?.1
|
||||
};
|
||||
for p in &paths {
|
||||
let _ = tokio::fs::remove_file(p).await;
|
||||
}
|
||||
state.db.lock().await.delete_channel(&channel_id)
|
||||
}
|
||||
|
||||
/// Fetches a YouTube page as a browser would, so the embedded player data is
|
||||
/// there to read.
|
||||
async fn fetch_page(state: &AppState, url: &str) -> Result<String, String> {
|
||||
let resp = state
|
||||
.http
|
||||
.get(url)
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
// Without a consent cookie some regions get an interstitial instead of
|
||||
// the page, and none of the player data is in that.
|
||||
.header("Cookie", "CONSENT=YES+1")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Could not reach YouTube: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("YouTube returned {}", resp.status()));
|
||||
}
|
||||
resp.text()
|
||||
.await
|
||||
.map_err(|e| format!("Could not read the page: {e}"))
|
||||
}
|
||||
|
||||
/// Adds one channel from any YouTube URL — the channel's own page, a handle, or
|
||||
/// a video of theirs. Returns the channel's title.
|
||||
#[tauri::command]
|
||||
pub async fn add_channel(
|
||||
url: String,
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let url = url.trim().to_string();
|
||||
if url.is_empty() {
|
||||
return Err("Paste a YouTube channel or video link first.".into());
|
||||
}
|
||||
let url = if url.starts_with("http") { url } else { format!("https://{url}") };
|
||||
if !resolve::is_youtube_url(&url) {
|
||||
return Err("That is not a YouTube link.".into());
|
||||
}
|
||||
|
||||
let html = fetch_page(&state, &url).await?;
|
||||
let Some((id, title)) = resolve::parse_channel(&html) else {
|
||||
return Err("No channel found at that link. A channel page or one of its videos works best.".into());
|
||||
};
|
||||
|
||||
if state.db.lock().await.has_channel(&id)? {
|
||||
return Err(format!("{title} is already in your subscriptions."));
|
||||
}
|
||||
|
||||
let channel = Channel { id: id.clone(), title: title.clone(), url: resolve::channel_url(&id) };
|
||||
state.db.lock().await.upsert_channels(&[channel])?;
|
||||
|
||||
// Fill the new channel in straight away, or it sits there empty.
|
||||
let _ = refresh_one(&app, &state, &id).await;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
/// Downloads that were still going when the app last closed.
|
||||
///
|
||||
/// Killing the app kills yt-dlp with it, leaving rows queued or running that no
|
||||
@@ -1680,6 +1866,7 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
||||
prereqs: Arc::new(Mutex::new(None)),
|
||||
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||
yt_dlp_argv: Arc::new(Mutex::new(yt_dlp_argv)),
|
||||
download_defaults: Arc::new(Mutex::new(("best".into(), "en".into()))),
|
||||
cookies_from: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user