Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "flowbit",
"private": true,
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "Flowbit"
version = "0.1.0"
version = "0.2.0"
description = "A Tauri App"
authors = ["_Axa_lotL_"]
edition = "2021"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/functions/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod dependencies;
pub mod get_info;
pub mod playlist;
pub mod preview;
pub mod twitch;
pub mod valid;
pub mod youtube;
31 changes: 24 additions & 7 deletions src-tauri/src/functions/playlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,40 @@ pub struct PlaylistDownloadResult {
pub fn is_playlist_url(url: String) -> bool {
let u = url.trim();

let is_playlist_page = u.contains("youtube.com") && u.contains("/playlist");

// list= without a specific video. A link to a single video (/watch?v=...
// or youtu.be/<id>) carrying &list=... (radio, "watch later") counts as a
// single video, not a playlist.
// single video, not a playlist. A bare /playlist path with no list= param
// isn't a resolvable playlist either.
let points_to_single_video =
(u.contains("watch") && u.contains("v=")) || u.contains("youtu.be");
let is_list_only =
let is_yt_playlist =
u.contains("youtube.com") && u.contains("list=") && !points_to_single_video;

let is_yt_playlist = is_playlist_page || is_list_only;

let is_twitch_playlist = u.contains("twitch.tv") && u.contains("/videos");
let is_twitch_playlist = is_twitch_videos_page(u);

is_yt_playlist || is_twitch_playlist
}

/// True for a Twitch channel's videos listing (`twitch.tv/<channel>/videos`),
/// which is a playlist. A single VOD link (`twitch.tv/videos/<id>`) merely
/// contains "/videos" as a substring too, so this checks path segments
/// instead of using `.contains("/videos")`.
fn is_twitch_videos_page(u: &str) -> bool {
let Some(idx) = u.find("twitch.tv/") else {
return false;
};
let rest = &u[idx + "twitch.tv/".len()..];
let segments: Vec<&str> = rest
.split(['?', '#'])
.next()
.unwrap_or("")
.split('/')
.filter(|s| !s.is_empty())
.collect();

matches!(segments.as_slice(), [channel, "videos"] if *channel != "videos")
}

#[tauri::command]
pub async fn get_playlist_info(url: String) -> Result<PlaylistInfo, String> {
let args: Vec<String> = vec![
Expand Down
117 changes: 117 additions & 0 deletions src-tauri/src/functions/preview.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use crate::functions::youtube::{file_mb, resolve_out_dir, DownloadResult};
use std::path::PathBuf;
use tauri::AppHandle;

/// Replaces characters invalid in filenames on Windows/macOS/Linux with `_`,
/// trims trailing dots/spaces (invalid as a Windows filename ending), and
/// caps the length so the sanitized title plus extension stays well under
/// common filesystem limits (255 bytes on most platforms).
pub fn sanitize_filename(title: &str) -> String {
let replaced: String = title
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();

let trimmed = replaced.trim().trim_end_matches(['.', ' ']);
let truncated: String = trimmed.chars().take(200).collect();

if truncated.is_empty() {
"thumbnail".to_string()
} else {
truncated
}
}

/// Downloads a single URL's bytes, treating any non-2xx status as an error.
async fn fetch_ok(url: &str) -> Result<Vec<u8>, String> {
Ok(reqwest::get(url)
.await
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.bytes()
.await
.map_err(|e| e.to_string())?
.to_vec())
}

/// Tries `url` first; on failure (e.g. a 404 for a video with no maxres
/// thumbnail) falls back to `fallback_url` if given. Reports the primary
/// error if both fail, since it's usually the more informative one.
async fn fetch_with_fallback(url: &str, fallback_url: Option<&str>) -> Result<Vec<u8>, String> {
match fetch_ok(url).await {
Ok(bytes) => Ok(bytes),
Err(primary_err) => match fallback_url {
Some(fb) => fetch_ok(fb).await.or(Err(primary_err)),
None => Err(primary_err),
},
}
}

/// Tauri command: downloads a video's preview thumbnail image to disk,
/// named after the video title (sanitized) rather than the video's own
/// output filename, since a preview can be saved independently of the
/// video/audio download.
#[tauri::command]
pub async fn download_preview(
app: AppHandle,
url: String,
fallback_url: Option<String>,
title: String,
path: Option<String>,
) -> Result<DownloadResult, String> {
let out_dir = resolve_out_dir(&app, path);
tokio::fs::create_dir_all(&out_dir)
.await
.map_err(|e| format!("Cannot create directory: {e}"))?;

let bytes = fetch_with_fallback(&url, fallback_url.as_deref()).await?;

let out_file: PathBuf = out_dir.join(format!("{}.jpg", sanitize_filename(&title)));

tokio::fs::write(&out_file, &bytes)
.await
.map_err(|e| format!("Cannot write file: {e}"))?;

Ok(DownloadResult {
path: out_file.to_string_lossy().into(),
file_size_mb: file_mb(bytes.len() as u64),
})
}

#[cfg(test)]
mod sanitize_tests {
use super::sanitize_filename;

#[test]
fn replaces_invalid_characters() {
assert_eq!(sanitize_filename("a/b\\c:d*e?f\"g<h>i|j"), "a_b_c_d_e_f_g_h_i_j");
}

#[test]
fn trims_trailing_dots_and_spaces() {
assert_eq!(sanitize_filename("My Video. "), "My Video");
}

#[test]
fn falls_back_to_placeholder_when_empty() {
assert_eq!(sanitize_filename(""), "thumbnail");
assert_eq!(sanitize_filename(" "), "thumbnail");
assert_eq!(sanitize_filename("..."), "thumbnail");
}

#[test]
fn truncates_long_titles() {
let long = "a".repeat(500);
assert_eq!(sanitize_filename(&long).chars().count(), 200);
}

#[test]
fn keeps_ordinary_titles_untouched() {
assert_eq!(sanitize_filename("Rick Astley - Never Gonna Give You Up"), "Rick Astley - Never Gonna Give You Up");
}
}
29 changes: 19 additions & 10 deletions src-tauri/src/functions/valid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,30 @@ pub fn validate_time_range(
};

let end_sec = match end.as_deref() {
Some(e) if !e.trim().is_empty() => parse_time_strict(e)?,
_ => return Ok(()),
Some(e) if !e.trim().is_empty() => Some(parse_time_strict(e)?),
_ => None,
};

if start_sec == 0 && end_sec == 0 {
return Ok(());
}
// No end, or an explicit "00:00:00" end, both mean "play to the end of
// the video" — there's no explicit end to compare start against.
let end_is_default = matches!(end_sec, None | Some(0));

if end_sec > 0 && start_sec >= end_sec {
return Err("Start must be earlier than end".into());
if start_sec == 0 && end_is_default {
return Ok(());
}

if let Some(max) = max_duration {
if end_sec > max {
return Err("End exceeds the video's duration".into());
if let Some(end_sec) = end_sec.filter(|_| !end_is_default) {
if start_sec >= end_sec {
return Err("Start must be earlier than end".into());
}
if let Some(max) = max_duration {
if end_sec > max {
return Err("End exceeds the video's duration".into());
}
}
} else if let Some(max) = max_duration {
if start_sec >= max {
return Err("Start exceeds the video's duration".into());
}
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use functions::youtube::DownloadState;

use crate::functions::dependencies::{deps_ready, install_dependencies};
use crate::functions::playlist::{download_playlist, get_playlist_info, is_playlist_url};
use crate::functions::preview::download_preview;
use tauri::Emitter;

pub fn run() {
Expand Down Expand Up @@ -48,6 +49,7 @@ pub fn run() {
get_youtube_info,
get_twitch_info,
download_video,
download_preview,
download_twitch,
is_playlist_url,
get_playlist_info,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Flowbit",
"version": "0.1.0",
"version": "0.2.0",
"identifier": "com.axalotl.flowbit",
"build": {
"beforeDevCommand": "bun run dev",
Expand Down
Loading
Loading