mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-17 01:33:43 +08:00
feat: Implement HLS cache management with service worker updates, metadata tracking, and a settings UI.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
视频片段缓存自动管理,7天后过期,最大1GB
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user