From 09d8a124a540195e65f2eb444c4b5104835961ef Mon Sep 17 00:00:00 2001 From: kuekhaoyang Date: Sun, 23 Nov 2025 14:22:51 +0800 Subject: [PATCH] feat: Implement HLS cache management with service worker updates, metadata tracking, and a settings UI. --- components/player/hooks/useHLSPreloader.ts | 3 +- components/settings/CacheSettings.tsx | 130 +++++++++++++++ lib/hooks/useHistoryDownloader.ts | 1 + lib/utils/cacheManager.ts | 175 ++++++++++++++++----- lib/utils/segmentDownloader.ts | 29 +++- public/sw.js | 57 ++++--- 6 files changed, 333 insertions(+), 62 deletions(-) create mode 100644 components/settings/CacheSettings.tsx diff --git a/components/player/hooks/useHLSPreloader.ts b/components/player/hooks/useHLSPreloader.ts index 65603c1..feeeaf3 100644 --- a/components/player/hooks/useHLSPreloader.ts +++ b/components/player/hooks/useHLSPreloader.ts @@ -91,7 +91,8 @@ export function useHLSPreloader({ src, currentTime }: UseHLSPreloaderProps) { downloadSegmentQueue({ segments: segmentsRef.current, startIndex, - signal: abortControllerRef.current.signal + signal: abortControllerRef.current.signal, + videoUrl: src // Pass the m3u8 URL for metadata tracking }); }, isInitializedRef.current ? 0 : 100); // 100ms debounce on initial load only diff --git a/components/settings/CacheSettings.tsx b/components/settings/CacheSettings.tsx new file mode 100644 index 0000000..9850b8a --- /dev/null +++ b/components/settings/CacheSettings.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Icons } from '@/components/ui/icons'; +import { cacheManager, type CacheStats } from '@/lib/utils/cacheManager'; + +export function CacheSettings() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + + const loadStats = async () => { + const cacheStats = await cacheManager.getCacheStats(); + setStats(cacheStats); + }; + + useEffect(() => { + loadStats(); + // Refresh stats every 10 seconds + const interval = setInterval(loadStats, 10000); + return () => clearInterval(interval); + }, []); + + const handleClearAll = async () => { + if (!confirm('确定要清除所有缓存吗?这将删除所有已下载的视频片段。')) { + return; + } + + setLoading(true); + try { + await cacheManager.clearAllCache(); + await loadStats(); + } finally { + setLoading(false); + } + }; + + const handleCleanup = async () => { + setLoading(true); + try { + await cacheManager.checkAndCleanup(); + await loadStats(); + } finally { + setLoading(false); + } + }; + + const formatDate = (timestamp: number) => { + if (!timestamp) return '无'; + return new Date(timestamp).toLocaleString('zh-CN'); + }; + + return ( + + + + + 缓存管理 + + + 视频片段缓存自动管理,7天后过期,最大1GB + + + + {stats && ( +
+
+
+
缓存条目
+
{stats.totalEntries}
+
+
+
缓存大小
+
{stats.totalSizeMB.toFixed(2)} MB
+
+
+ +
+
+ 最早缓存 + {formatDate(stats.oldestEntry)} +
+
+ 最新缓存 + {formatDate(stats.newestEntry)} +
+
+ + {stats.totalSizeMB > 800 && ( +
+
+ + 缓存空间即将达到上限 +
+
+ )} +
+ )} + +
+ + +
+ +
+

• 缓存会在7天后自动过期

+

• 超过1GB时自动清理最旧的30%

+

• 系统每5分钟自动检查一次

+
+
+
+ ); +} diff --git a/lib/hooks/useHistoryDownloader.ts b/lib/hooks/useHistoryDownloader.ts index d23096d..cd72125 100644 --- a/lib/hooks/useHistoryDownloader.ts +++ b/lib/hooks/useHistoryDownloader.ts @@ -39,6 +39,7 @@ export function useHistoryDownloader() { segments, startIndex: 0, signal: controller.signal, + videoUrl: url, // Track metadata for this video onProgress: (current, total) => { if (current % 50 === 0) { console.log(`[HistoryDownloader] ${item.title}: ${current}/${total}`); diff --git a/lib/utils/cacheManager.ts b/lib/utils/cacheManager.ts index 3e56ef9..bb875aa 100644 --- a/lib/utils/cacheManager.ts +++ b/lib/utils/cacheManager.ts @@ -1,47 +1,150 @@ -/** - * Cache Management Utility - * Handles clearing segments from cache - */ - +// Intelligent Cache Manager - Auto-manages video segment caching const CACHE_NAME = 'video-cache-v1'; +const METADATA_STORE = 'cache-metadata'; +const CACHE_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days +const MAX_CACHE_SIZE_MB = 1000; // 1GB -export async function clearSegmentsForUrl(url: string): Promise { - if (!('caches' in window)) return; +interface CacheMetadata { url: string; videoUrl: string; cachedAt: number; size: number; lastAccessed: number; } - try { +export interface CacheStats { totalEntries: number; totalSizeMB: number; oldestEntry: number; newestEntry: number; } + +class CacheManager { + private metadata = new Map(); + private initialized = false; + + async initialize(): Promise { + if (this.initialized) return; + try { + const stored = localStorage.getItem(METADATA_STORE); + if (stored) this.metadata = new Map(Object.entries(JSON.parse(stored))); + console.log('[CacheManager] Initialized with', this.metadata.size, 'entries'); + } catch (error) { console.error('[CacheManager] Init failed:', error); } + this.initialized = true; + } + + private save(): void { + try { localStorage.setItem(METADATA_STORE, JSON.stringify(Object.fromEntries(this.metadata))); } + catch (e) { console.error('[CacheManager] Save failed:', e); } + } + + async addCacheEntry(url: string, videoUrl: string, size: number): Promise { + await this.initialize(); + this.metadata.set(url, { url, videoUrl, cachedAt: Date.now(), size, lastAccessed: Date.now() }); + this.save(); + } + + async isCacheValid(url: string): Promise { + await this.initialize(); + const meta = this.metadata.get(url); + if (!meta) return false; + if (Date.now() - meta.cachedAt > CACHE_TTL) { + console.log('[CacheManager] Cache expired:', url); + return false; + } + meta.lastAccessed = Date.now(); + this.save(); + return true; + } + + async getCacheStats(): Promise { + await this.initialize(); + const entries = Array.from(this.metadata.values()); + const totalSize = entries.reduce((sum, e) => sum + e.size, 0); + const times = entries.map(e => e.cachedAt); + return { + totalEntries: entries.length, totalSizeMB: totalSize / (1024 * 1024), + oldestEntry: times.length ? Math.min(...times) : 0, newestEntry: times.length ? Math.max(...times) : 0 + }; + } + + async checkAndCleanup(): Promise { + await this.initialize(); + const stats = await this.getCacheStats(); + if (stats.totalSizeMB > MAX_CACHE_SIZE_MB) { + console.log(`[CacheManager] Size ${stats.totalSizeMB.toFixed(2)}MB exceeds ${MAX_CACHE_SIZE_MB}MB`); + await this.cleanupOldEntries(); + } + await this.cleanupExpiredEntries(); + } + + async cleanupExpiredEntries(): Promise { + await this.initialize(); + if (!('caches' in window)) return 0; const cache = await caches.open(CACHE_NAME); - const requests = await cache.keys(); - - // Parse the base URL to match against - const baseUrl = url.substring(0, url.lastIndexOf('/') + 1); - - let deletedCount = 0; - for (const request of requests) { - if (request.url.startsWith(baseUrl)) { - await cache.delete(request); - deletedCount++; + const now = Date.now(); + let cleaned = 0; + for (const [url, meta] of this.metadata.entries()) { + if (now - meta.cachedAt > CACHE_TTL) { + await cache.delete(url); + this.metadata.delete(url); + cleaned++; } } - - if (deletedCount > 0) { - console.log(`[CacheManager] Deleted ${deletedCount} segments for ${url}`); + if (cleaned > 0) { + this.save(); + console.log(`[CacheManager] Cleaned ${cleaned} expired entries`); } - } catch (error) { - console.error('[CacheManager] Error clearing cache:', error); + return cleaned; + } + + async cleanupOldEntries(): Promise { + await this.initialize(); + if (!('caches' in window)) return 0; + const cache = await caches.open(CACHE_NAME); + const sorted = Array.from(this.metadata.entries()).sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); + const toRemove = Math.ceil(sorted.length * 0.3); + let removed = 0; + for (let i = 0; i < toRemove && i < sorted.length; i++) { + await cache.delete(sorted[i][0]); + this.metadata.delete(sorted[i][0]); + removed++; + } + if (removed > 0) { + this.save(); + console.log(`[CacheManager] Removed ${removed} old entries`); + } + return removed; + } + + async clearVideoCache(videoUrl: string): Promise { + await this.initialize(); + if (!('caches' in window)) return 0; + const cache = await caches.open(CACHE_NAME); + let cleared = 0; + for (const [url, meta] of this.metadata.entries()) { + if (meta.videoUrl === videoUrl) { + await cache.delete(url); + this.metadata.delete(url); + cleared++; + } + } + if (cleared > 0) { + this.save(); + console.log(`[CacheManager] Cleared ${cleared} entries for:`, videoUrl); + } + return cleared; + } + + async clearAllCache(): Promise { + await this.initialize(); + if (!('caches' in window)) return 0; + const cache = await caches.open(CACHE_NAME); + await Promise.all((await cache.keys()).map(k => cache.delete(k))); + const count = this.metadata.size; + this.metadata.clear(); + this.save(); + console.log(`[CacheManager] Cleared all ${count} entries`); + return count; } } -export async function clearAllCache(): Promise { - if (!('caches' in window)) return; +export const cacheManager = new CacheManager(); +export const clearSegmentsForUrl = (url: string) => cacheManager.clearVideoCache(url); +export const clearAllCache = () => cacheManager.clearAllCache(); - try { - const deleted = await caches.delete(CACHE_NAME); - if (deleted) { - console.log('[CacheManager] Cleared all cached segments'); - // Recreate the cache for future use - await caches.open(CACHE_NAME); - } - } catch (error) { - console.error('[CacheManager] Error clearing all cache:', error); - } -} +if (typeof window !== 'undefined') { + cacheManager.initialize().then(() => { + setInterval(() => cacheManager.checkAndCleanup(), 5 * 60 * 1000); + setTimeout(() => cacheManager.checkAndCleanup(), 10000); + }); +} \ No newline at end of file diff --git a/lib/utils/segmentDownloader.ts b/lib/utils/segmentDownloader.ts index 9906779..6ed7a1a 100644 --- a/lib/utils/segmentDownloader.ts +++ b/lib/utils/segmentDownloader.ts @@ -4,12 +4,14 @@ */ import type { Segment } from './hlsManifestParser'; +import { cacheManager } from './cacheManager'; interface DownloadQueueOptions { segments: Segment[]; startIndex: number; signal: AbortSignal; onProgress?: (current: number, total: number) => void; + videoUrl?: string; // The m3u8 URL for metadata tracking } const CONCURRENCY = 1000; @@ -17,7 +19,7 @@ const TIMEOUT_MS = 15000; const CACHE_NAME = 'video-cache-v1'; export async function downloadSegmentQueue(options: DownloadQueueOptions): Promise { - const { segments, startIndex, signal, onProgress } = options; + const { segments, startIndex, signal, onProgress, videoUrl } = options; if (!('caches' in window)) return; @@ -38,18 +40,37 @@ export async function downloadSegmentQueue(options: DownloadQueueOptions): Promi const fetchSignal = anySignal([signal, timeoutController.signal]); try { + // Check if cache exists AND is valid (not expired) const match = await cache.match(url, { ignoreSearch: true }); - if (match) { + const isValid = match ? await cacheManager.isCacheValid(url) : false; + + if (match && isValid) { onProgress?.(currentIndex, segments.length); - console.log(`[Preloader] 已缓存,跳过: ${currentIndex}/${segments.length}`); + console.log(`[Preloader] 已缓存且有效: ${currentIndex}/${segments.length}`); } else { + // If cache exists but expired, delete it + if (match && !isValid) { + await cache.delete(url); + } + console.log(`[Preloader] 正在下载片段 ${currentIndex}/${segments.length}`); const response = await fetch(url, { signal: fetchSignal }); + if (response.ok) { try { + const clonedResponse = response.clone(); await cache.put(url, response.clone()); + + // Track metadata if videoUrl is provided + if (videoUrl) { + const blob = await clonedResponse.blob(); + await cacheManager.addCacheEntry(url, videoUrl, blob.size); + } + onProgress?.(currentIndex, segments.length); - } catch (e) { /* ignore quota errors */ } + } catch (e) { + console.warn('[Preloader] Cache quota error:', e); + } } } } catch (err) { diff --git a/public/sw.js b/public/sw.js index cad7ea5..27efb1c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -21,32 +21,47 @@ self.addEventListener('activate', (event) => { self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); - // Intercept .ts file requests + // Intercept HLS manifest files (.m3u8) + if (url.pathname.endsWith('.m3u8')) { + event.respondWith( + caches.open(CACHE_NAME).then((cache) => { + return cache.match(event.request, { ignoreSearch: true }).then((response) => { + // Always fetch fresh manifest but return cached while fetching + const fetchPromise = fetch(event.request).then((networkResponse) => { + if (networkResponse && networkResponse.status === 200) { + cache.put(event.request, networkResponse.clone()); + } + return networkResponse; + }).catch(() => response); // Fallback to cache on network error + + // Return cache immediately if available, otherwise wait for network + return response || fetchPromise; + }); + }) + ); + } + + // Intercept video segment files (.ts) if (url.pathname.endsWith('.ts')) { event.respondWith( - caches.match(event.request, { ignoreSearch: true }).then((response) => { - // Cache hit - return response - if (response) { - return response; - } - - // Clone the request because it's a stream and can only be consumed once - const fetchRequest = event.request.clone(); - - return fetch(fetchRequest).then((response) => { - // Check if we received a valid response - if (!response || response.status !== 200 || response.type !== 'basic') { - return response; + caches.open(CACHE_NAME).then((cache) => { + return cache.match(event.request, { ignoreSearch: true }).then((cachedResponse) => { + // Cache hit - return immediately for instant playback + if (cachedResponse) { + return cachedResponse; } - // Clone the response because it's a stream - const responseToCache = response.clone(); - - caches.open(CACHE_NAME).then((cache) => { - cache.put(event.request, responseToCache); + // Cache miss - fetch from network + return fetch(event.request).then((response) => { + // Only cache valid responses + if (response && response.status === 200) { + cache.put(event.request, response.clone()); + } + return response; + }).catch((error) => { + console.error('[SW] Failed to fetch segment:', error); + throw error; }); - - return response; }); }) );