diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 80aa954f..f28142c0 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -3,15 +3,14 @@ use std::path::{Path, PathBuf}; use crate::database::{ add_history_internal, add_history_with_summary, assign_history_collections_in_db, assign_history_tags_in_db, clear_history_from_db, create_collection_in_db, - delete_collection_from_db, delete_history_from_db, find_duplicate_downloads_in_history_db, - get_collections_from_db, get_history_count_from_db, get_history_entries_by_ids_from_db, - get_history_from_db, get_tags_from_db, remove_history_from_collection_in_db, - remove_history_tag_from_db, rename_collection_in_db, update_history_filepath_and_title, + delete_collection_from_db, delete_history_from_db, get_collections_from_db, + get_history_count_from_db, get_history_entries_by_ids_from_db, get_history_from_db, + get_tags_from_db, remove_history_from_collection_in_db, remove_history_tag_from_db, + rename_collection_in_db, update_history_filepath_and_title, update_history_filepath_and_title_by_id, update_history_summary, }; use crate::types::{ - DownloadDuplicateIdentity, DownloadDuplicateMatch, HistoryAdvancedFilters, HistoryCollection, - HistoryEntry, HistorySort, HistoryTag, + HistoryAdvancedFilters, HistoryCollection, HistoryEntry, HistorySort, HistoryTag, }; #[tauri::command] @@ -67,45 +66,10 @@ pub fn get_history_entries_by_ids(ids: Vec) -> Result, } #[tauri::command] -pub fn find_duplicate_downloads( - identities: Vec, -) -> Result, String> { - find_duplicate_downloads_in_history_db(identities) -} - -#[tauri::command] -pub fn delete_history(id: String, delete_file: Option) -> Result<(), String> { - if delete_file.unwrap_or(false) { - let entry = get_history_entries_by_ids_from_db(vec![id.clone()])? - .into_iter() - .next() - .ok_or_else(|| "History entry not found".to_string())?; - delete_history_media_file(&entry.filepath)?; - } - +pub fn delete_history(id: String) -> Result<(), String> { delete_history_from_db(id) } -fn delete_history_media_file(filepath: &str) -> Result<(), String> { - let trimmed = filepath.trim(); - if trimmed.is_empty() { - return Ok(()); - } - - let path = Path::new(trimmed); - if !path.exists() { - return Ok(()); - } - - let metadata = std::fs::symlink_metadata(path) - .map_err(|e| format!("Failed to inspect media file before deleting: {}", e))?; - if metadata.is_dir() { - return Err("Refusing to delete a directory from Library item deletion".to_string()); - } - - std::fs::remove_file(path).map_err(|e| format!("Failed to delete media file: {}", e)) -} - #[tauri::command] pub fn clear_history() -> Result<(), String> { clear_history_from_db() @@ -425,38 +389,6 @@ mod tests { assert!(err.contains("File not found")); } - #[test] - fn delete_history_media_file_removes_regular_file() { - let file = make_temp_file("video.mp4"); - - delete_history_media_file(file.to_str().expect("utf8 path")).expect("delete media file"); - - assert!(!file.exists()); - let _ = fs::remove_dir_all(file.parent().unwrap_or_else(|| Path::new("/"))); - } - - #[test] - fn delete_history_media_file_ignores_missing_file() { - let missing = - std::env::temp_dir().join(format!("youwee-missing-{}.mp4", uuid::Uuid::new_v4())); - - delete_history_media_file(missing.to_str().expect("utf8 path")) - .expect("missing media file should not fail"); - } - - #[test] - fn delete_history_media_file_rejects_directory() { - let dir = - std::env::temp_dir().join(format!("youwee-history-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&dir).expect("create temp dir"); - - let error = - delete_history_media_file(dir.to_str().expect("utf8 path")).expect_err("reject dir"); - - assert!(error.contains("Refusing to delete a directory")); - let _ = fs::remove_dir_all(&dir); - } - fn ensure_test_history_table() { if DB_CONNECTION.get().is_none() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); diff --git a/src/components/download/GalleryQueueItem.tsx b/src/components/download/GalleryQueueItem.tsx index 26ea478a..34f5629c 100644 --- a/src/components/download/GalleryQueueItem.tsx +++ b/src/components/download/GalleryQueueItem.tsx @@ -1,4 +1,4 @@ -import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { invoke } from '@tauri-apps/api/core'; import { CheckCircle2, Clock, FolderOpen, Globe, Loader2, X, XCircle } from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,7 +27,7 @@ export function GalleryQueueItem({ const handleOpenFolder = useCallback(async () => { if (!item.completedFilepath) return; try { - await revealItemInDir(item.completedFilepath); + await invoke('open_file_location', { filepath: item.completedFilepath }); } catch (error) { console.error('Failed to open gallery output folder:', error); } diff --git a/src/components/download/QueueItem.tsx b/src/components/download/QueueItem.tsx index a04d2e3a..bcf160be 100644 --- a/src/components/download/QueueItem.tsx +++ b/src/components/download/QueueItem.tsx @@ -1,9 +1,8 @@ -import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { invoke } from '@tauri-apps/api/core'; import { CheckCircle2, ChevronDown, ChevronUp, - CircleSlash, Clock, FolderOpen, HardDrive, @@ -12,6 +11,7 @@ import { Loader2, MonitorPlay, Pencil, + Play, RefreshCw, Scissors, Sparkles, @@ -20,14 +20,10 @@ import { } from 'lucide-react'; import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { SchedulePopover } from '@/components/download/SchedulePopover'; import { SimpleMarkdown } from '@/components/ui/simple-markdown'; import { useAI } from '@/contexts/AIContext'; -import type { ScheduleConfig } from '@/hooks/useSchedule'; import type { DownloadItem, ItemDownloadSettings } from '@/lib/types'; import { cn } from '@/lib/utils'; -import { extractYouTubeVideoId, youtubeThumbnailUrl } from '@/lib/youtube-url'; -import { ThumbnailCompletedBadge, ThumbnailFailedBadge } from './ThumbnailStatusBadge'; // Parse a duration string like "5:30" or "1:05:30" to total seconds function parseDurationString(dur: string): number { @@ -97,11 +93,6 @@ function formatQuality(quality: string): string { return qualityMap[quality] || quality; } -function getFolderName(path: string): string { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) || path; -} - interface QueueItemProps { item: DownloadItem; isFocused?: boolean; @@ -109,9 +100,7 @@ interface QueueItemProps { disabled?: boolean; onRemove: (id: string) => void; onUpdateTimeRange: (id: string, start?: string, end?: string) => void; - onSelectOutputFolder: (id: string) => Promise; onRename: (id: string, newName: string) => Promise; - onScheduleUpcomingLive?: (config: ScheduleConfig) => void; } export function QueueItem({ @@ -121,9 +110,7 @@ export function QueueItem({ disabled, onRemove, onUpdateTimeRange, - onSelectOutputFolder, onRename, - onScheduleUpcomingLive, }: QueueItemProps) { const { t } = useTranslation('download'); const ai = useAI(); @@ -150,6 +137,12 @@ export function QueueItem({ const generatingStatus = task?.status === 'fetching' ? 'fetching' : task?.status === 'generating' ? 'generating' : null; + // Extract video ID for thumbnail + const getVideoId = (url: string) => { + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&?]+)/); + return match ? match[1] : null; + }; + const handleGenerateSummary = () => { if (isGenerating) return; @@ -168,23 +161,20 @@ export function QueueItem({ }); }; - const videoId = extractYouTubeVideoId(item.url); - const thumbnailUrl = item.thumbnail || (videoId ? youtubeThumbnailUrl(videoId) : null); + const videoId = getVideoId(item.url); + const thumbnailUrl = + item.thumbnail || (videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null); const isActive = item.status === 'downloading' || item.status === 'fetching'; const isCompleted = item.status === 'completed'; const isError = item.status === 'error'; const isPending = item.status === 'pending'; - const isSkipped = item.status === 'skipped'; const retryState = item.retryState; - const isUpcomingLiveError = item.errorCode === 'YT_UPCOMING_LIVE'; // Get saved settings for pending items const itemSettings = item.settings as ItemDownloadSettings | undefined; const hasTimeRange = !!(itemSettings?.timeRangeStart && itemSettings?.timeRangeEnd); - const outputPath = itemSettings?.outputPath ?? ''; - const outputFolderName = outputPath ? getFolderName(outputPath) : ''; const handleApplyTimeRange = useCallback(() => { if (timeStart && timeEnd) { @@ -211,10 +201,6 @@ export function QueueItem({ }); }, [itemSettings?.timeRangeStart, itemSettings?.timeRangeEnd]); - const handleSelectOutputFolder = useCallback(() => { - void onSelectOutputFolder(item.id); - }, [item.id, onSelectOutputFolder]); - const handleTimeStartChange = useCallback((e: ChangeEvent) => { setTimeStart(autoFormatTimeInput(e.target.value)); }, []); @@ -233,7 +219,7 @@ export function QueueItem({ if (!item.completedFilepath) return; try { - await revealItemInDir(item.completedFilepath); + await invoke('open_file_location', { filepath: item.completedFilepath }); } catch (error) { console.error('Failed to open completed file location:', error); } @@ -296,7 +282,7 @@ export function QueueItem({ alt="" className={cn( 'w-full h-full object-cover transition-all duration-300', - isCompleted && 'brightness-90 saturate-95', + isCompleted && 'opacity-60', )} loading="lazy" referrerPolicy="no-referrer" @@ -371,10 +357,31 @@ export function QueueItem({ )} {/* Completed Overlay */} - {isCompleted && } + {isCompleted && ( +
+
+ +
+
+ )} {/* Error Overlay */} - {isError && } + {isError && ( +
+
+ +
+
+ )} + + {/* Pending Overlay */} + {isPending && ( +
+
+ +
+
+ )} {/* Playlist Badge */} {item.isPlaylist && showPlaylistBadge && ( @@ -423,14 +430,12 @@ export function QueueItem({ isActive && 'bg-primary/10 text-primary', isCompleted && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', isError && 'bg-red-500/10 text-red-600 dark:text-red-400', - isSkipped && 'bg-amber-500/10 text-amber-600 dark:text-amber-400', )} > {isPending && } {isActive && } {isCompleted && } {isError && } - {isSkipped && } {isPending && t('queue.status.pending')} {isActive && @@ -439,7 +444,6 @@ export function QueueItem({ : t('queue.status.downloading'))} {isCompleted && t('queue.status.completed')} {isError && t('queue.status.failed')} - {isSkipped && t('queue.status.skipped')} @@ -509,20 +513,10 @@ export function QueueItem({ {isError && ( - {isUpcomingLiveError ? t('queue.upcomingLive.hint') : t('queue.status.failedHint')} + {t('queue.status.failedHint')} )} - {isUpcomingLiveError && onScheduleUpcomingLive && ( - - )} - {/* Generating Status (inline with info badges) */} {isGenerating && ( @@ -535,27 +529,8 @@ export function QueueItem({ {/* Actions Row — interactive buttons, visually distinct */} - {!isActive && ( -
- {itemSettings && (isPending || isError) && ( - - )} - + {!isActive && !isError && ( +
{/* Time Range button (only when pending) */} {isPending && itemSettings && ( )} diff --git a/src/components/download/UniversalQueueItem.tsx b/src/components/download/UniversalQueueItem.tsx index ea824f04..0d03b90c 100644 --- a/src/components/download/UniversalQueueItem.tsx +++ b/src/components/download/UniversalQueueItem.tsx @@ -1,9 +1,8 @@ -import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { invoke } from '@tauri-apps/api/core'; import { CheckCircle2, ChevronDown, ChevronUp, - CircleSlash, Clock, FolderOpen, Globe, @@ -12,6 +11,7 @@ import { Loader2, MonitorPlay, Pencil, + Play, RefreshCw, Scissors, Sparkles, @@ -20,14 +20,11 @@ import { } from 'lucide-react'; import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { SchedulePopover } from '@/components/download/SchedulePopover'; import { SimpleMarkdown } from '@/components/ui/simple-markdown'; import { useAI } from '@/contexts/AIContext'; -import type { ScheduleConfig } from '@/hooks/useSchedule'; import type { DownloadItem, ItemUniversalSettings } from '@/lib/types'; import { cn } from '@/lib/utils'; import { SourceBadge } from './SourceBadge'; -import { ThumbnailCompletedBadge, ThumbnailFailedBadge } from './ThumbnailStatusBadge'; // Parse a duration string like "5:30" or "1:05:30" to total seconds function parseDurationString(dur: string): number { @@ -97,20 +94,13 @@ function formatQuality(quality: string): string { return qualityMap[quality] || quality; } -function getFolderName(path: string): string { - const segments = path.split(/[\\/]/).filter(Boolean); - return segments.at(-1) || path; -} - interface UniversalQueueItemProps { item: DownloadItem; isFocused?: boolean; disabled?: boolean; onRemove: (id: string) => void; onUpdateTimeRange: (id: string, start?: string, end?: string) => void; - onSelectOutputFolder: (id: string) => Promise; onRename: (id: string, newName: string) => Promise; - onScheduleUpcomingLive?: (config: ScheduleConfig) => void; } export function UniversalQueueItem({ @@ -119,9 +109,7 @@ export function UniversalQueueItem({ disabled, onRemove, onUpdateTimeRange, - onSelectOutputFolder, onRename, - onScheduleUpcomingLive, }: UniversalQueueItemProps) { const { t } = useTranslation('universal'); const ai = useAI(); @@ -156,17 +144,13 @@ export function UniversalQueueItem({ const isCompleted = item.status === 'completed'; const isError = item.status === 'error'; const isPending = item.status === 'pending'; - const isSkipped = item.status === 'skipped'; const retryState = item.retryState; const isFetchingMeta = isPending && !item.thumbnail && item.title === item.url && !item.extractor; - const isUpcomingLiveError = item.errorCode === 'YT_UPCOMING_LIVE'; // Get saved settings for pending items const itemSettings = item.settings as ItemUniversalSettings | undefined; const hasTimeRange = !!(itemSettings?.timeRangeStart && itemSettings?.timeRangeEnd); - const outputPath = itemSettings?.outputPath ?? ''; - const outputFolderName = outputPath ? getFolderName(outputPath) : ''; const handleApplyTimeRange = useCallback(() => { if (timeStart && timeEnd) { @@ -193,10 +177,6 @@ export function UniversalQueueItem({ }); }, [itemSettings?.timeRangeStart, itemSettings?.timeRangeEnd]); - const handleSelectOutputFolder = useCallback(() => { - void onSelectOutputFolder(item.id); - }, [item.id, onSelectOutputFolder]); - const handleTimeStartChange = useCallback((e: ChangeEvent) => { setTimeStart(autoFormatTimeInput(e.target.value)); }, []); @@ -215,7 +195,7 @@ export function UniversalQueueItem({ if (!item.completedFilepath) return; try { - await revealItemInDir(item.completedFilepath); + await invoke('open_file_location', { filepath: item.completedFilepath }); } catch (error) { console.error('Failed to open completed file location:', error); } @@ -294,7 +274,7 @@ export function UniversalQueueItem({ alt="" className={cn( 'w-full h-full object-cover transition-all duration-300', - isCompleted && 'brightness-90 saturate-95', + isCompleted && 'opacity-60', )} loading="lazy" onError={handleThumbError} @@ -374,10 +354,31 @@ export function UniversalQueueItem({ )} {/* Completed Overlay */} - {isCompleted && } + {isCompleted && ( +
+
+ +
+
+ )} {/* Error Overlay */} - {isError && } + {isError && ( +
+
+ +
+
+ )} + + {/* Pending Overlay */} + {isPending && ( +
+
+ +
+
+ )}
{/* Content */} @@ -421,14 +422,12 @@ export function UniversalQueueItem({ isActive && 'bg-primary/10 text-primary', isCompleted && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', isError && 'bg-red-500/10 text-red-600 dark:text-red-400', - isSkipped && 'bg-amber-500/10 text-amber-600 dark:text-amber-400', )} > {isPending && } {isActive && } {isCompleted && } {isError && } - {isSkipped && } {isPending && t('queue.status.pending')} {isActive && @@ -437,7 +436,6 @@ export function UniversalQueueItem({ : t('queue.status.downloading'))} {isCompleted && t('queue.status.completed')} {isError && t('queue.status.failed')} - {isSkipped && t('queue.status.skipped')} @@ -507,20 +505,10 @@ export function UniversalQueueItem({ {isError && ( - {isUpcomingLiveError ? t('queue.upcomingLive.hint') : t('queue.status.failedHint')} + {t('queue.status.failedHint')} )} - {isUpcomingLiveError && onScheduleUpcomingLive && ( - - )} - {/* Generating Status (inline with info badges) */} {isGenerating && ( @@ -533,27 +521,8 @@ export function UniversalQueueItem({
{/* Actions Row — interactive buttons, visually distinct */} - {!isActive && ( -
- {itemSettings && (isPending || isError) && ( - - )} - + {!isActive && !isError && ( +
{/* Time Range button (only when pending) */} {isPending && itemSettings && ( )} diff --git a/src/components/processing/ChatPanel.tsx b/src/components/processing/ChatPanel.tsx index bdf1ec03..67e768b7 100644 --- a/src/components/processing/ChatPanel.tsx +++ b/src/components/processing/ChatPanel.tsx @@ -1,5 +1,5 @@ import { open } from '@tauri-apps/plugin-dialog'; -import { revealItemInDir } from '@tauri-apps/plugin-opener'; +import { invoke } from '@tauri-apps/api/core'; import { Check, FileText, @@ -403,7 +403,7 @@ export function ChatPanel({