Files
EduardPrigoana e5430fca21 Fix IndexedDB stale-connection and key errors, harden toLowerCase sites, enable Sentry filtering
- db.js: retry raw transactions after InvalidStateError (addToHistory,
  getPlaylists, updatePlaylistTracks); keep timestamp keys valid
- cache.js: drop cached IDB connection on versionchange/close; guard clear()
- events.js: guard add-to-playlist modal against missing DOM nodes
- sentry.js: enable Sentry, ignore environmental noise (network, storage,
  chunk load, lyrics/WebView), add release 5.0.2
- api.js/lyrics.js/ui.js/settings.js/tracker.js: harden .toLowerCase()
  call sites against undefined/null receivers
2026-08-18 21:50:42 +03:00

200 lines
6.2 KiB
JavaScript

//js/cache.js
export class APICache {
constructor(options = {}) {
this.memoryCache = new Map();
this.maxSize = options.maxSize || 200;
this.ttl = options.ttl || 1000 * 60 * 30;
this.dbName = 'monochrome-cache';
this.dbVersion = 1;
this.db = null;
this.initDB().catch(console.error);
}
async initDB() {
if (typeof indexedDB === 'undefined') return;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
// If another tab upgrades or deletes the DB, the connection
// closes; drop our reference so the next operation re-opens
// instead of throwing InvalidStateError on a stale handle.
this.db.onversionchange = () => {
try {
this.db?.close();
} catch {}
this.db = null;
};
this.db.onclose = () => {
this.db = null;
};
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains('responses')) {
const store = db.createObjectStore('responses', { keyPath: 'key' });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
});
}
generateKey(type, params) {
const paramString = typeof params === 'object' ? JSON.stringify(params) : String(params);
return `${type}:${paramString}`;
}
async get(type, params) {
const key = this.generateKey(type, params);
if (this.memoryCache.has(key)) {
const cached = this.memoryCache.get(key);
if (Date.now() - cached.timestamp < this.ttl) {
return cached.data;
}
this.memoryCache.delete(key);
}
if (this.db) {
try {
const cached = await this.getFromIndexedDB(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
this.memoryCache.set(key, cached);
return cached.data;
}
} catch (error) {
console.log('IndexedDB read error:', error);
}
}
return null;
}
async set(type, params, data) {
const key = this.generateKey(type, params);
const entry = {
key,
data,
timestamp: Date.now(),
};
this.memoryCache.set(key, entry);
if (this.memoryCache.size > this.maxSize) {
const firstKey = this.memoryCache.keys().next().value;
this.memoryCache.delete(firstKey);
}
if (this.db) {
try {
await this.setInIndexedDB(entry);
} catch (error) {
console.log('IndexedDB write error:', error);
}
}
}
getFromIndexedDB(key) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve(null);
return;
}
const transaction = this.db.transaction(['responses'], 'readonly');
const store = transaction.objectStore('responses');
const request = store.get(key);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
setInIndexedDB(entry) {
return new Promise((resolve, reject) => {
if (!this.db) {
resolve();
return;
}
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.put(entry);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clear() {
this.memoryCache.clear();
if (this.db) {
try {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const request = store.clear();
await new Promise((resolve, reject) => {
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
} catch (error) {
this.db = null;
console.log('Failed to clear IndexedDB cache:', error);
}
}
}
async clearExpired() {
const now = Date.now();
const expired = [];
for (const [key, entry] of this.memoryCache.entries()) {
if (now - entry.timestamp >= this.ttl) {
expired.push(key);
}
}
expired.forEach((key) => this.memoryCache.delete(key));
if (this.db) {
try {
const transaction = this.db.transaction(['responses'], 'readwrite');
const store = transaction.objectStore('responses');
const index = store.index('timestamp');
const range = IDBKeyRange.upperBound(now - this.ttl);
const request = index.openCursor(range);
request.onsuccess = (event) => {
try {
const cursor = event.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
} catch (error) {
console.log('Failed to clear expired IndexedDB entry:', error);
}
};
} catch (error) {
console.log('Failed to clear expired IndexedDB entries:', error);
}
}
}
getCacheStats() {
return {
memoryEntries: this.memoryCache.size,
maxSize: this.maxSize,
ttl: this.ttl,
};
}
}