-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 0c0549b
Showing
13 changed files
with
422 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
package-lock.json | ||
node_modules |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.** | ||
|
||
 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]; | ||
} | ||
})() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
{ | ||
"dependencies": { | ||
"@types/chrome": "^0.0.270" | ||
} | ||
} |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" }); | ||
})(); |
Oops, something went wrong.