Compare commits
6
Commits
65fc04a4d8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f1a1aa73 | ||
|
|
3c42834d47 | ||
|
|
343447c37c | ||
|
|
0ed1a094a2 | ||
|
|
828c0a1d7b | ||
|
|
771e81f50b |
+37
-30
@@ -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}.`);
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user