Skip to content

Commit

Permalink
first commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dinoosauro committed Sep 4, 2024
0 parents commit 0c0549b
Show file tree
Hide file tree
Showing 13 changed files with 422 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
package-lock.json
node_modules
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# youtube-stream-saver

Record parts of a YouTube video using the MediaRecorder API

## Installation

First, download the
[.zip file](https://codeload.github.com/Dinoosauro/youtube-stream-saver/zip/refs/heads/main)
of this repository. Then, follow the instructions for your browser.

### Chromium:

Go to the `chrome://extensions` page, and enable the `Developer mode` slider.
Extract the .zip file, and then on your browser click on the
`Load unpacked extension` button. Choose the directory where you've extracted
the .zip file, and the extension will be installed.

### Firefox

Go to `about:debugging#/runtime/this-firefox`, and click on the
`Load Temporary Add-on` button. Choose the .zip file, and the extension will be
installed.

## Usage

Click on the extension icon to open it. Then, you'll be able to choose the
codecs for the recording. You can adjust the bitrate and the interval of the key
frames, and finally you can start the recording. You can stop it at any time,
but it'll be automatically stopped:

- When the video ends
- When the quality of the video is changed (or when an advertisement starts)

**Note: The video bitrate won't be accurate, and probably the MediaRecorder
encoder will go over that.**

![The extension UI](./readme_assets/ui.jpg)
145 changes: 145 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The extension object
* @type chrome
*/
const browserToUse = typeof chrome === "undefined" ? browser : chrome;
let settings = {
audioBitsPerSecond: 192_000,
videoBitsPerSecond: 2_500_000,
mimeType: null,
videoKeyFrameIntervalDuration: 5
}
/**
*
* @returns An array of the available codecs. Each array has a "type" key, with its value as a [string with the mimetype to replace, string with the name to show in the UI] and "containers" object, with a string[][] composed of [[the container mimetype, the name to show]]
*/
function checkAvailableMimetypes() {
const codecs = {
video: [["vp9", "VP9 Video"], ["avc1", "H264 Video"], ["vp8", "VP8 Video"], ["av1", "AV1 Video"], ["hev1", "H265 Video"], ["", ""]],
audio: [["opus", "Opus Audio"], ["pcm", "PCM Audio"], ["mp4a", "AAC Audio"], [""]],
container: [["webm", "WebM"], ["ogg", "OGG"], ["mp4", "MP4"], ["x-matroska", "Matroska"]]
}
const output = [];
for (const [video, videoDesc] of codecs.video) {
for (const [audio, audioDesc] of codecs.audio) {
if (video === "" && audio === "") continue;
for (const container of codecs.container) {
const stringToTest = `${video === "" ? "audio" : "video"}/${container[0]}; codecs=${video === "" ? "" : `"${video}"`}${video !== "" && audio !== "" ? "," : ""}${audio === "" ? "" : `"${audio}"`}`
if (MediaRecorder.isTypeSupported(stringToTest)) {
const generalString = stringToTest.replace(container[0], "$container");;
const index = output.findIndex(field => field.type[0] === generalString);
if (index !== -1) output[index].containers.push(container); else output.push({ type: [generalString, `${video === "" ? "" : videoDesc}${video !== "" && audio !== "" ? " + " : ""}${audio === "" ? "" : audioDesc}`], containers: [container] });
}
}
}
}
return output;
}
/**
* The object used for recording the video stream
* @type MediaRecorder
*/
let mediaRecorder = null;
/**
* The chunks of the recorded video. Note that nothing is added if the File System API is used.
*/
let chunks = [];
/**
* The name of the output file
*/
let suggestedName = crypto?.randomUUID() ?? Math.random();
/**
* The Writable of the file the user has selected. This is only used for the File System API
* @type FileSystemWritableFileStream
*/
let fileWritable;
/**
* The position where the new chunk should be written
*/
let writePosition = 0;
/**
* Stop the recording. This is used so that, if the source changes, the recording of the new source will be started automatically.
*/
let forceStop = false;
/**
* The main function, that starts the recording of the video
*/
async function startContent() {
const video = document.querySelector("video");
/**
* The extension of the output file (without the dot)
*/
const extension = (settings.mimeType ? settings.mimeType.substring(settings.mimeType.indexOf("/") + 1, settings.mimeType.indexOf(";")) : checkAvailableMimetypes()[0].containers[0][1]).replace("x-matroska", "mkv");
video.addEventListener("ended", () => {
mediaRecorder.stop();
});
const [title, channel, id, channelLink] = [document.querySelector("#title > h1 > yt-formatted-string")?.textContent, document.querySelector("#upload-info > #channel-name a")?.textContent, new URLSearchParams(window.location.search).get("v"), document.querySelector("#upload-info > #channel-name a").href]
if (title && channel && id) suggestedName = `${title} [${id}]`;
forceStop = false;
if (video !== null) { // Start the cappture
const stream = typeof video.captureStream !== "undefined" ? video.captureStream() : video.mozCaptureStream(); // Firefox has a different name for the captureStream function
for (const key in settings) settings[key] = parseInt(settings[key]) || undefined; // Delete null, NaN or "" placeholders
try { // Try saving the file using the File System API. If not available, the standard link method will be used.
fileWritable = await (await window.showSaveFilePicker({ id: channelLink.substring(channelLink.lastIndexOf("/") + 1).replace("@", "").substring(0, 32), suggestedName: `${suggestedName}.${extension}` })).createWritable();
} catch (ex) {

}
mediaRecorder = new MediaRecorder(stream, { ...settings }); // Initialize the new MediaRecorder
mediaRecorder.ondataavailable = fileWritable ? async (event) => { // Since a Writable is being used, the file will be directly written on the device
event.data.size > 0 && fileWritable.write({ data: event.data, position: writePosition, type: "write" });
writePosition += event.data.size;
} : (event) => { // No Writable is being used. So, we'll add the Blob to an array
event.data.size > 0 && chunks.push(event.data);
};
mediaRecorder.onstop = async () => {
if (!fileWritable) {
const blob = new Blob(chunks, { type: `video/${extension}` }); // Create the output blob with all the merged files
const a = document.createElement("a"); // And download it using a link
a.href = URL.createObjectURL(blob);
a.download = `${suggestedName}.${extension}`;
a.click();
URL.revokeObjectURL(a.href);
} else await fileWritable.close(); // If the File System API is being used, just close the writable.
// Restore the values at their original value
chunks = [];
mediaRecorder = null;
fileWritable = null;
writePosition = 0;
suggestedName = crypto?.randomUUID() ?? Math.random();
browserToUse.runtime.sendMessage(browserToUse.runtime.id, { action: "running", content: false }); // Say to the extension that the conversion has ended
!video.paused && !forceStop && startContent(); // If the user hasn't manually stopped the video, and it's still playing, start a new reproduction.
};
mediaRecorder.start(fileWritable ? 500 : undefined);
browserToUse.runtime.sendMessage(browserToUse.runtime.id, { action: "running", content: true });
}
}

browserToUse.runtime.onMessage.addListener((msg) => {
switch (msg.action) {
case "start":
if (mediaRecorder instanceof MediaRecorder) mediaRecorder.stop();
startContent();
break;
case "stop":
if (mediaRecorder instanceof MediaRecorder) {
forceStop = true;
mediaRecorder.stop();
}
break;
case "running":
browserToUse.runtime.sendMessage(browserToUse.runtime.id, { action: "running", content: mediaRecorder !== null })
break;
case "getAvailableCodecs":
browserToUse.runtime.sendMessage(browserToUse.runtime.id, { action: "getAvailableCodecs", content: checkAvailableMimetypes() })
break;
case "updateFields":
settings = { ...settings, ...msg.content }
break;
}
});
(async () => { // Update the settings by fetching the values in the sync storage
const result = await browserToUse.storage.sync.get(Object.keys(settings));
for (const key in result) {
settings[key] = result[key] || settings[key];
}
})()
27 changes: 27 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"manifest_version": 3,
"name": "YouTube Stream Saver",
"description": "Save the YouTube stream on your device by re-encoding it while playing the video",
"version": "1",
"action": {
"default_popup": "./ui/index.html"
},
"permissions": [
"storage"
],
"content_scripts": [
{
"js": [
"main.js"
],
"matches": [
"https://*.youtube.com/*"
]
}
],
"browser_specific_settings": {
"gecko": {
"id": "{eb5affd7-77c8-48ca-bf98-5fbe1d72bedf}"
}
}
}
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"dependencies": {
"@types/chrome": "^0.0.270"
}
}
Binary file added readme_assets/ui.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added ui/icons/1 4.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 added ui/icons/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 added ui/icons/48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions ui/icons/svg.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
54 changes: 54 additions & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Stream Saver</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<header>
<div class="flex hcenter gap" style="margin-bottom: 20px;">
<img style="width: 48px; height: 48px;" src="./icons/svg.svg">
<h1 style="margin: 0px;">YouTube Stream Saver</h1>
</div>
<p>Record YouTube's video stream and save the result on your device</p>
</header>
<main>
<div class="card" id="requireAccess" style="background-color: darkred; margin-bottom: 20px;">
<h2>The extension doesn't have access to YouTube</h2>
<p>Please grant the authorization to read the YouTube webpage to make it work.</p>
<button id="grantAccess">Grant authorization</button>
</div>
<div data-result-show="0">
<div class="card">
<h2 style="margin-top: 0px;">Settings:</h2>
<label class="flex hcenter gap">
Video bitrate (in bit/s):
<input data-settings="videoBitsPerSecond" data-default-value="2500000" type="number">
</label><br>
<label class="flex hcenter gap">
Audio bitrate in (bit/s):
<input data-settings="audioBitsPerSecond" data-default-value="192000" type="number">
</label><br>
<label class="flex hcenter gap">
Keyframes in output video: <input data-settings="videoKeyFrameIntervalDuration" data-default-value="5" type="number">
</label>
<p>Output codec:</p>
<select id="resolution"></select>
<p>Output container:</p>
<select id="container"></select>
</div><br>
<button id="start">Start recording</button>
</div>
<div data-result-show="1" style="display: none;">
<h2>The video is being recorded...</h2>
<p>The recording will automatically stop at the end of the video.</p><br>
<button id="stop">Stop recording</button>
</div>
<br><br>
<i>Icons from Microsoft's <a href="https://github.com/microsoft/fluentui-system-icons/tree/main" target="_blank">Fluent UI System Icons</a>. This project is in no way affiliated with YouTube, that is a trademark of Google.</i>
</main>
<script src="./script.js"></script>
</body>
</html>
97 changes: 97 additions & 0 deletions ui/script.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
(async () => {
/**
* The browser interface to use
* @type chrome
*/
const browserToUse = typeof chrome === "undefined" ? browser : chrome;
/**
* A list of the available codecs. It's composed by a "type" key, with a string[] that contains [the mimetype, and the string to show]; and by a "containers" key, with a string[][] that contains [["the mimetype of the container", "the name of the container to show"]]
*/
let codecs = [];
/**
* The Select where the user can choose the codec option
* @type HTMLSelectElement
*/
const select = document.getElementById("resolution");
/**
* The Select where the user can choose the container of the output file
* @type HTMLSelectElement
*/
const container = document.getElementById("container");
/**
* The string of the output codec chosen
*/
let outputCodec = "";
const ids = await browserToUse.tabs.query({ active: true });
/**
* If Settings are being restored. This is used so that the value of the container won't be updated every time from the Settings
*/
let settingsRestore = true;
select.onchange = () => {
container.innerHTML = "";
browserToUse.storage.sync.set({ availableMetadataIndex: select.value });
if (select.value === "-1") { // The default mimetype
browserToUse.tabs.sendMessage(ids[0].id, { action: "updateFields", content: { mimeType: null } })
return;
}
for (const [containerType, containerName] of codecs[+select.value].containers) {
const option = document.createElement("option");
option.textContent = containerName;
option.value = containerType;
container.append(option);
}
if (settingsRestore) {
container.value = syncProperties.chosenContainer.toString();
settingsRestore = false;
}
container.dispatchEvent(new Event("change"));
}
container.onchange = () => {
outputCodec = codecs[+select.value].type[0].replace("$container", container.value);
browserToUse.tabs.sendMessage(ids[0].id, { action: "updateFields", content: { mimeType: outputCodec } });
browserToUse.storage.sync.set({ chosenContainer: container.value });
}
browserToUse.runtime.onMessage.addListener((msg) => {
switch (msg.action) {
case "running":
for (const item of document.querySelectorAll("[data-result-show]")) {
const type = item.getAttribute("data-result-show")
item.style.display = (msg.content && type === "1") || (!msg.content && type === "0") ? "block" : "none";
}
break;
case "getAvailableCodecs":
codecs = msg.content;
select.innerHTML = "";
msg.content.forEach(({ type }, i) => { // Create an option for every available codec
const option = document.createElement("option");
option.textContent = type[1];
option.value = i;
select.append(option);
});
select.value = syncProperties.availableMetadataIndex;
select.dispatchEvent(new Event("change"));
break;
}
});
document.getElementById("start").onclick = () => browserToUse.tabs.sendMessage(ids[0].id, { action: "start" });
document.getElementById("stop").onclick = () => browserToUse.tabs.sendMessage(ids[0].id, { action: "stop" });
const syncProperties = await browserToUse.storage.sync.get(["availableMetadataIndex", "chosenContainer", ...Array.from(document.querySelectorAll("[data-settings]")).map(item => item.getAttribute("data-settings"))]); // The first two properties contain the value of the two selects.
for (const item of document.querySelectorAll("[data-settings]")) {
const prop = item.getAttribute("data-settings");
item.value = syncProperties[prop] ?? item.getAttribute("data-default-value");
item.addEventListener("change", () => {
browserToUse.storage.sync.set({ [prop]: item.value });
browserToUse.tabs.sendMessage(ids[0].id, { action: "updateFields", content: { [prop]: item.value } })
});
}
document.getElementById("grantAccess").onclick = async () => { // Request the access to the YouTube webpage
await browserToUse.permissions.request({ origins: ["https://*.youtube.com/*"] });
checkPermission();
}
async function checkPermission() { // Check if the user has granted permission to the extension to access the YouTube webpage, so that, if false, a warning on the extension UI will be shown.
document.getElementById("requireAccess").style.display = await browserToUse.permissions.contains({ origins: ["https://*.youtube.com/*"] }) ? "none" : "block";
}
checkPermission();
browserToUse.tabs.sendMessage(ids[0].id, { action: "running" });
browserToUse.tabs.sendMessage(ids[0].id, { action: "getAvailableCodecs" });
})();
Loading

0 comments on commit 0c0549b

Please sign in to comment.