Initial commit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-29 22:42:55 -04:00
commit 0d7b2b1aab
389 changed files with 280296 additions and 0 deletions

5282
web/frontend/src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
/**
* Cache Invalidation Utilities
*
* Centralized cache invalidation to ensure consistency across all pages
* when files are moved, deleted, restored, or modified.
*/
import { QueryClient } from '@tanstack/react-query'
/**
* Invalidate all file-related caches
* Use this when files are moved between locations, deleted, restored, or modified
*/
export function invalidateAllFileCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['media-gallery'] })
queryClient.invalidateQueries({ queryKey: ['review-queue'] })
queryClient.invalidateQueries({ queryKey: ['review-list'] })
queryClient.invalidateQueries({ queryKey: ['recycle-bin'] })
queryClient.invalidateQueries({ queryKey: ['recycle-bin-stats'] })
queryClient.invalidateQueries({ queryKey: ['downloads'] })
queryClient.invalidateQueries({ queryKey: ['downloads-search'] })
queryClient.invalidateQueries({ queryKey: ['stats'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['dashboard-recent-items'] })
}
/** Remove items from media-gallery cache by file_path */
export function optimisticRemoveFromMediaGallery(
queryClient: QueryClient,
filePaths: Set<string>
) {
queryClient.setQueriesData<{ media: any[]; total: number }>(
{ queryKey: ['media-gallery'] },
(old) => {
if (!old) return old
const filtered = old.media.filter(m => !filePaths.has(m.file_path))
return { ...old, media: filtered, total: old.total - (old.media.length - filtered.length) }
}
)
}
/** Remove items from review-queue cache by file_path */
export function optimisticRemoveFromReviewQueue(
queryClient: QueryClient,
filePaths: Set<string>
) {
queryClient.setQueriesData<{ files: any[]; total: number }>(
{ queryKey: ['review-queue'] },
(old) => {
if (!old) return old
const filtered = old.files.filter(f => !filePaths.has(f.file_path))
return { ...old, files: filtered, total: old.total - (old.files.length - filtered.length) }
}
)
queryClient.setQueriesData<{ files: any[]; total: number }>(
{ queryKey: ['review-list'] },
(old) => {
if (!old) return old
const filtered = old.files.filter(f => !filePaths.has(f.file_path))
return { ...old, files: filtered, total: old.total - (old.files.length - filtered.length) }
}
)
}
/** Remove items from recycle-bin cache by id */
export function optimisticRemoveFromRecycleBin(
queryClient: QueryClient,
ids: Set<string>
) {
queryClient.setQueriesData<{ items: any[]; total: number }>(
{ queryKey: ['recycle-bin'] },
(old) => {
if (!old) return old
const filtered = old.items.filter(item => !ids.has(item.id))
return { ...old, items: filtered, total: old.total - (old.items.length - filtered.length) }
}
)
}
/**
* Invalidate filter caches
* Use this when new files are added that might introduce new platforms/sources
*/
export function invalidateFilterCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['download-filters'] })
queryClient.invalidateQueries({ queryKey: ['review-filters'] })
}
/**
* Invalidate all caches (files + filters)
* Use this for operations that might affect both files and filter options
*/
export function invalidateAllCaches(queryClient: QueryClient) {
invalidateAllFileCaches(queryClient)
invalidateFilterCaches(queryClient)
}

View File

@@ -0,0 +1,409 @@
import { ToastNotification } from '../components/NotificationToast'
type NotificationCallback = (notifications: ToastNotification[]) => void
// Centralized icon constants for consistency
export const ICONS = {
// Status
SUCCESS: '✅',
ERROR: '❌',
WARNING: '⚠️',
INFO: '📋',
PROCESSING: '⏳',
// Actions
DELETE: '🗑️',
SAVE: '💾',
SYNC: '🔄',
DOWNLOAD: '📥',
COPY: '📋',
// Features
REVIEW: '👁️',
FACE: '👤',
SCHEDULER: '📅',
// Platforms
INSTAGRAM: '📸',
TIKTOK: '🎵',
SNAPCHAT: '👻',
FORUM: '💬',
YOUTUBE: '▶️',
PLEX: '🎬',
} as const
/**
* Extract error message from various error types consistently
*/
export function extractErrorMessage(err: unknown, fallback: string = 'An error occurred'): string {
if (!err) return fallback
// Axios-style error with response.data.detail
if (typeof err === 'object' && err !== null) {
const e = err as Record<string, unknown>
// Check for axios response structure
if (e.response && typeof e.response === 'object') {
const response = e.response as Record<string, unknown>
if (response.data && typeof response.data === 'object') {
const data = response.data as Record<string, unknown>
if (typeof data.detail === 'string') return data.detail
if (typeof data.message === 'string') return data.message
if (typeof data.error === 'string') return data.error
}
}
// Standard Error object
if (e.message && typeof e.message === 'string') return e.message
// Plain object with error/detail/message
if (typeof e.detail === 'string') return e.detail
if (typeof e.error === 'string') return e.error
}
// String error
if (typeof err === 'string') return err
return fallback
}
class NotificationManager {
private notifications: ToastNotification[] = []
private listeners: Set<NotificationCallback> = new Set()
private idCounter = 0
subscribe(callback: NotificationCallback) {
this.listeners.add(callback)
// Immediately notify with current state
callback(this.notifications)
return () => {
this.listeners.delete(callback)
}
}
private notify() {
this.listeners.forEach(callback => callback([...this.notifications]))
}
show(title: string, message: string, icon?: string, type?: 'success' | 'error' | 'info' | 'warning', thumbnailUrl?: string) {
const notification: ToastNotification = {
id: `notification-${++this.idCounter}-${Date.now()}`,
title,
message,
icon,
type,
thumbnailUrl
}
this.notifications.push(notification)
this.notify()
return notification.id
}
dismiss(id: string) {
this.notifications = this.notifications.filter(n => n.id !== id)
this.notify()
}
// ============================================
// Core convenience methods
// ============================================
success(title: string, message: string, icon: string = ICONS.SUCCESS, thumbnailUrl?: string) {
return this.show(title, message, icon, 'success', thumbnailUrl)
}
error(title: string, message: string, icon: string = ICONS.ERROR, thumbnailUrl?: string) {
return this.show(title, message, icon, 'error', thumbnailUrl)
}
info(title: string, message: string, icon: string = ICONS.INFO, thumbnailUrl?: string) {
return this.show(title, message, icon, 'info', thumbnailUrl)
}
warning(title: string, message: string, icon: string = ICONS.WARNING, thumbnailUrl?: string) {
return this.show(title, message, icon, 'warning', thumbnailUrl)
}
// ============================================
// Settings & Configuration
// ============================================
/** Notify that settings were saved successfully */
settingsSaved(category: string) {
return this.success('Settings Saved', `${category} settings saved successfully`)
}
/** Notify that saving settings failed */
settingsSaveError(err: unknown, category?: string) {
const what = category ? `${category} settings` : 'settings'
return this.error('Save Failed', extractErrorMessage(err, `Failed to save ${what}`))
}
// ============================================
// Connection & Authentication
// ============================================
/** Notify successful connection */
connectionSuccess(service: string, message?: string) {
return this.success('Connected', message || `Connected to ${service}`)
}
/** Notify connection failure */
connectionFailed(service: string, err?: unknown) {
return this.error('Connection Failed', extractErrorMessage(err, `Could not connect to ${service}`))
}
/** Notify disconnection */
disconnected(service: string) {
return this.success('Disconnected', `${service} has been disconnected`)
}
// ============================================
// CRUD Operations
// ============================================
/** Notify successful deletion */
deleted(itemType: string, message?: string) {
return this.success(`${itemType} Deleted`, message || `${itemType} removed successfully`, ICONS.DELETE)
}
/** Notify deletion failure */
deleteError(itemType: string, err?: unknown) {
return this.error('Delete Failed', extractErrorMessage(err, `Failed to delete ${itemType.toLowerCase()}`))
}
/** Notify successful move/keep operation */
moved(itemType: string, destination?: string, count?: number) {
const countStr = count !== undefined ? `${count} ` : ''
const destStr = destination ? ` to ${destination}` : ''
return this.success(`${itemType} Moved`, `${countStr}${itemType.toLowerCase()}${count !== 1 ? 's' : ''} moved${destStr}`)
}
/** Notify move failure */
moveError(itemType: string, err?: unknown) {
return this.error('Move Failed', extractErrorMessage(err, `Failed to move ${itemType.toLowerCase()}`))
}
/** Notify item kept (moved from review to destination) */
kept(itemType: string = 'Image') {
return this.success(`${itemType} Kept`, `${itemType} moved to destination`)
}
// ============================================
// Batch Operations
// ============================================
/** Notify successful batch operation */
batchSuccess(operation: string, count: number, itemType: string = 'item') {
const plural = count !== 1 ? 's' : ''
return this.success(`Batch ${operation}`, `${count} ${itemType}${plural} ${operation.toLowerCase()}`)
}
/** Notify batch operation failure */
batchError(operation: string, err?: unknown) {
return this.error(`Batch ${operation}`, extractErrorMessage(err, `${operation} operation failed`))
}
// ============================================
// Face Recognition
// ============================================
/** Notify face reference added */
faceReferenceAdded(personName: string, filename?: string) {
return this.success('Face Reference Added', filename || `Added as reference for ${personName}`, ICONS.FACE)
}
/** Notify face reference operation failed */
faceReferenceError(err: unknown) {
return this.error('Add Reference Failed', extractErrorMessage(err, 'Failed to add face reference'))
}
/** Notify processing started (for async operations) */
processing(message: string) {
return this.info('Processing', message, ICONS.PROCESSING)
}
/** Notify item moved to review queue */
movedToReview(count: number = 1) {
const plural = count !== 1 ? 's' : ''
return this.success('Moved to Review', `${count} item${plural} moved to review queue`, ICONS.REVIEW)
}
// ============================================
// Sync & Background Operations
// ============================================
/** Notify sync started */
syncStarted(what: string) {
return this.success('Sync Started', `${what} sync started in background`, ICONS.SYNC)
}
/** Notify sync completed */
syncCompleted(what: string) {
return this.success('Sync Complete', `${what} sync completed`, ICONS.SYNC)
}
/** Notify sync failed */
syncError(what: string, err?: unknown) {
return this.error('Sync Failed', extractErrorMessage(err, `Failed to sync ${what.toLowerCase()}`))
}
/** Notify rescan started */
rescanStarted(count?: number) {
const msg = count ? `Scanning ${count} files...` : 'Rescan started'
return this.info('Rescan Started', msg, ICONS.SYNC)
}
/** Notify rescan completed */
rescanCompleted(count: number, matched?: number) {
const matchedStr = matched !== undefined ? `, ${matched} matched` : ''
return this.success('Rescan Complete', `Processed ${count} files${matchedStr}`, ICONS.SYNC)
}
// ============================================
// Scheduler Operations
// ============================================
/** Notify scheduler started */
schedulerStarted() {
return this.success('Scheduler Started', 'The scheduler service has been started successfully', ICONS.SCHEDULER)
}
/** Notify scheduler stopped */
schedulerStopped() {
return this.success('Scheduler Stopped', 'The scheduler service has been stopped', ICONS.SCHEDULER)
}
/** Notify scheduler operation failed */
schedulerError(operation: string, err?: unknown) {
return this.error(`${operation} Failed`, extractErrorMessage(err, `Failed to ${operation.toLowerCase()} scheduler`))
}
/** Notify download stopped */
downloadStopped(platform: string) {
const platformName = this.formatPlatformName(platform)
return this.success('Download Stopped', `${platformName} download has been stopped`)
}
// ============================================
// Clipboard Operations
// ============================================
/** Notify content copied to clipboard */
copied(what: string = 'Content') {
return this.success('Copied', `${what} copied to clipboard`, ICONS.COPY)
}
// ============================================
// Validation
// ============================================
/** Notify validation error */
validationError(message: string) {
return this.error('Required', message)
}
// ============================================
// Date/Time Operations
// ============================================
/** Notify date updated */
dateUpdated(itemType: string = 'File') {
return this.success('Date Updated', `${itemType} date updated successfully`)
}
/** Notify date update failed */
dateUpdateError(err?: unknown) {
return this.error('Date Update Failed', extractErrorMessage(err, 'Failed to update file date'))
}
// ============================================
// Download Operations
// ============================================
/** Notify ZIP download ready */
downloadReady(count: number) {
return this.success('Download Ready', `ZIP file with ${count} items is ready`, ICONS.DOWNLOAD)
}
// ============================================
// Platform-specific notifications
// ============================================
downloadStarted(platform: string, username: string, thumbnailUrl?: string) {
const icons: Record<string, string> = {
instagram: ICONS.INSTAGRAM,
fastdl: ICONS.INSTAGRAM,
imginn: ICONS.INSTAGRAM,
toolzu: ICONS.INSTAGRAM,
tiktok: ICONS.TIKTOK,
snapchat: ICONS.SNAPCHAT,
forum: ICONS.FORUM,
}
const icon = icons[platform.toLowerCase()] || ICONS.DOWNLOAD
const platformName = this.formatPlatformName(platform)
return this.info(`${platformName}`, `Downloading from ${username}...`, icon, thumbnailUrl)
}
downloadCompleted(platform: string, filename: string, username?: string, thumbnailUrl?: string) {
const icons: Record<string, string> = {
instagram: ICONS.INSTAGRAM,
fastdl: ICONS.INSTAGRAM,
imginn: ICONS.INSTAGRAM,
toolzu: ICONS.INSTAGRAM,
tiktok: ICONS.TIKTOK,
snapchat: ICONS.SNAPCHAT,
forum: ICONS.FORUM,
}
const icon = icons[platform.toLowerCase()] || ICONS.DOWNLOAD
const platformName = this.formatPlatformName(platform)
// Don't show " from all" when username is "all" (platform-wide download)
const from = username && username !== 'all' ? ` from ${username}` : ''
return this.success(`${platformName} Download Complete`, `${filename}${from}`, icon, thumbnailUrl)
}
downloadError(platform: string, error: string) {
const platformName = this.formatPlatformName(platform)
return this.error(`${platformName} Error`, error, ICONS.WARNING)
}
reviewQueue(_platform: string, count: number, username?: string) {
const from = username ? ` from ${username}` : ''
const itemText = count === 1 ? 'item' : 'items'
return this.show(
`Review Queue${from ? `: ${username}` : ''}`,
`${count} ${itemText} moved to review queue (no face match)`,
ICONS.REVIEW,
'warning'
)
}
private formatPlatformName(platform: string): string {
const names: Record<string, string> = {
fastdl: 'Instagram',
imginn: 'Instagram',
toolzu: 'Instagram',
instagram: 'Instagram',
tiktok: 'TikTok',
snapchat: 'Snapchat',
forum: 'Forum',
}
return names[platform.toLowerCase()] || platform.charAt(0).toUpperCase() + platform.slice(1)
}
// ============================================
// Generic API Error Handler
// ============================================
/** Show error notification from API response */
apiError(title: string, err: unknown, fallback?: string) {
return this.error(title, extractErrorMessage(err, fallback || 'An error occurred'))
}
}
export const notificationManager = new NotificationManager()

View File

@@ -0,0 +1,160 @@
/**
* Global Task Manager
*
* Tracks background tasks across page navigation and shows notifications on completion.
*/
import { api } from './api'
import { notificationManager } from './notificationManager'
interface TaskInfo {
taskId: string
presetId: number
presetName: string
startedAt: number
}
interface TaskResult {
results_count?: number
new_count?: number
match_count?: number
error?: string
}
interface TaskResponse {
success: boolean
task: {
status: string
result?: TaskResult
}
}
class TaskManager {
private runningTasks: Map<string, TaskInfo> = new Map()
private pollingIntervals: Map<string, ReturnType<typeof setInterval>> = new Map()
private listeners: Set<(tasks: Map<string, TaskInfo>) => void> = new Set()
/**
* Start tracking a background task
*/
trackTask(taskId: string, presetId: number, presetName: string): void {
const taskInfo: TaskInfo = {
taskId,
presetId,
presetName,
startedAt: Date.now()
}
this.runningTasks.set(taskId, taskInfo)
this.notifyListeners()
this.startPolling(taskId)
}
/**
* Check if a preset is currently running
*/
isPresetRunning(presetId: number): boolean {
for (const task of this.runningTasks.values()) {
if (task.presetId === presetId) {
return true
}
}
return false
}
/**
* Get all running preset IDs
*/
getRunningPresetIds(): Set<number> {
const ids = new Set<number>()
for (const task of this.runningTasks.values()) {
ids.add(task.presetId)
}
return ids
}
/**
* Subscribe to task updates
*/
subscribe(listener: (tasks: Map<string, TaskInfo>) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
private notifyListeners(): void {
for (const listener of this.listeners) {
listener(this.runningTasks)
}
}
private startPolling(taskId: string): void {
const maxDuration = 10 * 60 * 1000 // 10 minutes max
const pollInterval = 1000 // 1 second
const poll = async () => {
const taskInfo = this.runningTasks.get(taskId)
if (!taskInfo) {
this.stopPolling(taskId)
return
}
// Check if we've exceeded max duration
if (Date.now() - taskInfo.startedAt > maxDuration) {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
notificationManager.warning('Timeout', `${taskInfo.presetName}: Task timed out`)
return
}
try {
const response = await api.get<TaskResponse>(`/celebrity/tasks/${taskId}`)
if (response.task.status === 'completed') {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
const result = response.task.result
if (result?.results_count !== undefined || result?.new_count !== undefined) {
const resultsText = result.results_count ? `${result.results_count} found` : ''
const matchText = result.match_count ? `${result.match_count} matched` : ''
const newText = result.new_count !== undefined ? `${result.new_count} new` : ''
const parts = [resultsText, matchText, newText].filter(Boolean).join(', ')
notificationManager.success('Complete', `${taskInfo.presetName}: ${parts || 'Discovery complete'}`)
} else {
notificationManager.success('Complete', `${taskInfo.presetName}: Discovery complete`)
}
} else if (response.task.status === 'failed') {
this.runningTasks.delete(taskId)
this.stopPolling(taskId)
this.notifyListeners()
notificationManager.error('Failed', response.task.result?.error || `${taskInfo.presetName}: Discovery failed`)
}
// Still running - continue polling
} catch (e) {
// API error - continue polling (task might not be ready yet)
console.debug('Task polling error (will retry):', taskId, e)
}
}
// Start polling
const interval = setInterval(poll, pollInterval)
this.pollingIntervals.set(taskId, interval)
// Initial poll after a short delay
setTimeout(poll, 500)
}
private stopPolling(taskId: string): void {
const interval = this.pollingIntervals.get(taskId)
if (interval) {
clearInterval(interval)
this.pollingIntervals.delete(taskId)
}
}
}
// Singleton instance
export const taskManager = new TaskManager()

View File

@@ -0,0 +1,57 @@
/**
* Thumbnail request queue - limits concurrent fetches to avoid
* overwhelming the browser connection pool and nginx.
*
* With HTTP/1.1, browsers open 6-8 connections per domain.
* Without a queue, 100+ thumbnail requests fire simultaneously,
* causing most to wait in the browser's internal queue.
* This queue ensures orderly loading with priority for visible items.
*/
type QueueItem = {
resolve: (value: string) => void
reject: (reason: unknown) => void
src: string
}
const MAX_CONCURRENT = 20
let activeCount = 0
const queue: QueueItem[] = []
function processQueue() {
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
const item = queue.shift()!
activeCount++
const img = new Image()
img.onload = () => {
activeCount--
item.resolve(item.src)
processQueue()
}
img.onerror = () => {
activeCount--
item.reject(new Error('Failed to load'))
processQueue()
}
img.src = item.src
}
}
/**
* Queue a thumbnail URL for loading. Returns a promise that resolves
* when the image has been fetched (and is in the browser cache).
*/
export function queueThumbnail(src: string): Promise<string> {
return new Promise((resolve, reject) => {
queue.push({ resolve, reject, src })
processQueue()
})
}
/**
* Clear all pending items from the queue (e.g., on page navigation).
*/
export function clearThumbnailQueue() {
queue.length = 0
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useRef } from 'react'
interface SwipeGestureHandlers {
onSwipeLeft?: () => void
onSwipeRight?: () => void
onSwipeDown?: () => void
threshold?: number
}
export function useSwipeGestures({
onSwipeLeft,
onSwipeRight,
onSwipeDown,
threshold = 50,
}: SwipeGestureHandlers) {
const touchStartX = useRef<number>(0)
const touchStartY = useRef<number>(0)
const touchEndX = useRef<number>(0)
const touchEndY = useRef<number>(0)
useEffect(() => {
const handleTouchStart = (e: TouchEvent) => {
touchStartX.current = e.changedTouches[0].screenX
touchStartY.current = e.changedTouches[0].screenY
}
const handleTouchEnd = (e: TouchEvent) => {
touchEndX.current = e.changedTouches[0].screenX
touchEndY.current = e.changedTouches[0].screenY
handleGesture()
}
const handleGesture = () => {
const deltaX = touchEndX.current - touchStartX.current
const deltaY = touchEndY.current - touchStartY.current
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
// Horizontal swipe (left/right)
if (absDeltaX > threshold && absDeltaX > absDeltaY) {
if (deltaX > 0 && onSwipeRight) {
onSwipeRight()
} else if (deltaX < 0 && onSwipeLeft) {
onSwipeLeft()
}
}
// Vertical swipe down
else if (absDeltaY > threshold && absDeltaY > absDeltaX && deltaY > 0 && onSwipeDown) {
onSwipeDown()
}
}
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
}
}, [onSwipeLeft, onSwipeRight, onSwipeDown, threshold])
}

View File

@@ -0,0 +1,294 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export function formatDate(date: string | undefined | null) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
// Database stores dates as 'YYYY-MM-DD HH:MM:SS' in UTC (SQLite CURRENT_TIMESTAMP)
// Parse as UTC and convert to local time for display
const d = date.includes(' ')
? new Date(date.replace(' ', 'T') + 'Z') // UTC from database
: new Date(date) // Already has timezone or is ISO format
return d.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
export function formatDateOnly(date: string | undefined | null) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
// Parse date - handles ISO format (2024-03-15T00:00:00) and simple date (2024-03-15)
const d = date.includes(' ')
? new Date(date.replace(' ', 'T') + 'Z')
: new Date(date)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
export function formatRelativeTime(date: string) {
if (!date) return 'N/A'
// Ensure date is a string (guard against Date objects or numbers)
if (typeof date !== 'string') {
date = String(date)
}
const now = new Date()
// Handle different date formats:
// - Scheduler API returns local ISO format: "2025-10-30T10:41:30.722479"
// - Database returns UTC format: "2025-10-30 10:41:30" (SQLite CURRENT_TIMESTAMP)
let then: Date
if (date.includes('T') && !date.endsWith('Z') && date.match(/T\d{2}:\d{2}:\d{2}\.\d+$/)) {
// Local timestamp from scheduler API (has 'T' and microseconds, no 'Z')
// Parse as local time directly - do NOT append 'Z'
then = new Date(date)
} else if (date.includes(' ')) {
// UTC timestamp from database (SQLite CURRENT_TIMESTAMP is UTC)
// Append 'Z' to indicate UTC timezone
then = new Date(date.replace(' ', 'T') + 'Z')
} else {
// Already has timezone or is ISO format
then = new Date(date)
}
const diff = now.getTime() - then.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
// Handle future times (next scheduled runs)
if (seconds < 0) {
const absSeconds = Math.abs(seconds)
const absMinutes = Math.floor(absSeconds / 60)
const absHours = Math.floor(absMinutes / 60)
const absDays = Math.floor(absHours / 24)
if (absSeconds < 60) return 'now'
if (absMinutes < 60) return `in ${absMinutes}m`
if (absHours < 24) return `in ${absHours}h ${absMinutes % 60}m`
return `in ${absDays}d ${absHours % 24}h`
}
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return formatDate(date)
}
/**
* Check if a filename or path represents a video file.
* Centralized utility to avoid duplication across components.
*/
export function isVideoFile(filename: string | undefined | null): boolean {
if (!filename) return false
return /\.(mp4|mov|webm|avi|mkv|flv|m4v|wmv|mpg|mpeg)$/i.test(filename)
}
/**
* Get the media type string based on filename.
*/
export function getMediaType(filename: string | undefined | null): 'video' | 'image' {
return isVideoFile(filename) ? 'video' : 'image'
}
export function formatPlatformName(platform: string | null | undefined): string {
if (!platform) return 'Unknown'
const platformNames: Record<string, string> = {
// Social media platforms
'instagram': 'Instagram',
'fastdl': 'Instagram',
'imginn': 'Instagram',
'instagram_client': 'Instagram',
'toolzu': 'Instagram',
'snapchat': 'Snapchat',
'tiktok': 'TikTok',
'twitter': 'Twitter',
'x': 'Twitter',
'reddit': 'Reddit',
// YouTube
'youtube': 'YouTube',
'youtube_monitor': 'YouTube',
'youtube_channel_monitor': 'YouTube',
// Twitch
'twitch': 'Twitch',
// Pornhub
'pornhub': 'Pornhub',
'xhamster': 'xHamster',
// Paid content platforms
'paid_content': 'Paid Content',
'fansly': 'Fansly',
'fansly_direct': 'Fansly',
'onlyfans': 'OnlyFans',
'onlyfans_direct': 'OnlyFans',
'coomer': 'Coomer',
'kemono': 'Kemono',
// Usenet
'easynews': 'Easynews',
'easynews_monitor': 'Easynews',
'nzb': 'NZB',
// Forums & galleries
'forum': 'Forums',
'forums': 'Forums',
'forum_monitor': 'Forums',
'monitor': 'Forum Monitor',
'coppermine': 'Coppermine',
'bellazon': 'Bellazon',
'hqcelebcorner': 'HQCelebCorner',
'picturepub': 'PicturePub',
'soundgasm': 'Soundgasm',
// Appearances & media databases
'appearances': 'Appearances',
'appearances_sync': 'Appearances',
'tmdb': 'TMDb',
'tvdb': 'TVDB',
'imdb': 'IMDb',
'plex': 'Plex',
'press': 'Press',
// Video platforms
'vimeo': 'Vimeo',
'dailymotion': 'Dailymotion',
// Other
'other': 'Other',
'unknown': 'Unknown'
}
const key = platform.toLowerCase()
if (platformNames[key]) {
return platformNames[key]
}
// Handle underscore-separated names like "easynews_monitor" -> "Easynews Monitor"
// But first check if we have a specific mapping
const withoutSuffix = key.replace(/_monitor$|_sync$|_channel$/, '')
if (platformNames[withoutSuffix]) {
return platformNames[withoutSuffix]
}
// Fall back to title case with underscores replaced by spaces
return platform
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
/**
* Get the appropriate thumbnail URL for a media file.
* Centralizes the logic for determining image vs video type.
*
* @param api - The API client instance
* @param filePath - Path to the media file
* @param filename - Filename to check for video extensions
* @param contentType - Optional content type from server (takes precedence)
*/
export function getMediaThumbnailType(filename: string | undefined | null, contentType?: string): 'image' | 'video' {
if (contentType === 'video') return 'video'
return isVideoFile(filename) ? 'video' : 'image'
}
/**
* Format error messages from API responses consistently.
*/
export function decodeHtmlEntities(text: string | null | undefined): string {
if (!text) return ''
const textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
/**
* Strip Instagram-style dot spacers from captions.
* Collapses sequences of lines that are only dots or blank into a
* single newline, as long as there are at least 3 dot-only lines.
*/
export function cleanCaption(text: string): string {
// Split into lines, find runs of dot/blank lines, collapse if >=3 dots
const lines = text.split('\n')
const result: string[] = []
let i = 0
while (i < lines.length) {
const trimmed = lines[i].trim()
if (trimmed === '.' || trimmed === '') {
// Start of a potential spacer block
let j = i
let dotCount = 0
while (j < lines.length) {
const t = lines[j].trim()
if (t === '.') { dotCount++; j++ }
else if (t === '') { j++ }
else { break }
}
if (dotCount >= 3) {
// Collapse the whole block
result.push('')
i = j
} else {
// Not enough dots, keep original lines
result.push(lines[i])
i++
}
} else {
result.push(lines[i])
i++
}
}
return result.join('\n')
}
/**
* Cache buster for thumbnail URLs. Rotates daily so browsers
* refetch thumbnails once per day but still cache within a day.
*/
export const THUMB_CACHE_V = `v=yt4-${Math.floor(Date.now() / 86_400_000)}`
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
// Check for API error structure
const apiError = error as Error & { response?: { data?: { detail?: string } } }
if (apiError.response?.data?.detail) {
return apiError.response.data.detail
}
return error.message
}
if (typeof error === 'string') return error
return 'An unexpected error occurred'
}