feat(storage): 实现跨平台 Storage API 抽象层
- 添加统一的 IStorage 接口定义 - 实现 IndexedDBStorage (Web/Desktop) - 实现 NativeStorage (Android/TV) - 添加类型定义 (Subscription, Channel, SourceValidity等) - 更新 Android/TV AssetReader 支持 SharedPreferences - 安装 idb 库用于 IndexedDB 操作
This commit is contained in:
parent
2cab50db31
commit
380f4ab4d6
@ -1,6 +1,7 @@
|
|||||||
package com.iptv.tv;
|
package com.iptv.tv;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.webkit.JavascriptInterface;
|
import android.webkit.JavascriptInterface;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -9,9 +10,12 @@ import java.io.InputStreamReader;
|
|||||||
|
|
||||||
public class AssetReader {
|
public class AssetReader {
|
||||||
private Context context;
|
private Context context;
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private static final String PREFS_NAME = "IPTVData";
|
||||||
|
|
||||||
public AssetReader(Context context) {
|
public AssetReader(Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
@ -45,4 +49,31 @@ public class AssetReader {
|
|||||||
return false;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package com.iptv.app;
|
package com.iptv.app;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.webkit.JavascriptInterface;
|
import android.webkit.JavascriptInterface;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@ -9,15 +10,17 @@ import java.io.InputStreamReader;
|
|||||||
|
|
||||||
public class AssetReader {
|
public class AssetReader {
|
||||||
private Context context;
|
private Context context;
|
||||||
|
private SharedPreferences prefs;
|
||||||
|
private static final String PREFS_NAME = "IPTVData";
|
||||||
|
|
||||||
public AssetReader(Context context) {
|
public AssetReader(Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
|
this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
public String readFile(String path) {
|
public String readFile(String path) {
|
||||||
try {
|
try {
|
||||||
// path 如: "www/api/result.txt"
|
|
||||||
InputStream is = context.getAssets().open(path);
|
InputStream is = context.getAssets().open(path);
|
||||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
@ -46,4 +49,31 @@ public class AssetReader {
|
|||||||
return false;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
ui/package-lock.json
generated
11
ui/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
"hls.js": "^1.5.0",
|
"hls.js": "^1.5.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
@ -351,6 +352,11 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"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": {
|
"node_modules/is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz",
|
||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
|
"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": {
|
"is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.2.0",
|
"@vueuse/core": "^14.2.0",
|
||||||
"hls.js": "^1.5.0",
|
"hls.js": "^1.5.0",
|
||||||
|
"idb": "^8.0.3",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
|
|||||||
8
ui/src/storage/adapters/README.md
Normal file
8
ui/src/storage/adapters/README.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Storage Adapters
|
||||||
|
|
||||||
|
存储适配器目录,按平台分离实现:
|
||||||
|
|
||||||
|
- `indexeddb.js` - Web/Desktop 端(使用 IndexedDB)
|
||||||
|
- `native.js` - Android/TV 端(通过 JS Bridge 调用原生存储)
|
||||||
|
|
||||||
|
目前所有实现统一在 `../index.js` 中,如需拆分可在此目录创建单独文件。
|
||||||
467
ui/src/storage/index.js
Normal file
467
ui/src/storage/index.js
Normal file
@ -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;
|
||||||
72
ui/src/storage/types.js
Normal file
72
ui/src/storage/types.js
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user