From 3806e64be990d77043084ef17592642592fcaf51 Mon Sep 17 00:00:00 2001 From: Dinoosauro <80783030+Dinoosauro@users.noreply.github.com> Date: Thu, 26 Dec 2024 14:34:27 +0100 Subject: [PATCH] TrustedHTML script, extension UI changes & improvements in File System API - The console script now works also on pages with TrustedHTML (every usage of innerHTML has been removed) - The console script now supports using the File System API - When using the File System API, the already-cached files will be written to the disk and, if possible, the next files. This is done to avoid a SecurityException that might be triggered if a source is created without user action - When choosing a folder using the File System API, the script will automatically create the new file and write the cached chunks to the file system - Now, only one file at a time will be created on the user's drive. This should help fixing some bugs (even if probably it wasn't related, but better safe than sorry). If more than one file needs to be created, they'll be created later - Before creating the file on the system, the title must be marked as final. This helps ensuring the file has a readable name there - It's now possible to disable the download of the content when the video ends, or the automatic closure of the stream while using the File System API - The content title is now shown also in the extension UI - Bumped version to 1.1.0 --- .gitignore | 1 + GenerateConsoleScript.cjs | 71 +++++++++++++++----- background.js | 4 ++ manifest.json | 2 +- script.js | 136 ++++++++++++++++++++++++++++++-------- ui/comms.js | 37 +++++++++-- ui/style.css | 32 ++++++++- ui/ui.html | 18 ++++- 8 files changed, 249 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index edf4625..a043bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ package-lock.json Output-Chromium.zip Output-Firefox.zip Output-Firefox +Output-Chromium .DS_Store ConsoleScript-UI.js ConsoleScript-Console.js diff --git a/GenerateConsoleScript.cjs b/GenerateConsoleScript.cjs index 708f5c3..61ee82b 100644 --- a/GenerateConsoleScript.cjs +++ b/GenerateConsoleScript.cjs @@ -28,21 +28,37 @@ function addDownloader() { const listContainer = Object.assign(document.createElement("div"), { style: "position: fixed; top: 55px; right: 15px; max-width: 45vw; padding: 10px; max-height: 70vh; border-radius: 8px; background-color: #151515; display: none; z-index: 99999999; overflow: scroll" }); + /** + * The directory where the files of the current page will be created. + * This is saved also on the script since it's used when writing a single file in the FS. + * @type FileSystemDirectoryHandle + */ + let fsPicker = undefined; /** * The button that shows or hides the list */ const downloadSwitch = Object.assign(document.createElement("div"), { style: "position: fixed; top: 15px; right: 15px; display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: 8px; background-color: #151515; z-index: 99999999", onclick: () => { - listContainer.innerHTML = ""; + for (const child of listContainer.children) child.remove(); listContainer.style.display = listContainer.style.display === "none" ? "block" : "none"; if (listContainer.style.display === "none") return; // We'll now create the instructions to download the video listContainer.append(Object.assign(document.createElement("p"), { - textContent: "Click on a name to read it as a Blob. Click again to download it (you have five seconds to click it again before it's deleted).", + textContent: "Click on a name to read it as a Blob. Click again to download it (you have five seconds to click it again before it's deleted), or, if you've enabled the File System API (entries in italic), to close the stream.", style: globalStyles.text }), document.createElement("br")); - for (const item of $ActionHandler({ action: "getDownloads" }).content) { + // If possible, we'll also create the button to use the File System API + (typeof window.showDirectoryPicker === "function") && listContainer.append(Object.assign(document.createElement("button"), { + textContent: "Write the current (and if possible the next) files in a folder (FS API)", + style: `width: fit-content; ${globalStyles.button}`, + onclick: async () => { + const picker = await window.showDirectoryPicker({ id: "MediaCachePicker", mode: "readwrite" }); + fsPicker = picker; + $ActionHandler({ action: "fileSystem", content: picker }); + } + }), document.createElement("br"), document.createElement("br")); + for (const item of $ActionHandler({ action: "getDownloads", everything: true }).content) { /** * The container for the link and the delete button */ @@ -56,9 +72,15 @@ function addDownloader() { */ const link = Object.assign(document.createElement("label"), { textContent: `${item.title} [${item.mimeType}]`, - style: globalStyles.text + style: `${globalStyles.text} ${item.writable ? "font-style: italic" : ""}` }); link.onclick = () => { + if (item.writable) { // The File System API is being used, so we'll close the stream + $ActionHandler({ action: "fsFinalize", content: item.id }); + div.remove(); + return; + } + // We'll create a new link so that the file can be downloaded const newLink = Object.assign(document.createElement("a"), { textContent: `${item.title} [${item.mimeType}]`, download: item.title, @@ -68,15 +90,29 @@ function addDownloader() { setTimeout(() => { URL.revokeObjectURL(newLink.href); }, 5000); newLink.click(); } - const button = Object.assign(document.createElement("button"), { - textContent: "Delete", - style: `width: fit-content; ${globalStyles.button}`, - onclick: () => { - $ActionHandler({ action: "deleteThis", content: { id: item.id } }); - div.remove(); - } - }); - div.append(link, button); + div.append(link); + if (!item.writable) { // Delete the file + const button = Object.assign(document.createElement("button"), { + textContent: "Delete", + style: `width: fit-content; ${globalStyles.button}`, + onclick: () => { + $ActionHandler({ action: "deleteThis", content: { id: item.id } }); + div.remove(); + } + }); + div.append(button); + } + if (fsPicker && !item.writable) { // It's possible to write the file into the File System, but user authorization is required (since there's no writable – it needs to be created. The script automatically tries to create a new writable, but sometimes it fails due to the lack of user interaction). So, we'll display a button to force writing it. + const button = Object.assign(document.createElement("button"), { + textContent: "Write on FS", + style: `width: fit-content; ${globalStyles.button}`, + onclick: async () => { + const file = await fsPicker.getFileHandle(item.title, { create: true }); + $ActionHandler({ action: "fileSystemSingleOperation", content: { file, handle: fsPicker, id: item.id } }); + } + }); + div.append(button); + } listContainer.append(div); } /** @@ -95,10 +131,13 @@ function addDownloader() { }); listContainer.append(document.createElement("br"), hideEverything); }, - innerHTML: `` }); + const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + for (const item of [["width", "20"], ["height", "20"], ["viewBox", "0 0 20 20"], ["fill", "none"], ["xmlns", "http://www.w3.org/2000/svg"]]) svg.setAttribute(item[0], item[1]); + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + for (const item of [["d", "M15.5 16.9997C15.7761 16.9997 16 17.2236 16 17.4997C16 17.7452 15.8231 17.9494 15.5899 17.9917L15.5 17.9997H4.5C4.22386 17.9997 4 17.7759 4 17.4997C4 17.2543 4.17688 17.0501 4.41012 17.0078L4.5 16.9997H15.5ZM10.0001 2.00195C10.2456 2.00195 10.4497 2.17896 10.492 2.41222L10.5 2.5021L10.496 14.296L14.1414 10.6476C14.3148 10.4739 14.5842 10.4544 14.7792 10.5892L14.8485 10.647C15.0222 10.8204 15.0418 11.0898 14.907 11.2848L14.8492 11.3541L10.3574 15.8541C10.285 15.9267 10.1957 15.9724 10.1021 15.9911L9.99608 16.0008C9.83511 16.0008 9.69192 15.9247 9.60051 15.8065L5.14386 11.3547C4.94846 11.1595 4.94823 10.8429 5.14336 10.6475C5.3168 10.4739 5.58621 10.4544 5.78117 10.5892L5.85046 10.647L9.496 14.288L9.5 2.50181C9.50008 2.22567 9.724 2.00195 10.0001 2.00195Z"], ["fill", "#fafafa"]]) path.setAttribute(item[0], item[1]); + svg.append(path); + downloadSwitch.append(svg); downloadSwitch.style.display = document.fullscreenElement ? "none" : "flex"; document.addEventListener("fullscreenchange", () => { // If there's something in full screen, hide the download button downloadSwitch.style.display = document.fullscreenElement ? "none" : "flex"; diff --git a/background.js b/background.js index f42bae4..5453886 100644 --- a/background.js +++ b/background.js @@ -72,6 +72,10 @@ files: ['script.js'], world: "MAIN" }); + browserToUse.tabs.sendMessage(ids[0].id, { // Update user preferences + action: "updateChoices", + content: await browserToUse.storage.sync.get(["finalize_fs_stream_when_video_finishes", "delete_entries_when_video_finishes", "download_content_when_video_finishes"]) + }); await getPromise(); // Check again resolve(); } diff --git a/manifest.json b/manifest.json index 3de9a7c..adfa3c5 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "MediaCache", "description": "Cache the video/audio content displayed by various websites, and download it", - "version": "1.0.2", + "version": "1.1.0", "action": { "default_popup": "./ui/ui.html" }, diff --git a/script.js b/script.js index 0d4b781..b6962cb 100644 --- a/script.js +++ b/script.js @@ -1,4 +1,14 @@ (async () => { + + /** + * Includes some flags that can be enabled/disabled either from the code or from the extension UI. These might not always work. + */ + const CUSTOM_BEHAVIOR = { + finalize_fs_stream_when_video_finishes: true, + delete_entries_when_video_finishes: false, + download_content_when_video_finishes: true + } + /** * Get the suggested title for the file. * NOTE: These are only examples from two popular streaming sites. Before downloading anything from them, ensure you've authorization from the channel owner, and download them only in the cases provided for their Terms of Service. @@ -18,10 +28,48 @@ } let arr = []; /** - * The directory where the files of the current page will be opened - * @type FileSystemDirectoryHandle - */ + * The directory where the files of the current page will be opened + * @type FileSystemDirectoryHandle + */ let picker = undefined; + + /** + * Write the already-cached ArrayBuffers to a FileSystemWritable. The writable will be linked with the ID, so that further caching wil be directly done on the FS. + * @param {string} id the identifier of the resource to write + * @param {FileSystemWritableFileStream} writable where the binary data should be written + */ + async function fsWriteOperation(id, writable, handle) { + let position = 0; + const currentItem = arr.find(item => item.id === id); + while (currentItem.data.length !== 0) { + const data = currentItem.data[0]; + await writable.write({ data, position, type: "write" }); + position += data.byteLength; + currentItem.data.splice(0, 1); + } + currentItem.writable = writable; // And save the writable in the object, so that future data will be written there + currentItem.currentWrite = position; // Save in the "currentWrite" key the position where further buffers should be written + currentItem.file = handle; // Add the FileSystemFileHandle in the Object so that it can be moved (if the browser supports so) + } + /** + * If a File is being created in the user's file system + */ + let isFileHandleInCreation = false; + /** + * + * @param {string} name + * @returns + */ + async function intelligentFileHandle(name) { + if (isFileHandleInCreation) { + await new Promise((res) => setTimeout(res, 50)); + return await intelligentFileHandle(name); + } + isFileHandleInCreation = true; + const file = await picker.getFileHandle(name, { create: true }); + isFileHandleInCreation = false; + return file; + } /** * Edit the MediaSource prototype. Basically, make this script work. */ @@ -33,35 +81,44 @@ * @type SourceBuffer */ const sourceBuffer = originalAddSourceBuffer.call(this, mimeType); + /** + * If the provided title is final, so no further edits will be made + */ + let finalTitle = false; /** * Get the suggested title for the item * @param id the ID of the item that should be added + * @param timeout make sure this is 0. The script will automatically increase it before stopping looking for changes in the webpage (if it can't find any special filename) */ - function addTitle(id) { + function addTitle(id, timeout) { const currentItem = arr.find(item => item.id === id); if (!currentItem) return; const [suggestedTitle, result] = getSuggestedTitle(); currentItem.title = (`${suggestedTitle} [${mimeType.substring(0, mimeType.indexOf("/"))} ${id}].${mimeType.substring(mimeType.indexOf("/") + 1, mimeType.indexOf(";", mimeType.indexOf("/")))}`).replaceAll("<", "‹").replaceAll(">", "›").replaceAll(":", "∶").replaceAll("\"", "″").replaceAll("/", "∕").replaceAll("\\", "∖").replaceAll("|", "¦").replaceAll("?", "¿").replaceAll("*", ""); - (document.readyState !== "complete" || !result) && setTimeout(() => addTitle(id), 1500); // We'll try again when the page has been loaded + if ((document.readyState !== "complete" || !result) && timeout < 4) { + setTimeout(() => addTitle(id, timeout + 1), 1500); // We'll try again when the page has been loaded + finalTitle = false; + } else finalTitle = true; } const id = crypto.randomUUID() ?? `${Math.random()}-${mimeType}-${Date.now()}`; arr[arr.length] = { mimeType, data: [], title: document.title, id }; - setTimeout(() => addTitle(id), 1500); // Let's wait a little bit so that the title on the page can be updated + setTimeout(() => addTitle(id, 0), 1500); if (picker !== undefined) { - picker.getFileHandle(arr.title, { create: true }).then((handle) => { - handle.createWritable().then(async (writable) => { // Write the previously-fetched data on the file, and delete it. - let position = 0; - const currentItem = arr.find(item => item.id === id); - while (currentItem.data.length !== 0) { - const data = currentItem.data[0]; - await writable.write({ data, position, type: "write" }); - position += data.byteLength; - currentItem.data.splice(0, 1); + setTimeout(() => { + async function nextStep() { + if (!finalTitle) { // We'll wait that the title of the file is final before writing it to the FS. + await new Promise((res) => setTimeout(res, 1750)); + return await nextStep(); } - currentItem.currentWrite = position; // Save in the "currentWrite" key the position where further buffers should be written - currentItem.writable = writable; // And save the writable in the object, so that future data will be written there - }) - }) + intelligentFileHandle(arr.find(entry => entry.id === id).title).then((handle) => { + handle.createWritable().then(async (writable) => { // Write the previously-fetched data on the file, and delete it. + await fsWriteOperation(id, writable, handle); + }).catch((ex) => console.warn(ex)); + + }).catch((ex) => console.warn(ex)); // If it wasn't possible to create the file, we won't do anything. + } + nextStep(); + }, 1600) // We'll wait 1750ms so that there's a possibility of having the new title. } const originalAppend = sourceBuffer.appendBuffer; sourceBuffer.appendBuffer = function (data) { @@ -98,10 +155,12 @@ * Download every ArrayBuffer stored */ function startDownload() { - for (let i = 0; i < arr.length; i++) { - singleDownload(arr[i].id); - arr[i]?.writable?.close(); + const length = arr.length; + for (let i = 0; i < length; i++) { + CUSTOM_BEHAVIOR.download_content_when_video_finishes && singleDownload(arr[i].id); + CUSTOM_BEHAVIOR.finalize_fs_stream_when_video_finishes && arr[i].writable?.close(); } + CUSTOM_BEHAVIOR.delete_entries_when_video_finishes && arr.splice(0, length); } document.querySelector("video")?.addEventListener("ended", () => { startDownload(); @@ -121,21 +180,46 @@ arr = []; break; case "getDownloads": // Return the downlaods available - comms.postMessage({ from: "b", action: "getDownloads", context: msg.data.content, content: arr.filter(entry => !entry.writable && entry.data.length > 0) }); + comms.postMessage({ from: "b", action: "getDownloads", context: msg.data.content, content: arr.filter(entry => (entry.writable || entry.data.length > 0)).map(({ id, title, mimeType, data, writable }) => { return { id, title, mimeType, data: msg.data.everything ? data : undefined, writable: msg.data.everything ? writable : !!writable } }) }); break; case "downloadThis": // Download the item in the data.content position singleDownload(msg.data.content); break; - case "fileSystem": // Pick a directory - window.showDirectoryPicker().then((res) => { + case "fileSystem": // Pick a directory, and write the previously-cached files there. + async function apply(res) { picker = res; - }); + for (let i = 0; i < arr.length; i++) { + const handle = await res.getFileHandle(arr[i].title, { create: true }); + const writable = await handle.createWritable({ keepExistingData: true }); + await fsWriteOperation(arr[i].id, writable, handle); + } + } + msg.data.content ? apply(msg.data.content) : window.showDirectoryPicker({ id: "MediaCachePicker", mode: "readwrite" }).then((res) => apply(res)); + break; + case "fileSystemSingleOperation": // Write the already-cached chunks to a file handle provided in the request. Used only in the Console Script. + (async () => { + const writable = await msg.data.content.file.createWritable({ keepExistingData: true }); + await fsWriteOperation(msg.data.content.id, writable, msg.data.content.handle); + })() break; case "deleteThis": const getIndex = arr.findIndex(item => item.id === msg.data.content.id); if (getIndex === -1) return; if (msg.data.content.permanent) arr.splice(getIndex, 1); else arr[getIndex].data = []; break; + case "fsFinalize": // Close the stream in a File System file and delete it from the array list + const index = arr.findIndex(item => item.id === msg.data.content); + if (index === -1) return; + arr[index].writable.close(); + arr.splice(index, 1); + break; + case "updateChoices": // Update the CUSTOM_BEHAVIOR settings + for (const key in msg.data.content) CUSTOM_BEHAVIOR[key] = !!msg.data.content[key]; + comms.postMessage({ from: "b", action: "getChoices", content: CUSTOM_BEHAVIOR }); + break; + case "getChoices": // Return the CUSTOM_BEHAVIOR settings + comms.postMessage({ from: "b", action: "getChoices", content: CUSTOM_BEHAVIOR }); + break; } }; })() diff --git a/ui/comms.js b/ui/comms.js index fbc2b0a..76367f8 100644 --- a/ui/comms.js +++ b/ui/comms.js @@ -30,11 +30,15 @@ target: { tabId: ids[0].id }, files: ['/script.js'], world: "MAIN" - }) + }); + browserToUse.tabs.sendMessage(ids[0].id, { // Change what the script should do when the video ends according to the previously-selected things + action: "updateChoices", + content: await browserToUse.storage.sync.get(["finalize_fs_stream_when_video_finishes", "delete_entries_when_video_finishes", "download_content_when_video_finishes"]) + }); checkFunctionaly(); // Check again. If everything works, this card will be hidden } } - }) + }); } checkFunctionaly(); document.getElementById("addHostname").addEventListener("click", () => { // Add a new hostname (with the wildcard pattern) in the list of the alllowed URLs @@ -75,13 +79,14 @@ card.style.backgroundColor = "var(--cardsecond)"; card.style.marginBottom = "15px"; card.append(Object.assign(document.createElement("h3"), { - textContent: `Content ${item.id} [${item.mimeType}]`, + textContent: `${item.title} [ID: ${item.id}] [Mimetype: ${item.mimeType}]`, }), Object.assign(document.createElement("button"), { - textContent: "Download", + textContent: item.writable ? "Finalize stream" : "Download", onclick: () => { - browserToUse.tabs.sendMessage(+document.getElementById("availableTabs").value, { action: "downloadThis", content: item.id }); + browserToUse.tabs.sendMessage(+document.getElementById("availableTabs").value, { action: item.writable ? "fsFinalize" : "downloadThis", content: item.id }); } - }), document.createElement("br"), + })); + !item.writable && card.append(document.createElement("br"), document.createElement("br"), Object.assign(document.createElement("label"), { style: "text-decoration: underline; margin-right: 10px;", @@ -101,7 +106,7 @@ })); document.getElementById("availableDownloads").append(card); } - + browserToUse.tabs.sendMessage(+document.getElementById("availableTabs").value, { action: "getChoices" }); } browserToUse.runtime.onMessage.addListener((msg) => { switch (msg.action) { @@ -111,6 +116,11 @@ if (document.getElementById("availableTabs").children.length === 1) document.getElementById("availableTabs").dispatchEvent(new Event("change")); break; } + case "getChoices": { // Update the "After downloading, do this..." choices + for (const choice in msg.content) { + document.querySelector(`[data-updatechoice='${choice}']`).checked = msg.content[choice]; + } + } } }); /** @@ -126,4 +136,17 @@ document.getElementById("chooseDirectory").onclick = async () => { // Pick a directory for the File System API browserToUse.tabs.sendMessage(ids[0].id, { action: "fileSystem" }); } + + + for (const checkbox of document.querySelectorAll("[data-updatechoice]")) { // Permit to change the behavior of the script after the video has ended + checkbox.addEventListener("change", () => { + const [checked, property] = [checkbox.checked, checkbox.getAttribute("data-updatechoice")]; + browserToUse.storage.sync.set({ [property]: checked }); + browserToUse.tabs.sendMessage(+document.getElementById("availableTabs").value, { + action: "updateChoices", + content: { [property]: checked } + }); + }); + } + browserToUse.tabs.sendMessage(ids[0].id, { action: "getChoices" }); // Ask the current choices to the script. })() \ No newline at end of file diff --git a/ui/style.css b/ui/style.css index 70cd7d1..e9c25fd 100644 --- a/ui/style.css +++ b/ui/style.css @@ -29,7 +29,7 @@ body { .gap { gap: 10px; } -button, input, select { +button, input:not([type=checkbox]), select { background-color: var(--card); font-family: var(--font); border: 1px solid var(--text); @@ -40,6 +40,36 @@ button, input, select { padding: 10px; color: var(--text); } + +input[type=checkbox] { + position: relative; + width: 60px; + height: 20px; + border-radius: 8px; + background-color: var(--cardsecond); + transition: 0.2s ease-in-out; + min-width: 60px; + appearance: none; + border: 0px solid var(--text); +} +input[type=checkbox]::before { + content: ""; + position: absolute; + width: 16px; + height: 16px; + top: 2px; + left: 2px; + border-radius: 50%; + background-color: var(--text); + transition: 0.2s ease-in-out; +} +input[type=checkbox]:checked::before { + left: 42px; +} +input[type=checkbox]:checked { + background-color: var(--accent); +} + select { background-color: var(--cardsecond); } diff --git a/ui/ui.html b/ui/ui.html index 283b492..608fe5e 100644 --- a/ui/ui.html +++ b/ui/ui.html @@ -51,7 +51,23 @@