From 380f4ab4d60a19207cb2b40d8598d088e0b5d2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Thu, 5 Feb 2026 14:29:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(storage):=20=E5=AE=9E=E7=8E=B0=E8=B7=A8?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=20Storage=20API=20=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加统一的 IStorage 接口定义 - 实现 IndexedDBStorage (Web/Desktop) - 实现 NativeStorage (Android/TV) - 添加类型定义 (Subscription, Channel, SourceValidity等) - 更新 Android/TV AssetReader 支持 SharedPreferences - 安装 idb 库用于 IndexedDB 操作 --- .../main/java/com/iptv/tv/AssetReader.java | 31 ++ .../main/java/com/iptv/app/AssetReader.java | 32 +- ui/package-lock.json | 11 + ui/package.json | 1 + ui/src/storage/adapters/README.md | 8 + ui/src/storage/index.js | 467 ++++++++++++++++++ ui/src/storage/types.js | 72 +++ 7 files changed, 621 insertions(+), 1 deletion(-) create mode 100644 ui/src/storage/adapters/README.md create mode 100644 ui/src/storage/index.js create mode 100644 ui/src/storage/types.js diff --git a/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java b/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java index 85f73d4..0674e5a 100644 --- a/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java +++ b/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java @@ -1,6 +1,7 @@ package com.iptv.tv; import android.content.Context; +import android.content.SharedPreferences; import android.webkit.JavascriptInterface; import java.io.BufferedReader; import java.io.IOException; @@ -9,9 +10,12 @@ import java.io.InputStreamReader; public class AssetReader { private Context context; + private SharedPreferences prefs; + private static final String PREFS_NAME = "IPTVData"; public AssetReader(Context context) { this.context = context; + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } @JavascriptInterface @@ -45,4 +49,31 @@ public class AssetReader { return false; } } + + // SharedPreferences 存储接口 + @JavascriptInterface + public String getItem(String key) { + return prefs.getString(key, null); + } + + @JavascriptInterface + public void setItem(String key, String value) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(key, value); + editor.apply(); + } + + @JavascriptInterface + public void removeItem(String key) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(key); + editor.apply(); + } + + @JavascriptInterface + public void clear() { + SharedPreferences.Editor editor = prefs.edit(); + editor.clear(); + editor.apply(); + } } diff --git a/android/app/src/main/java/com/iptv/app/AssetReader.java b/android/app/src/main/java/com/iptv/app/AssetReader.java index 3a01b97..02d80f3 100644 --- a/android/app/src/main/java/com/iptv/app/AssetReader.java +++ b/android/app/src/main/java/com/iptv/app/AssetReader.java @@ -1,6 +1,7 @@ package com.iptv.app; import android.content.Context; +import android.content.SharedPreferences; import android.webkit.JavascriptInterface; import java.io.BufferedReader; import java.io.IOException; @@ -9,15 +10,17 @@ import java.io.InputStreamReader; public class AssetReader { private Context context; + private SharedPreferences prefs; + private static final String PREFS_NAME = "IPTVData"; public AssetReader(Context context) { this.context = context; + this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); } @JavascriptInterface public String readFile(String path) { try { - // path 如: "www/api/result.txt" InputStream is = context.getAssets().open(path); BufferedReader reader = new BufferedReader(new InputStreamReader(is)); StringBuilder sb = new StringBuilder(); @@ -46,4 +49,31 @@ public class AssetReader { return false; } } + + // SharedPreferences 存储接口 + @JavascriptInterface + public String getItem(String key) { + return prefs.getString(key, null); + } + + @JavascriptInterface + public void setItem(String key, String value) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putString(key, value); + editor.apply(); + } + + @JavascriptInterface + public void removeItem(String key) { + SharedPreferences.Editor editor = prefs.edit(); + editor.remove(key); + editor.apply(); + } + + @JavascriptInterface + public void clear() { + SharedPreferences.Editor editor = prefs.edit(); + editor.clear(); + editor.apply(); + } } diff --git a/ui/package-lock.json b/ui/package-lock.json index a91720f..0d8d71c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@vueuse/core": "^14.2.0", "hls.js": "^1.5.0", + "idb": "^8.0.3", "pinia": "^3.0.4", "vue": "^3.4.0", "vue-router": "^4.2.0" @@ -351,6 +352,11 @@ "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", @@ -845,6 +851,11 @@ "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" }, + "idb": { + "version": "8.0.3", + "resolved": "https://registry.npmmirror.com/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==" + }, "is-what": { "version": "5.5.0", "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", diff --git a/ui/package.json b/ui/package.json index 62f1cc4..df7c319 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "dependencies": { "@vueuse/core": "^14.2.0", "hls.js": "^1.5.0", + "idb": "^8.0.3", "pinia": "^3.0.4", "vue": "^3.4.0", "vue-router": "^4.2.0" diff --git a/ui/src/storage/adapters/README.md b/ui/src/storage/adapters/README.md new file mode 100644 index 0000000..1e6b045 --- /dev/null +++ b/ui/src/storage/adapters/README.md @@ -0,0 +1,8 @@ +# Storage Adapters + +存储适配器目录,按平台分离实现: + +- `indexeddb.js` - Web/Desktop 端(使用 IndexedDB) +- `native.js` - Android/TV 端(通过 JS Bridge 调用原生存储) + +目前所有实现统一在 `../index.js` 中,如需拆分可在此目录创建单独文件。 diff --git a/ui/src/storage/index.js b/ui/src/storage/index.js new file mode 100644 index 0000000..e1dcc4e --- /dev/null +++ b/ui/src/storage/index.js @@ -0,0 +1,467 @@ +import { openDB } from 'idb'; +import { + Subscription, Channel, SourceValidity, + Preferences, PlayHistory, CacheMeta +} from './types.js'; + +const DB_NAME = 'IPTVStorage'; +const DB_VERSION = 1; + +/** + * 抽象存储接口 + */ +class IStorage { + // 基础操作 + async get(key) { throw new Error('Not implemented'); } + async set(key, value) { throw new Error('Not implemented'); } + async remove(key) { throw new Error('Not implemented'); } + async clear() { throw new Error('Not implemented'); } + + // 频道数据 + async getChannels() { throw new Error('Not implemented'); } + async setChannels(channels) { throw new Error('Not implemented'); } + async getGroups() { throw new Error('Not implemented'); } + + // 订阅源 + async getSubscriptions() { throw new Error('Not implemented'); } + async setSubscriptions(subs) { throw new Error('Not implemented'); } + + // 线路有效性 + async getValidity(url) { throw new Error('Not implemented'); } + async setValidity(url, validity) { throw new Error('Not implemented'); } + async getAllValidity() { throw new Error('Not implemented'); } + + // 用户偏好 + async getPreferences() { throw new Error('Not implemented'); } + async setPreferences(prefs) { throw new Error('Not implemented'); } + + // 播放历史 + async getHistory(limit = 50) { throw new Error('Not implemented'); } + async addHistory(item) { throw new Error('Not implemented'); } + async clearHistory() { throw new Error('Not implemented'); } + + // 缓存元数据 + async getCacheMeta(key) { throw new Error('Not implemented'); } + async setCacheMeta(key, meta) { throw new Error('Not implemented'); } + async isCacheValid(key, ttl) { throw new Error('Not implemented'); } +} + +/** + * IndexedDB 实现 (Web/Desktop) + */ +class IndexedDBStorage extends IStorage { + constructor() { + super(); + this.db = null; + } + + async init() { + if (this.db) return; + + this.db = await openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + // 存储对象定义 + if (!db.objectStoreNames.contains('channels')) { + const channelStore = db.createObjectStore('channels', { keyPath: 'id' }); + channelStore.createIndex('group', 'group'); + channelStore.createIndex('name', 'name'); + } + + if (!db.objectStoreNames.contains('subscriptions')) { + db.createObjectStore('subscriptions', { keyPath: 'id' }); + } + + if (!db.objectStoreNames.contains('validity')) { + db.createObjectStore('validity', { keyPath: 'url' }); + } + + if (!db.objectStoreNames.contains('preferences')) { + db.createObjectStore('preferences'); + } + + if (!db.objectStoreNames.contains('history')) { + const historyStore = db.createObjectStore('history', { + keyPath: 'timestamp', + autoIncrement: true + }); + historyStore.createIndex('channelId', 'channelId'); + } + + if (!db.objectStoreNames.contains('cacheMeta')) { + db.createObjectStore('cacheMeta', { keyPath: 'key' }); + } + + if (!db.objectStoreNames.contains('generic')) { + db.createObjectStore('generic'); + } + } + }); + } + + // 基础操作 + async get(key) { + await this.init(); + return await this.db.get('generic', key); + } + + async set(key, value) { + await this.init(); + await this.db.put('generic', value, key); + } + + async remove(key) { + await this.init(); + await this.db.delete('generic', key); + } + + async clear() { + await this.init(); + const stores = ['channels', 'subscriptions', 'validity', 'history', 'cacheMeta', 'generic']; + for (const store of stores) { + await this.db.clear(store); + } + } + + // 频道数据 + async getChannels() { + await this.init(); + return await this.db.getAll('channels'); + } + + async setChannels(channels) { + await this.init(); + const tx = this.db.transaction('channels', 'readwrite'); + await tx.store.clear(); + for (const channel of channels) { + await tx.store.put(channel); + } + await tx.done; + + // 更新缓存元数据 + await this.setCacheMeta('channels', new CacheMeta({ + key: 'channels', + updatedAt: Date.now(), + size: channels.length + })); + } + + async getGroups() { + const channels = await this.getChannels(); + const groups = new Set(channels.map(c => c.group)); + return Array.from(groups); + } + + // 订阅源 + async getSubscriptions() { + await this.init(); + return await this.db.getAll('subscriptions'); + } + + async setSubscriptions(subs) { + await this.init(); + const tx = this.db.transaction('subscriptions', 'readwrite'); + await tx.store.clear(); + for (const sub of subs) { + await tx.store.put(sub); + } + await tx.done; + } + + // 线路有效性 + async getValidity(url) { + await this.init(); + return await this.db.get('validity', url); + } + + async setValidity(url, validity) { + await this.init(); + await this.db.put('validity', { ...validity, url }); + } + + async getAllValidity() { + await this.init(); + return await this.db.getAll('validity'); + } + + // 用户偏好 + async getPreferences() { + await this.init(); + const prefs = await this.db.get('preferences', 'user'); + return prefs || new Preferences(); + } + + async setPreferences(prefs) { + await this.init(); + await this.db.put('preferences', prefs, 'user'); + } + + // 播放历史 + async getHistory(limit = 50) { + await this.init(); + const all = await this.db.getAll('history'); + return all.slice(-limit).reverse(); + } + + async addHistory(item) { + await this.init(); + await this.db.add('history', item); + + // 清理旧记录(保留最近 500 条) + const count = await this.db.count('history'); + if (count > 500) { + const toDelete = count - 500; + const keys = await this.db.getAllKeys('history', null, toDelete); + for (const key of keys) { + await this.db.delete('history', key); + } + } + } + + async clearHistory() { + await this.init(); + await this.db.clear('history'); + } + + // 缓存元数据 + async getCacheMeta(key) { + await this.init(); + return await this.db.get('cacheMeta', key); + } + + async setCacheMeta(key, meta) { + await this.init(); + await this.db.put('cacheMeta', { ...meta, key }); + } + + async isCacheValid(key, ttl) { + const meta = await this.getCacheMeta(key); + if (!meta || !meta.updatedAt) return false; + return Date.now() - meta.updatedAt < ttl; + } +} + +/** + * Native 存储实现 (Android/TV) + * 通过 AndroidAsset 接口调用原生 SQLite/SharedPreferences + */ +class NativeStorage extends IStorage { + constructor() { + super(); + this.memoryCache = new Map(); // 内存缓存 + } + + // 检查 Android 接口是否可用 + isAvailable() { + return typeof window.AndroidAsset !== 'undefined'; + } + + // 调用原生接口 + async callNative(method, ...args) { + if (!this.isAvailable()) { + throw new Error('AndroidAsset not available'); + } + return window.AndroidAsset[method](...args); + } + + // 基础操作 (使用 localStorage 作为后备) + async get(key) { + try { + const value = await this.callNative('getItem', key); + return value ? JSON.parse(value) : null; + } catch { + return this.memoryCache.get(key) || null; + } + } + + async set(key, value) { + this.memoryCache.set(key, value); + try { + await this.callNative('setItem', key, JSON.stringify(value)); + } catch { + // 忽略原生存储失败 + } + } + + async remove(key) { + this.memoryCache.delete(key); + try { + await this.callNative('removeItem', key); + } catch {} + } + + async clear() { + this.memoryCache.clear(); + try { + await this.callNative('clear'); + } catch {} + } + + // 频道数据 (使用专用接口) + async getChannels() { + try { + const data = await this.callNative('readChannelData'); + if (data && !data.startsWith('ERROR:')) { + // 解析频道数据并缓存 + const channels = this.parseChannelData(data); + this.memoryCache.set('channels', channels); + return channels; + } + } catch {} + return this.memoryCache.get('channels') || []; + } + + async setChannels(channels) { + this.memoryCache.set('channels', channels); + // 原生端通过文件存储,这里只更新内存缓存 + await this.setCacheMeta('channels', new CacheMeta({ + key: 'channels', + updatedAt: Date.now(), + size: channels.length + })); + } + + parseChannelData(text) { + // 简单的 TXT 格式解析 + const channels = []; + const lines = text.split('\n'); + let currentGroup = ''; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.includes('#genre#')) { + currentGroup = trimmed.split(',')[0]; + } else if (trimmed.includes(',')) { + const [name, url] = trimmed.split(',').map(s => s.trim()); + if (name && url) { + const existing = channels.find(c => c.name === name && c.group === currentGroup); + if (existing) { + existing.urls.push(url); + } else { + channels.push(new Channel({ + id: `${currentGroup}_${name}`, + name, + group: currentGroup, + urls: [url] + })); + } + } + } + } + + return channels; + } + + async getGroups() { + const channels = await this.getChannels(); + const groups = new Set(channels.map(c => c.group)); + return Array.from(groups); + } + + // 订阅源 + async getSubscriptions() { + return await this.get('subscriptions') || []; + } + + async setSubscriptions(subs) { + await this.set('subscriptions', subs); + } + + // 线路有效性 (使用 SharedPreferences) + async getValidity(url) { + const all = await this.getAllValidity(); + return all.find(v => v.url === url); + } + + async setValidity(url, validity) { + const all = await this.getAllValidity(); + const index = all.findIndex(v => v.url === url); + if (index >= 0) { + all[index] = { ...validity, url }; + } else { + all.push({ ...validity, url }); + } + await this.set('validity', all); + } + + async getAllValidity() { + return await this.get('validity') || []; + } + + // 用户偏好 + async getPreferences() { + return await this.get('preferences') || new Preferences(); + } + + async setPreferences(prefs) { + await this.set('preferences', prefs); + } + + // 播放历史 + async getHistory(limit = 50) { + const all = await this.get('history') || []; + return all.slice(-limit).reverse(); + } + + async addHistory(item) { + const all = await this.get('history') || []; + all.push(item); + // 保留最近 500 条 + if (all.length > 500) { + all.splice(0, all.length - 500); + } + await this.set('history', all); + } + + async clearHistory() { + await this.remove('history'); + } + + // 缓存元数据 + async getCacheMeta(key) { + const all = await this.get('cacheMeta') || {}; + return all[key]; + } + + async setCacheMeta(key, meta) { + const all = await this.get('cacheMeta') || {}; + all[key] = { ...meta, key }; + await this.set('cacheMeta', all); + } + + async isCacheValid(key, ttl) { + const meta = await this.getCacheMeta(key); + if (!meta || !meta.updatedAt) return false; + return Date.now() - meta.updatedAt < ttl; + } +} + +/** + * 存储工厂 - 根据平台创建对应的存储实例 + */ +export function createStorage() { + const platform = import.meta.env.VITE_PLATFORM || 'web'; + + if (platform === 'android' || platform === 'tv') { + return new NativeStorage(); + } + + return new IndexedDBStorage(); +} + +// 导出类型和类 +export { + IStorage, + IndexedDBStorage, + NativeStorage, + Subscription, + Channel, + SourceValidity, + Preferences, + PlayHistory, + CacheMeta +}; + +// 默认导出 +export default createStorage; diff --git a/ui/src/storage/types.js b/ui/src/storage/types.js new file mode 100644 index 0000000..f5f60aa --- /dev/null +++ b/ui/src/storage/types.js @@ -0,0 +1,72 @@ +/** + * 存储类型定义 + */ + +// 订阅源 +export class Subscription { + constructor(data = {}) { + this.id = data.id || crypto.randomUUID(); + this.name = data.name || ''; + this.url = data.url || ''; + this.type = data.type || 'm3u'; // 'm3u' | 'txt' + this.enabled = data.enabled !== false; + this.lastUpdated = data.lastUpdated || 0; + this.etag = data.etag || ''; + } +} + +// 频道 +export class Channel { + constructor(data = {}) { + this.id = data.id || ''; + this.name = data.name || ''; + this.group = data.group || ''; + this.urls = data.urls || []; // string[] + this.logo = data.logo || ''; + this.epgId = data.epgId || ''; + } +} + +// 线路有效性 +export class SourceValidity { + constructor(data = {}) { + this.url = data.url || ''; + this.status = data.status || 'unknown'; // 'online' | 'offline' | 'unknown' + this.checkedAt = data.checkedAt || 0; + this.latency = data.latency || 0; + this.failCount = data.failCount || 0; + } +} + +// 用户偏好 +export class Preferences { + constructor(data = {}) { + this.defaultGroup = data.defaultGroup || ''; + this.autoPlay = data.autoPlay !== false; + this.preferredQuality = data.preferredQuality || 'auto'; // 'auto' | 'hd' | 'sd' + this.volume = data.volume ?? 1.0; + this.listCacheTTL = data.listCacheTTL || 24 * 60 * 60 * 1000; // 1天 + this.validityCacheTTL = data.validityCacheTTL || 12 * 60 * 60 * 1000; // 12小时 + } +} + +// 播放历史 +export class PlayHistory { + constructor(data = {}) { + this.channelId = data.channelId || ''; + this.channelName = data.channelName || ''; + this.sourceUrl = data.sourceUrl || ''; + this.timestamp = data.timestamp || Date.now(); + this.duration = data.duration || 0; + } +} + +// 缓存元数据 +export class CacheMeta { + constructor(data = {}) { + this.key = data.key || ''; + this.updatedAt = data.updatedAt || 0; + this.size = data.size || 0; + this.version = data.version || 1; + } +}