feat: Implement HLS cache management with service worker updates, metadata tracking, and a settings UI.

This commit is contained in:
kuekhaoyang
2025-11-23 14:22:51 +08:00
parent 9ee468b246
commit 09d8a124a5
6 changed files with 333 additions and 62 deletions
+2 -1
View File
@@ -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
+130
View File
@@ -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<CacheStats | null>(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 (
<Card className="glass-effect">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icons.Database className="h-5 w-5" />
</CardTitle>
<CardDescription>
71GB
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{stats && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div className="glass-panel p-4 rounded-[var(--radius-lg)]">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">{stats.totalEntries}</div>
</div>
<div className="glass-panel p-4 rounded-[var(--radius-lg)]">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="text-2xl font-bold">{stats.totalSizeMB.toFixed(2)} MB</div>
</div>
</div>
<div className="glass-panel p-4 rounded-[var(--radius-lg)] space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{formatDate(stats.oldestEntry)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span>{formatDate(stats.newestEntry)}</span>
</div>
</div>
{stats.totalSizeMB > 800 && (
<div className="glass-panel p-3 rounded-[var(--radius-lg)] border-l-4 border-yellow-500">
<div className="flex items-center gap-2 text-sm text-yellow-600 dark:text-yellow-400">
<Icons.AlertTriangle className="h-4 w-4" />
<span></span>
</div>
</div>
)}
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleCleanup}
disabled={loading}
variant="outline"
className="flex-1"
>
<Icons.Trash2 className="h-4 w-4 mr-2" />
</Button>
<Button
onClick={handleClearAll}
disabled={loading}
variant="outline"
className="flex-1"
>
<Icons.X className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p> 7</p>
<p> 1GB时自动清理最旧的30%</p>
<p> 5</p>
</div>
</CardContent>
</Card>
);
}
+1
View File
@@ -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}`);
+139 -36
View File
@@ -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<void> {
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<string, CacheMetadata>();
private initialized = false;
async initialize(): Promise<void> {
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<void> {
await this.initialize();
this.metadata.set(url, { url, videoUrl, cachedAt: Date.now(), size, lastAccessed: Date.now() });
this.save();
}
async isCacheValid(url: string): Promise<boolean> {
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<CacheStats> {
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<void> {
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<number> {
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<number> {
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<number> {
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<number> {
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<void> {
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);
});
}
+25 -4
View File
@@ -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<void> {
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) {
+36 -21
View File
@@ -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;
});
})
);