Skip to content

Commit

Permalink
Merge pull request #64 from chrisbendel/request-retries
Browse files Browse the repository at this point in the history
Request retries
  • Loading branch information
chrisbendel authored Apr 26, 2024
2 parents 52f20d6 + 469db1e commit d7c08a3
Show file tree
Hide file tree
Showing 10 changed files with 332 additions and 297 deletions.
9 changes: 1 addition & 8 deletions manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,7 @@ const manifest: chrome.runtime.ManifestV3 = {
],
web_accessible_resources: [
{
resources: [
"assets/js/*.js",
"assets/css/*.css",
// TODO Needed?
// "stealie-16.png",
// "stealie-48.png",
"stealie-128.png",
],
resources: ["assets/js/*.js", "assets/css/*.css", "stealie-128.png"],
matches: ["*://*/*"],
},
],
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@
},
"type": "module",
"dependencies": {
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
"react": "18.2.0",
"react-dom": "18.2.0",
"streamsaver": "^2.0.6"
"react-dom": "18.2.0"
},
"devDependencies": {
"@rollup/plugin-typescript": "^8.5.0",
Expand Down
Binary file modified public/stealie-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed public/stealie-16.png
Binary file not shown.
Binary file removed public/stealie-48.png
Binary file not shown.
276 changes: 231 additions & 45 deletions src/pages/content/components/app.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { FC, useEffect, useState } from "react";
import {
ArchiveFile,
MP3Url,
ArchiveShow,
Track,
} from "@pages/content/models/interfaces";
import streamSaver from "streamsaver";
import pickBy from "lodash-es/pickBy";
import findKey from "lodash-es/findKey";
import ZIP from "@pages/content/utils/zip-stream.js";
import JSZip from "jszip";

const getMP3Urls = (show: ArchiveShow) => {
const getTracks = (show: ArchiveShow): Track[] => {
const mp3Files = pickBy(show.files, function (file: ArchiveFile) {
return file.format === "VBR MP3";
});
Expand All @@ -19,7 +18,7 @@ const getMP3Urls = (show: ArchiveShow) => {
const url = baseURL + key;
let title = data.title || data.original;
title = title.replace(/-|[^-_,A-Za-z0-9 ]+/g, "").trim();
return { title: index + 1 + ". " + title, track: title, url: url };
return { title: index + 1 + ". " + title, url: url } as Track;
});
};

Expand All @@ -32,40 +31,52 @@ const getInfoFileUrl = (show: ArchiveShow) => {
return `${baseURL}/${infoFile}`;
};

// TODO Allow option for selecting show format OR
// Prompt user for download title? window.prompt?
const getShowTitle = (show: ArchiveShow) => {
return show.metadata.date[0];
return prompt(
`Custom folder title? Default ${show.metadata.date[0]}`,
show.metadata.date[0]
);
};

const createZip = async (show: ArchiveShow) => {
const fileStream = streamSaver.createWriteStream(`${getShowTitle(show)}.zip`);
const mp3s = getMP3Urls(show);

const readableZipStream = new ZIP({
async pull(ctrl) {
// Gets executed everytime zip.js asks for more data
const infoFile = await fetch(getInfoFileUrl(show));
ctrl.enqueue({
name: "info.txt",
stream: () => infoFile.body,
});
async function fetchWithRedirect(url: string) {
const response = await fetch(url, { redirect: "follow" });
if (response.status === 302) {
const redirectUrl = response.headers.get("Location");
if (!redirectUrl) {
throw new Error("Redirect URL not found");
}
return fetch(redirectUrl); // Fetch the redirect URL
} else if (response.ok) {
return response;
} else {
throw new Error("Network response was not ok");
}
}

// TODO Implement range fetching: https://www.zeng.dev/post/2023-http-range-and-play-mp4-in-browser/
await Promise.all(
mp3s.map(async (mp3) => {
const res = await fetch(mp3.url);
const stream = () => res.body;
ctrl.enqueue({ name: `${mp3.title}.mp3`, stream });
})
async function fetchWithRetry(url: string, maxRetries = 3): Promise<Response> {
let retries = 0;
while (true) {
try {
const response = await fetchWithRedirect(url);
if (response.ok) {
return response;
} else {
throw new Error(`Failed to fetch: ${response.statusText}`);
}
} catch (error) {
retries++;
if (retries >= maxRetries) {
throw error; // If max retries exceeded, propagate the error
}
console.log(
`Error occurred, retrying (${retries}/${maxRetries}):`,
error
);

ctrl.close();
},
});

return readableZipStream.pipeTo(fileStream);
};
// Wait for a short duration before retrying (you can adjust the duration as needed)
await new Promise((resolve) => setTimeout(resolve, 1000 * retries));
}
}
}

export default function App() {
const [archiveShow, setArchiveShow] = useState<ArchiveShow>();
Expand Down Expand Up @@ -99,32 +110,207 @@ export default function App() {
>
<h3>Grateful Grabber</h3>
<DownloadButton show={archiveShow} />
<DownloadIndividualSong show={archiveShow} />
</div>
</div>
</div>
</div>
);
}

const DownloadIndividualSong: FC<{ show: ArchiveShow }> = ({ show }) => {
const [loadingTracks, setLoadingTracks] = useState<string[]>([]);
const tracks = getTracks(show);

const onDownload = async (event) => {
const selectedOption = event.target.selectedOptions[0];
const title = selectedOption.text;
const url = selectedOption.value;
setLoadingTracks((prevState) => [...prevState, title]);
await downloadFile(url, title, ".mp3");
setLoadingTracks(loadingTracks.filter((t) => t == title));
};

return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
alignItems: "center",
}}
>
<select style={{ fontSize: ".75em" }} onChange={onDownload}>
<option>Download Individually</option>
{tracks.map((track) => {
return (
<option
key={track.title}
onSelect={() => onDownload(track)}
value={track.url}
>
{track.title}
</option>
);
})}
</select>

{loadingTracks.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
fontSize: ".75em",
}}
>
<p>Download queue</p>
{loadingTracks.map((title) => (
<span key={title}>{title}</span>
))}
</div>
) : null}
</div>
);
};

const DownloadButton: FC<{ show: ArchiveShow }> = ({ show }) => {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState(null);

const downloadShow = async (archiveShow: ArchiveShow) => {
setError(null);

const showTitle = getShowTitle(archiveShow);
if (!showTitle) return;

setLoading(true);
await createZip(archiveShow);

await createZip(archiveShow, setProgress)
.then((blob) => downloadZip(showTitle, blob, setProgress))
.catch((error) => {
setError(error.toString());
setLoading(false);
console.error("Error:", error);
});

setLoading(false);
setProgress(0);
};

return (
<button
onClick={() => downloadShow(show)}
style={{
borderRadius: "1rem",
fontSize: "2rem",
}}
disabled={loading}
>
{loading ? "Downloading... Please be patient :)" : "Download Show"}
</button>
<>
<button
onClick={() => downloadShow(show)}
style={{
borderRadius: "1rem",
fontSize: "2rem",
}}
disabled={loading}
>
{loading ? "Downloading... Please be patient :)" : "Download Show"}
</button>
{progress ? <progress value={progress}> </progress> : null}
{error && (
<span
style={{
fontSize: ".75em",
}}
>
Something went wrong, restart the download, it should pick up where
you left off :)
</span>
)}
</>
);
};

async function downloadFile(url: string, fileName: string, extension = ".mp3") {
const response = await fetchWithRetry(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}`);
}

const blob = await response.blob();

const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${fileName}${extension}`;

a.click();

URL.revokeObjectURL(a.href);
}

async function getFileBlob(
url: string,
onProgress?: (progress: number) => void
): Promise<Blob> {
const response = await fetchWithRetry(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}`);
}

const contentLength = parseInt(
response.headers.get("Content-Length") || "0",
10
);
let receivedLength = 0;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];

while (true) {
const { done, value } = await reader.read();

if (done) {
break;
}

chunks.push(value);
receivedLength += value.length;
const progress = receivedLength / contentLength;
onProgress?.(progress);
}

return new Blob(chunks);
}

async function createZip(
show: ArchiveShow,
onProgress: (progress: number) => void
): Promise<Blob> {
const zip = new JSZip();
let completedCount = 0;
const mp3Urls = getTracks(show);
const infoFile = getInfoFileUrl(show);

const infoBlob = await getFileBlob(infoFile);
zip.file("info.txt", infoBlob);

for (const url of mp3Urls) {
const mp3Blob = await getFileBlob(url.url, (progress) => {
const overallProgress = (completedCount + progress) / mp3Urls.length;
onProgress(overallProgress);
});

zip.file(`${url.title}.mp3`, mp3Blob);
completedCount++;
onProgress(completedCount / mp3Urls.length);
}

return await zip.generateAsync({ type: "blob" });
}

function downloadZip(
folderName: string,
blob: Blob,
onProgress: (progress: number) => void
) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${folderName}.zip`;
a.click();
onProgress(1); // Mark progress as completed
}
3 changes: 1 addition & 2 deletions src/pages/content/models/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ export interface Metadata {
date: string[];
}

export interface MP3Url {
export interface Track {
title: string;
track: string;
url: string;
}
14 changes: 14 additions & 0 deletions src/pages/content/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,17 @@
.content-view {
font-size: 30px;
}

.loader {
border: 16px solid #f3f3f3; /* Light grey */
border-top: 16px solid #3498db; /* Blue */
border-radius: 50%;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
Loading

0 comments on commit d7c08a3

Please sign in to comment.