6 Commits
4 changed files with 134 additions and 49 deletions
+36 -29
View File
@@ -22,52 +22,59 @@ const lookup = {
tracks: 'tracks' tracks: 'tracks'
}; };
// Retrieve existing IDs from Stream const existingRow = db.prepare('SELECT * FROM Stream WHERE id = ?');
const existingIds = db
.prepare('SELECT id FROM Stream')
.all()
.map((row) => row.id);
// Create a set of existing IDs for efficient lookup
const idSet = new Set(existingIds);
// Check if --overwrite flag is set // Overwrite differing streams without asking
const shouldOverwrite = process.argv.includes('--overwrite'); const shouldOverwrite = process.argv.includes('--overwrite');
// Process JSON files and insert data into Stream // Process JSON files and insert data into Stream
fs.readdir(jsonFolder, (err, files) => { const files = fs.readdirSync(jsonFolder);
if (err) throw err;
console.log(`Found ${files.length} JSON file(s) in ${jsonFolder}.`); console.log(`Found ${files.length} JSON file(s) in ${jsonFolder}.`);
files.forEach((file) => { for (const file of files) {
if (!file.endsWith('.json')) return; if (!file.endsWith('.json')) continue;
if (file.endsWith('.sideload.json')) return; if (file.endsWith('.sideload.json')) continue;
const jsonString = fs.readFileSync(path.join(jsonFolder, file), 'utf8'); const jsonString = fs.readFileSync(path.join(jsonFolder, file), 'utf8');
let jsonData = JSON.parse(jsonString); let jsonData = JSON.parse(jsonString);
const sideloadPath = path.join(jsonFolder, file.slice(0, -5) + '.sideload.json'); const sideloadPath = path.join(jsonFolder, file.slice(0, -5) + '.sideload.json');
if (fs.existsSync(sideloadPath, 'utf8')) { if (fs.existsSync(sideloadPath)) {
const sideloadData = JSON.parse(fs.readFileSync(sideloadPath)); const sideloadData = JSON.parse(fs.readFileSync(sideloadPath));
jsonData = { ...jsonData, ...sideloadData }; jsonData = { ...jsonData, ...sideloadData };
} }
// Skip if ID already exists in Stream
if (idSet.has(jsonData.id)) { // A key with no column would otherwise reach the query as `undefined`
if (!shouldOverwrite) { const unknown = Object.keys(jsonData).filter((key) => !(key in lookup));
console.log(`Skipped data for ID ${jsonData.id} (already exists).`); if (unknown.length > 0)
return; console.log(`Ignored unknown key(s) in ${file}: ${unknown.join(', ')}.`);
}
console.log(`Overwriting data for ID ${jsonData.id}.`);
}
// Prepare attributes for insertion // Prepare attributes for insertion
const values = Object.keys(jsonData).map( const fields = Object.entries(jsonData)
.filter(([key]) => key in lookup)
// Serialize value if array // Serialize value if array
(key) => (Array.isArray(jsonData[key]) ? JSON.stringify(jsonData[key]) : jsonData[key]) .map(([key, value]) => [lookup[key], Array.isArray(value) ? JSON.stringify(value) : value]);
);
const columns = Object.keys(jsonData).map((key) => lookup[key]);
const row = existingRow.get(jsonData.id);
if (row) {
const changed = fields
.filter(([column, value]) => String(row[column] ?? '') !== String(value ?? ''))
.map(([column]) => column);
if (changed.length === 0) {
console.log(`Unchanged data for ID ${jsonData.id}.`);
continue;
}
console.log(`ID ${jsonData.id} differs from the database in: ${changed.join(', ')}.`);
if (!shouldOverwrite && !confirm(`Overwrite ID ${jsonData.id}?`)) {
console.log(`Kept database version of ID ${jsonData.id}.`);
continue;
}
}
const columns = fields.map(([column]) => column);
const sql = `INSERT OR REPLACE INTO Stream (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`; const sql = `INSERT OR REPLACE INTO Stream (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
db.prepare(sql).run(...values); db.prepare(sql).run(...fields.map(([, value]) => value));
console.log(`Inserted data for ID ${jsonData.id}.`); console.log(`Inserted data for ID ${jsonData.id}.`);
}); }
});
+58 -8
View File
@@ -17,6 +17,15 @@ const CHUNK_SIZE = 4 * 1024 * 1024;
// as the intent is to catch socket breakage during cellular network changes for example // as the intent is to catch socket breakage during cellular network changes for example
const CHUNK_TIMEOUT = 60_000; const CHUNK_TIMEOUT = 60_000;
class QuotaError extends Error {
constructor(readonly missing: number) {
super();
}
}
// total bytes used by our OPFS origin
export const usage = $state<{ bytes?: number }>({});
export const cached = new SvelteSet<string>(readStored<string[]>(STORAGE_KEY, [])); export const cached = new SvelteSet<string>(readStored<string[]>(STORAGE_KEY, []));
export const downloading = new SvelteMap<string, number>(); export const downloading = new SvelteMap<string, number>();
@@ -26,6 +35,7 @@ export const partials = new SvelteMap<string, PartialDownload>(
); );
if (browser) { if (browser) {
refreshUsage();
$effect.root(() => { $effect.root(() => {
$effect(() => { $effect(() => {
writeStored(STORAGE_KEY, [...cached]); writeStored(STORAGE_KEY, [...cached]);
@@ -51,7 +61,14 @@ export async function download(stream: StreamSummary) {
partials.delete(stream.id); partials.delete(stream.id);
cached.add(stream.id); cached.add(stream.id);
return; return;
} catch { } catch (err) {
if (err instanceof QuotaError) {
alert(
`not enough disk space ;_;, need ${Math.ceil(err.missing / 1_000_000)} MB more.`
);
return;
}
console.error(`[streamCache] ${stream.id} attempt failed`, err);
// reset attempts if download of at least one chunk successful // reset attempts if download of at least one chunk successful
attempt = (partials.get(stream.id)?.received ?? 0) > before ? 0 : attempt + 1; attempt = (partials.get(stream.id)?.received ?? 0) > before ? 0 : attempt + 1;
if (attempt === MAX_ATTEMPTS) return; if (attempt === MAX_ATTEMPTS) return;
@@ -66,8 +83,6 @@ export async function download(stream: StreamSummary) {
export async function remove(streamId: string) { export async function remove(streamId: string) {
cached.delete(streamId); cached.delete(streamId);
await discard(streamId); await discard(streamId);
const root = await navigator.storage.getDirectory();
await root.removeEntry(streamId).catch(() => {});
} }
export async function getUrl(streamId: string): Promise<string | null> { export async function getUrl(streamId: string): Promise<string | null> {
@@ -85,12 +100,31 @@ export async function getUrl(streamId: string): Promise<string | null> {
async function attemptDownload(stream: StreamSummary) { async function attemptDownload(stream: StreamSummary) {
let state = partials.get(stream.id); let state = partials.get(stream.id);
let checkedSpace = false;
while (!state || state.received < state.total) { while (!state || state.received < state.total) {
state = await fetchChunk(stream, state); state = await fetchChunk(stream, state);
partials.set(stream.id, state); partials.set(stream.id, state);
downloading.set(stream.id, percent(state.received, state.total)); downloading.set(stream.id, percent(state.received, state.total));
// first chunk's HTTP response carries the total stream size
if (!checkedSpace) {
await checkSpace(stream.id, state);
checkedSpace = true;
} }
}
console.log(`[streamCache] ${stream.id} assembling ${state.total} bytes`);
await assemble(stream.id, state.total); await assemble(stream.id, state.total);
console.log(`[streamCache] ${stream.id} assembled`);
}
/** check available space below what's needed to download the stream */
async function checkSpace(streamId: string, state: PartialDownload) {
const { usage: used, quota } = await navigator.storage.estimate();
if (used === undefined || quota === undefined) return;
// extra CHUNK_SIZE is due to the delta in `assemble()` merging one chunk in (+1) then deleting it
const peak = used + (state.total - state.received) + CHUNK_SIZE;
if (peak > quota) throw new QuotaError(peak - quota);
} }
/** fetch chunk starting from the last valid one, store in OPFS, and return updated state */ /** fetch chunk starting from the last valid one, store in OPFS, and return updated state */
@@ -137,21 +171,29 @@ async function assemble(streamId: string, total: number) {
const root = await navigator.storage.getDirectory(); const root = await navigator.storage.getDirectory();
const dir = await partsDir(streamId, true); const dir = await partsDir(streamId, true);
const handle = await root.getFileHandle(streamId, { create: true }); const handle = await root.getFileHandle(streamId, { create: true });
const writable = await handle.createWritable();
// track size from previous assembling attempt if there was one
const done = (await handle.getFile()).size;
const writable = await handle.createWritable({ keepExistingData: done > 0 });
await writable.seek(done);
// check at each step the chunk names (ranges) are coherent // check at each step the chunk names (ranges) are coherent
let written = 0; let written = done;
while (written < total) { while (written < total) {
const part = await dir.getFileHandle(String(written)).catch(() => null); const name = String(written);
const part = await dir.getFileHandle(name).catch(() => null);
if (!part) break; if (!part) break;
const file = await part.getFile(); const file = await part.getFile();
if (!file.size) break; if (!file.size) break;
await writable.write(file); await writable.write(file);
written += file.size; written += file.size;
await dir.removeEntry(name).catch(() => {});
} }
await writable.close(); await writable.close();
await discard(streamId); partials.delete(streamId);
await sweep(streamId);
// chunks went missing, shit's fucked... // chunks went missing, shit's fucked...
if (written !== total) throw new Error(`Assembled ${written} of ${total} bytes`); if (written !== total) throw new Error(`Assembled ${written} of ${total} bytes`);
} }
@@ -163,9 +205,11 @@ async function partsDir(streamId: string, create = false) {
return parts.getDirectoryHandle(streamId, { create }); return parts.getDirectoryHandle(streamId, { create });
} }
/** discard stream's partial download state and run a sweep of leftover parts files */ /** discard everything on disk for a stream that is not fully downloaded */
async function discard(streamId: string) { async function discard(streamId: string) {
partials.delete(streamId); partials.delete(streamId);
const root = await navigator.storage.getDirectory();
await root.removeEntry(streamId).catch(() => {});
await sweep(streamId); await sweep(streamId);
} }
@@ -183,6 +227,12 @@ async function sweep(force?: string) {
orphans.push(name); orphans.push(name);
} }
for (const name of orphans) await parts.removeEntry(name, { recursive: true }).catch(() => {}); for (const name of orphans) await parts.removeEntry(name, { recursive: true }).catch(() => {});
await refreshUsage();
}
async function refreshUsage() {
usage.bytes = (await navigator.storage.estimate()).usage;
} }
/** exponential backoff, cut short if the connection comes back before it elapses */ /** exponential backoff, cut short if the connection comes back before it elapses */
+22 -1
View File
@@ -15,6 +15,7 @@
let listOpen = $state(); let listOpen = $state();
let displayedStreams = $derived.by(streamsToDisplay); let displayedStreams = $derived.by(streamsToDisplay);
let remainingTags = $derived.by(getRemainingTags); let remainingTags = $derived.by(getRemainingTags);
let cacheUsage = $derived(streamCache.usage.bytes);
$effect(() => { $effect(() => {
if (displayedStreams.length == 1) { if (displayedStreams.length == 1) {
@@ -68,11 +69,12 @@
{#each ctx.streams as stream} {#each ctx.streams as stream}
{@const favorited = favoritedStreams.has(stream.id)} {@const favorited = favoritedStreams.has(stream.id)}
{@const isCached = streamCache.cached.has(stream.id)} {@const isCached = streamCache.cached.has(stream.id)}
{@const isPartial = streamCache.partials.has(stream.id)}
{@const current = ctx.current?.id === stream.id} {@const current = ctx.current?.id === stream.id}
<li <li
hidden={!displayedStreams.includes(stream) || hidden={!displayedStreams.includes(stream) ||
(favoritesOnly && !favorited) || (favoritesOnly && !favorited) ||
(cachedOnly && !isCached)} (cachedOnly && !(isCached || isPartial))}
class="stream-item {current ? 'current-stream' : ''}" class="stream-item {current ? 'current-stream' : ''}"
id="stream-{stream.id}" id="stream-{stream.id}"
> >
@@ -131,6 +133,18 @@
{/each} {/each}
</ul> </ul>
{#if cachedOnly && (streamCache.cached.size || streamCache.partials.size)}
<p class="cache-usage">
{#if cacheUsage !== undefined}
{cacheUsage > 1_000_000_000
? (cacheUsage / 1_000_000_000).toFixed(1) + ' GB'
: Math.round(cacheUsage / 1_000_000) + ' MB'} of cached streams
{:else}
Calculating…
{/if}
</p>
{/if}
<style> <style>
.stream-list { .stream-list {
list-style-type: none; list-style-type: none;
@@ -192,6 +206,13 @@
margin-bottom: 1px; margin-bottom: 1px;
} }
.cache-usage {
margin: 0;
padding: 4px 5px;
font-family: Tahoma;
font-size: smaller;
}
.material-icons { .material-icons {
margin-bottom: 0px; margin-bottom: 0px;
color: black; color: black;
+8 -1
View File
@@ -11,8 +11,15 @@
</div> </div>
<div class="description-bubble"> <div class="description-bubble">
<p>Hello, there, welcome to V1.6.</p> <p>Hello, there, welcome to V1.7.</p>
<p> <p>
<u>2026-09-06 update:</u> caching behavior has been greatly improved when trying to download
streams in poor network conditions.<br />
You can now also resume interrupted downloads and see how much disk space is being used when the
downloads-only filter is enabled.<br />
I've done a bit of refactoring to the codebase, so don't hesitate to message me if you're having
any weird issues!<br /><br />
<u>2026-03-16 update:</u> you can now cache streams offline for playback in low-connectivity <u>2026-03-16 update:</u> you can now cache streams offline for playback in low-connectivity
environments (and to prevent buffering)!<br /> environments (and to prevent buffering)!<br />
Simply click on the download icon in the stream list (and click again to remove them from cache). Simply click on the download icon in the stream list (and click again to remove them from cache).