diff --git a/assets/icons/icon.png b/assets/icons/icon.png
index b444117..173e674 100644
Binary files a/assets/icons/icon.png and b/assets/icons/icon.png differ
diff --git a/manifest.json b/manifest.json
index 5444c0c..6ce2a4a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -9,14 +9,19 @@
"web_accessible_resources": [
{
"resources": [
+ "src/amazon-ivs-worker.min.js",
"src/app.js",
- "src/chrome/app.js"
+ "src/chrome/app.js",
+ "src/download.js"
],
"matches": [
"https://*.twitch.tv/*"
]
}
],
+ "background": {
+ "service_worker": "src/background.js"
+ },
"content_scripts": [
{
"matches": [
@@ -34,5 +39,11 @@
"https://*.twitch.tv/*",
"https://static.twitchcdn.net/assets/*"
],
- "permissions": []
+ "permissions": [
+ "activeTab",
+ "tabs",
+ "webRequest",
+ "webNavigation",
+ "declarativeNetRequest"
+ ]
}
\ No newline at end of file
diff --git a/src/amazon-ivs-worker.min.js b/src/amazon-ivs-worker.min.js
index 857f1cd..76f785d 100644
--- a/src/amazon-ivs-worker.min.js
+++ b/src/amazon-ivs-worker.min.js
@@ -39,11 +39,10 @@ async function isValidQuality(url) {
const oldFetch = self.fetch;
-self.fetch = async function (input, opt) {
- let url = input instanceof Request ? input.url : input.toString();
- let response = await oldFetch(input, opt);
+self.fetch = async function (url, opt) {
+ let response = await oldFetch(url, opt);
+
- // Patch playlist from unmuted to muted segments
if (url.includes("cloudfront") && url.includes(".m3u8")) {
const body = await response.text();
@@ -56,7 +55,7 @@ self.fetch = async function (input, opt) {
const data = await fetchTwitchDataGQL(vodId);
if (data == undefined) {
- return new Response("Unable to fetch twitch data API", { status: 403 });
+ return new Response("Unable to fetch twitch data API", 403);
}
const vodData = data.data.video;
@@ -94,7 +93,7 @@ self.fetch = async function (input, opt) {
let ordered_resolutions = {};
- for (const key in sorted_dict) {
+ for (key in sorted_dict) {
ordered_resolutions[sorted_dict[key]] = resolutions[sorted_dict[key]];
}
@@ -119,8 +118,8 @@ self.fetch = async function (input, opt) {
let startQuality = 8534030;
- for (const [resKey, resValue] of Object.entries(resolutions)) {
- url = undefined;
+ for ([resKey, resValue] of Object.entries(resolutions)) {
+ var url = undefined;
if (broadcastType === "highlight") {
url = `https://${domain}/${vodSpecialID}/${resKey}/highlight-${vodId}.m3u8`;
diff --git a/src/app.js b/src/app.js
index 7a34087..59fd49b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,25 +1,32 @@
-// From vaft script (https://github.com/pixeltris/TwitchAdSolutions/blob/master/vaft/vaft.user.js#L299)
-function getWasmWorkerJs(twitchBlobUrl) {
- var req = new XMLHttpRequest();
- req.open('GET', twitchBlobUrl, false);
- req.overrideMimeType("text/javascript");
- req.send();
- return req.responseText;
-}
+var isVariantA = false;
+const originalAppendChild = document.head.appendChild;
+
+document.head.appendChild = function (element) {
+ if (element.tagName === "SCRIPT") {
+ if (element.src.includes("player-core-variant-a")) {
+ isVariantA = true;
+ }
+ }
+
+ return originalAppendChild.call(this, element);
+};
const oldWorker = window.Worker;
window.Worker = class Worker extends oldWorker {
constructor(twitchBlobUrl) {
- var workerString = getWasmWorkerJs(`${twitchBlobUrl.replaceAll("'", "%27")}`);
+ super(twitchBlobUrl);
+
+ this.addEventListener("message", (event) => {
+ const data = event.data;
+
+ if ((data.id == 1 || isVariantA) && data.type == 1) {
+ const newData = event.data;
- const blobUrl = URL.createObjectURL(new Blob([`
- importScripts(
- '${patch_url}',
- );
- ${workerString}
- `]));
+ newData.arg = [data.arg];
- super(blobUrl);
+ this.postMessage(newData);
+ }
+ });
}
}
\ No newline at end of file
diff --git a/src/background.js b/src/background.js
new file mode 100644
index 0000000..39a5dc9
--- /dev/null
+++ b/src/background.js
@@ -0,0 +1,82 @@
+
+chrome.webNavigation.onBeforeNavigate.addListener(function () {
+
+}, {
+ url: [{ hostContains: "twitch" }]
+});
+
+var isChrome = chrome.declarativeNetRequest != undefined;
+var cdnLink = '';
+
+if (isChrome) {
+
+ chrome.runtime.onStartup.addListener(() => {
+ chrome.runtime.reload();
+ });
+}
+
+
+const app = () => {
+ if (isChrome) {
+
+ chrome.declarativeNetRequest.updateDynamicRules({
+ addRules: [{
+ 'id': 1001,
+ 'priority': 1,
+ 'action': {
+ 'type': 'redirect',
+ 'redirect': { url: cdnLink }
+ },
+ 'condition': { urlFilter: 'https://static.twitchcdn.net/assets/amazon-ivs-wasmworker.min-*.js' }
+ }],
+ removeRuleIds: [1001]
+ });
+
+ chrome.declarativeNetRequest.updateDynamicRules({
+ addRules: [{
+ 'id': 1002,
+ 'priority': 1,
+ 'action': {
+ 'type': 'redirect',
+ 'redirect': { url: cdnLink }
+ },
+ 'condition': { urlFilter: 'https://assets.twitch.tv/assets/amazon-ivs-wasmworker.min-*.js' }
+ }],
+ removeRuleIds: [1002]
+ });
+ } else {
+
+ browser.webRequest.onBeforeRequest.addListener(() => {
+ return { redirectUrl: cdnLink };
+ }, {
+ urls: [
+ "https://static.twitchcdn.net/assets/amazon-ivs-wasmworker.min-*.js",
+ "https://assets.twitch.tv/assets/amazon-ivs-wasmworker.min-*.js"
+ ],
+ types: ["main_frame", "script"]
+ }, ["blocking"]);
+ }
+
+};
+
+(async () => {
+
+ try {
+ const response = await fetch("https://api.github.com/repos/besuper/TwitchNoSub/commits");
+ const content = await response.json();
+
+ var latestCommit = content[0].sha;
+
+ console.log("Lastest commit sha: " + latestCommit);
+
+ cdnLink = `https://cdn.jsdelivr.net/gh/besuper/TwitchNoSub@${latestCommit}/src/amazon-ivs-worker.min.js`;
+ } catch (e) {
+ console.log(e);
+
+ cdnLink = `https://cdn.jsdelivr.net/gh/besuper/TwitchNoSub/src/amazon-ivs-worker.min.js`;
+ }
+
+ console.log("CDN link : " + cdnLink);
+
+ app();
+})();
\ No newline at end of file
diff --git a/src/download.js b/src/download.js
new file mode 100644
index 0000000..19b2227
--- /dev/null
+++ b/src/download.js
@@ -0,0 +1,525 @@
+
+
+(function() {
+
+ let downloadButtonAdded = false;
+
+
+ function getVodId() {
+ const match = window.location.pathname.match(/\/videos\/(\d+)/);
+ return match ? match[1] : null;
+ }
+
+
+ async function getVodInfo(vodId) {
+
+ const vodInfo = {
+ m3u8Url: null,
+ title: null
+ };
+
+
+ const videoElements = document.querySelectorAll('video');
+ for (const video of videoElements) {
+ if (video.src && video.src.includes('.m3u8')) {
+ vodInfo.m3u8Url = video.src;
+
+
+ const titleElement = document.querySelector('h1[data-a-target="stream-title"], .tw-title');
+ if (titleElement) {
+ vodInfo.title = titleElement.textContent.trim();
+ }
+
+ return vodInfo;
+ }
+ }
+
+
+ try {
+
+ const resp = await fetch("https://gql.twitch.tv/gql", {
+ method: 'POST',
+ body: JSON.stringify({
+ "query": "query { video(id: \"" + vodId + "\") { seekPreviewsURL, title, owner { login } }}"
+ }),
+ headers: {
+ 'Client-Id': 'kimne78kx3ncx6brgo4mv6wki5h1ko',
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const data = await resp.json();
+ if (data && data.data && data.data.video) {
+ const vodData = data.data.video;
+ const currentURL = new URL(vodData.seekPreviewsURL);
+ const domain = currentURL.host;
+ const paths = currentURL.pathname.split("/");
+ const vodSpecialID = paths[paths.findIndex(element => element.includes("storyboards")) - 1];
+
+
+ vodInfo.m3u8Url = `https://${domain}/${vodSpecialID}/chunked/index-dvr.m3u8`;
+ vodInfo.title = vodData.title;
+
+ return vodInfo;
+ }
+ } catch (e) {
+ console.error("Error getting VOD info:", e);
+ }
+
+ return vodInfo;
+ }
+
+
+ function createSafeFilename(title, vodId) {
+ if (!title) return `twitch_vod_${vodId}`;
+
+
+ let safeTitle = title
+ .replace(/[\\/:*?"<>|]/g, '_')
+ .replace(/\s+/g, '_')
+ .replace(/__+/g, '_')
+ .substring(0, 100);
+
+
+ return `${safeTitle}_${vodId}`;
+ }
+
+
+ async function getFixedM3u8Content(m3u8Url) {
+ try {
+ const response = await fetch(m3u8Url);
+ const content = await response.text();
+
+
+ const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
+
+
+ const lines = content.split('\n');
+ const fixedLines = lines.map(line => {
+ if (line.endsWith('.ts') || (line.endsWith('.m3u8') && !line.startsWith('#'))) {
+ return baseUrl + line;
+ }
+ return line;
+ });
+
+ return fixedLines.join('\n');
+ } catch (error) {
+ console.error('Error fixing M3U8:', error);
+ return null;
+ }
+ }
+
+
+ function createDownloadButton() {
+ if (downloadButtonAdded) return;
+
+ const vodId = getVodId();
+ if (!vodId) return;
+
+
+ let safeFilename = `twitch_vod_${vodId}`;
+
+
+ const controlsContainer = document.querySelector('.player-controls__right-control-group') ||
+ document.querySelector('.ScCoreButton-sc-ocjdkq-0');
+
+ if (!controlsContainer) {
+
+ setTimeout(createDownloadButton, 1000);
+ return;
+ }
+
+
+ const downloadDialog = document.createElement('div');
+ downloadDialog.style.display = 'none';
+ downloadDialog.style.position = 'fixed';
+ downloadDialog.style.left = '50%';
+ downloadDialog.style.top = '50%';
+ downloadDialog.style.transform = 'translate(-50%, -50%)';
+ downloadDialog.style.backgroundColor = '#18181b';
+ downloadDialog.style.border = '1px solid #3a3a3d';
+ downloadDialog.style.borderRadius = '6px';
+ downloadDialog.style.padding = '20px';
+ downloadDialog.style.zIndex = '9999';
+ downloadDialog.style.width = '500px';
+ downloadDialog.style.maxHeight = '80vh';
+ downloadDialog.style.overflowY = 'auto';
+ downloadDialog.style.color = 'white';
+ downloadDialog.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.5)';
+
+ downloadDialog.innerHTML = `
+
Télécharger la VOD
+
+
Choisissez une option de téléchargement :
+
+
+
+
+
+
+
1. Télécharger le script ts-downloader.js
+
2. Exécutez avec Node.js :
+
+ node ts-downloader.js ${safeFilename}.m3u8
+
+
+
+
3. Après avoir obtenu le fichier .ts, convertissez-le en MP4 avec FFmpeg en ligne de commande :
+
+
+
+
+
+
+
+
+
+
1. Téléchargez FFmpeg pour Windows (choisissez la version ffmpeg-master-latest-win64-gpl.zip)
+
2. Extrayez le fichier ZIP dans un dossier (ex: C:\\ffmpeg)
+
3. Ajoutez le dossier bin au PATH Windows:
+
+ - Recherchez "variables d'environnement" dans le menu Démarrer
+ - Cliquez sur "Modifier les variables d'environnement système"
+ - Cliquez sur "Variables d'environnement"
+ - Dans "Variables système", sélectionnez "Path" et cliquez sur "Modifier"
+ - Cliquez sur "Nouveau" et ajoutez le chemin vers le dossier bin (ex:
C:\\ffmpeg\\bin)
+
+
4. Redémarrez votre invite de commande pour appliquer les changements
+
+
+
+
1. Installez Homebrew si ce n'est pas déjà fait:
+
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
+
+
2. Installez FFmpeg avec la commande:
+
+ brew install ffmpeg
+
+
+
+
+
Pour Ubuntu/Debian:
+
+ sudo apt update && sudo apt install ffmpeg
+
+
Pour Fedora:
+
+ sudo dnf install ffmpeg
+
+
Pour Arch Linux:
+
+ sudo pacman -S ffmpeg
+
+
+
+
+ ffmpeg -i ${safeFilename}.ts -c copy ${safeFilename}.mp4
+
+
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(downloadDialog);
+
+
+ const downloadButton = document.createElement('button');
+ downloadButton.innerHTML = `
+
+ `;
+ downloadButton.className = 'ScCoreButton-sc-ocjdkq-0 ScCoreButtonSecondary-sc-ocjdkq-2';
+ downloadButton.style.marginLeft = '10px';
+ downloadButton.title = 'Télécharger VOD';
+
+
+ downloadButton.addEventListener('click', async () => {
+ const vodInfo = await getVodInfo(vodId);
+ if (vodInfo.m3u8Url) {
+
+ const safeFilename = createSafeFilename(vodInfo.title, vodId);
+
+
+ downloadDialog.style.display = 'block';
+
+
+ document.getElementById('directM3u8').addEventListener('click', async () => {
+ try {
+
+ const fixedContent = await getFixedM3u8Content(vodInfo.m3u8Url);
+ if (!fixedContent) {
+ alert('Erreur lors de la préparation du fichier. Veuillez réessayer.');
+ return;
+ }
+
+
+ const blob = new Blob([fixedContent], { type: 'application/x-mpegURL' });
+ const downloadUrl = URL.createObjectURL(blob);
+ const downloadLink = document.createElement('a');
+ downloadLink.href = downloadUrl;
+ downloadLink.setAttribute('download', `${safeFilename}.m3u8`);
+ downloadLink.click();
+
+
+ URL.revokeObjectURL(downloadUrl);
+
+
+ document.getElementById('nodeCmd').textContent = `node ts-downloader.js ${safeFilename}.m3u8`;
+ document.getElementById('ffmpegCmd').textContent = `ffmpeg -i ${safeFilename}.ts -c copy ${safeFilename}.mp4`;
+
+
+ document.getElementById('downloadHelp').style.display = 'block';
+ } catch (error) {
+ alert('Erreur: ' + error.message);
+ }
+ });
+
+ document.getElementById('copyNode').addEventListener('click', () => {
+ const cmd = document.getElementById('nodeCmd').textContent;
+ navigator.clipboard.writeText(cmd)
+ .then(() => {
+ const btn = document.getElementById('copyNode');
+ btn.textContent = 'Copié!';
+ setTimeout(() => { btn.textContent = 'Copier la commande'; }, 2000);
+ });
+ });
+
+ document.getElementById('copyFfmpeg').addEventListener('click', () => {
+ const cmd = document.getElementById('ffmpegCmd').textContent;
+ navigator.clipboard.writeText(cmd)
+ .then(() => {
+ const btn = document.getElementById('copyFfmpeg');
+ btn.textContent = 'Copié!';
+ setTimeout(() => { btn.textContent = 'Copier la commande'; }, 2000);
+ });
+ });
+
+ document.getElementById('downloadScript').addEventListener('click', (e) => {
+ e.preventDefault();
+
+
+ const scriptContent = `// ts-downloader.js - Outil pour télécharger les segments TS d'une VOD Twitch
+// Utilisez: node ts-downloader.js
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const readline = require('readline');
+
+
+const m3u8File = process.argv[2];
+if (!m3u8File) {
+ console.error('Usage: node ts-downloader.js ');
+ process.exit(1);
+}
+
+
+const outputDir = path.join(path.dirname(m3u8File), 'segments');
+const outputFile = path.join(path.dirname(m3u8File), path.basename(m3u8File, '.m3u8') + '.ts');
+
+try {
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir);
+ }
+} catch (error) {
+ console.error('Erreur lors de la création du dossier segments:', error);
+ process.exit(1);
+}
+
+
+const content = fs.readFileSync(m3u8File, 'utf8');
+const lines = content.split('\\n');
+
+
+const segments = [];
+for (const line of lines) {
+ if (line.trim() && line.indexOf('http') === 0 && line.endsWith('.ts')) {
+ segments.push(line.trim());
+ }
+}
+
+if (segments.length === 0) {
+ console.error('Aucun segment trouvé dans le fichier M3U8');
+ process.exit(1);
+}
+
+console.log(\`Nombre total de segments: \${segments.length}\`);
+
+
+function downloadSegment(url, outputPath) {
+ return new Promise((resolve, reject) => {
+ https.get(url, (response) => {
+ if (response.statusCode !== 200) {
+ reject(new Error(\`Erreur HTTP \${response.statusCode}\`));
+ return;
+ }
+
+ const fileStream = fs.createWriteStream(outputPath);
+ response.pipe(fileStream);
+
+ fileStream.on('finish', () => {
+ fileStream.close();
+ resolve();
+ });
+
+ fileStream.on('error', (err) => {
+ fs.unlink(outputPath, () => {});
+ reject(err);
+ });
+ }).on('error', (err) => {
+ reject(err);
+ });
+ });
+}
+
+
+async function downloadAllSegments() {
+
+ const combinedFile = fs.createWriteStream(outputFile);
+
+
+ let completed = 0;
+ const total = segments.length;
+ const updateProgress = () => {
+ const percent = Math.round((completed / total) * 100);
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(\`Progression: \${completed}/\${total} (\${percent}%)\`);
+ };
+
+
+ for (let i = 0; i < segments.length; i++) {
+ const segmentUrl = segments[i];
+ const segmentPath = path.join(outputDir, \`segment_\${i}.ts\`);
+
+ try {
+
+ await downloadSegment(segmentUrl, segmentPath);
+
+
+ const segmentData = fs.readFileSync(segmentPath);
+ combinedFile.write(segmentData);
+
+
+ fs.unlinkSync(segmentPath);
+
+
+ completed++;
+ updateProgress();
+
+ } catch (error) {
+ console.error(\`\\nErreur lors du téléchargement du segment \${i}:\`, error.message);
+
+ }
+ }
+
+ combinedFile.end();
+ console.log(\`\\nTéléchargement terminé. Fichier enregistré: \${outputFile}\`);
+ console.log(\`\\nPour convertir en MP4, utilisez cette commande: ffmpeg -i \${outputFile} -c copy \${outputFile.replace('.ts', '.mp4')}\`);
+}
+
+
+downloadAllSegments().catch(err => {
+ console.error('\\nErreur:', err);
+});`;
+
+
+ const blob = new Blob([scriptContent], { type: 'application/javascript' });
+ const url = URL.createObjectURL(blob);
+ const downloadLink = document.createElement('a');
+ downloadLink.href = url;
+ downloadLink.setAttribute('download', 'ts-downloader.js');
+ downloadLink.click();
+ URL.revokeObjectURL(url);
+ });
+
+ document.getElementById('closeDialog').addEventListener('click', () => {
+ downloadDialog.style.display = 'none';
+ });
+
+
+ document.getElementById('ffmpegHelp').addEventListener('click', () => {
+ const guide = document.getElementById('ffmpegInstallGuide');
+ guide.style.display = guide.style.display === 'none' ? 'block' : 'none';
+ });
+
+
+ document.querySelectorAll('.os-tab').forEach(tab => {
+ tab.addEventListener('click', () => {
+
+ document.querySelectorAll('.os-content').forEach(content => {
+ content.style.display = 'none';
+ });
+
+
+ document.querySelectorAll('.os-tab').forEach(t => {
+ t.classList.remove('active');
+ t.style.borderBottom = '2px solid transparent';
+ });
+
+
+ const os = tab.getAttribute('data-os');
+ document.getElementById(`${os}-content`).style.display = 'block';
+
+
+ tab.classList.add('active');
+ tab.style.borderBottom = '2px solid #9147ff';
+ });
+ });
+ } else {
+ alert('Impossible d\'obtenir l\'URL vidéo. Veuillez réessayer quand la vidéo est en cours de lecture.');
+ }
+ });
+
+
+ controlsContainer.appendChild(downloadButton);
+ downloadButtonAdded = true;
+
+
+ document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') {
+ const dialog = document.querySelector('div[style*="fixed"]');
+ if (dialog && dialog.style.display !== 'none') {
+ dialog.style.display = 'none';
+ }
+ }
+ });
+
+ console.log('TwitchNoSub: Download button added');
+ }
+
+
+ function checkAndAddButton() {
+ const isVodPage = window.location.pathname.includes('/videos/');
+ if (isVodPage) {
+ createDownloadButton();
+ } else {
+ downloadButtonAdded = false;
+ }
+ }
+
+
+ checkAndAddButton();
+
+
+ const pushState = history.pushState;
+ history.pushState = function() {
+ pushState.apply(history, arguments);
+ setTimeout(checkAndAddButton, 1000);
+ };
+
+
+ window.addEventListener('popstate', () => {
+ setTimeout(checkAndAddButton, 1000);
+ });
+
+
+ setInterval(checkAndAddButton, 3000);
+})();
\ No newline at end of file
diff --git a/src/downloader-worker.js b/src/downloader-worker.js
new file mode 100644
index 0000000..85b2315
--- /dev/null
+++ b/src/downloader-worker.js
@@ -0,0 +1,136 @@
+
+self.onmessage = async function(e) {
+ const { m3u8Url, vodId } = e.data;
+
+ try {
+
+ const m3u8Response = await fetch(m3u8Url);
+ if (!m3u8Response.ok) {
+ throw new Error(`Erreur lors du téléchargement du fichier m3u8: ${m3u8Response.status}`);
+ }
+
+ const m3u8Content = await m3u8Response.text();
+
+
+ const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
+
+
+ const segmentUrls = [];
+ const lines = m3u8Content.split('\n');
+
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].endsWith('.ts')) {
+ segmentUrls.push(baseUrl + lines[i]);
+ }
+ }
+
+ if (segmentUrls.length === 0) {
+
+
+ let subPlaylistUrl = '';
+ for (let i = 0; i < lines.length; i++) {
+ if (lines[i].endsWith('.m3u8')) {
+ subPlaylistUrl = baseUrl + lines[i];
+ break;
+ }
+ }
+
+ if (subPlaylistUrl) {
+
+ const subResponse = await fetch(subPlaylistUrl);
+ if (!subResponse.ok) {
+ throw new Error(`Erreur lors du téléchargement de la sous-playlist: ${subResponse.status}`);
+ }
+
+ const subContent = await subResponse.text();
+ const subBaseUrl = subPlaylistUrl.substring(0, subPlaylistUrl.lastIndexOf('/') + 1);
+
+
+ const subLines = subContent.split('\n');
+ for (let i = 0; i < subLines.length; i++) {
+ if (subLines[i].endsWith('.ts')) {
+ segmentUrls.push(subBaseUrl + subLines[i]);
+ }
+ }
+ }
+ }
+
+ self.postMessage({
+ type: 'info',
+ message: `Téléchargement de ${segmentUrls.length} segments...`
+ });
+
+
+ let completedSegments = 0;
+ const totalSegments = segmentUrls.length;
+ const chunkSize = 20;
+
+
+ const segments = new Map();
+
+
+ for (let i = 0; i < totalSegments; i += chunkSize) {
+ const batch = segmentUrls.slice(i, i + chunkSize);
+
+ await Promise.all(batch.map(async (url, index) => {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Failed to download segment: ${response.status}`);
+ }
+
+ const buffer = await response.arrayBuffer();
+ segments.set(i + index, new Uint8Array(buffer));
+
+ completedSegments++;
+ if (completedSegments % 10 === 0 || completedSegments === totalSegments) {
+ self.postMessage({
+ type: 'progress',
+ completed: completedSegments,
+ total: totalSegments
+ });
+ }
+ } catch (error) {
+ self.postMessage({
+ type: 'error',
+ message: `Erreur segment ${i + index}: ${error.message}`
+ });
+ }
+ }));
+ }
+
+
+ self.postMessage({ type: 'info', message: 'Combinaison des segments...' });
+
+
+ let totalSize = 0;
+ for (const segment of segments.values()) {
+ totalSize += segment.length;
+ }
+
+
+ const combined = new Uint8Array(totalSize);
+ let offset = 0;
+
+
+ const sortedSegments = Array.from(segments.entries()).sort((a, b) => a[0] - b[0]);
+
+
+ for (const [_, segment] of sortedSegments) {
+ combined.set(segment, offset);
+ offset += segment.length;
+ }
+
+ self.postMessage({
+ type: 'complete',
+ data: combined.buffer,
+ filename: `twitch_vod_${vodId}.ts`
+ }, [combined.buffer]);
+
+ } catch (error) {
+ self.postMessage({
+ type: 'error',
+ message: `Erreur: ${error.message}`
+ });
+ }
+};
diff --git a/src/patch_amazonworker.js b/src/patch_amazonworker.js
index aaa86cb..86dbb98 100644
--- a/src/patch_amazonworker.js
+++ b/src/patch_amazonworker.js
@@ -31,35 +31,18 @@ async function isValidQuality(url) {
if (response.ok) {
const data = await response.text();
- if (data.includes(".ts")) {
- // ts files should still use the h264
- return { codec: "avc1.4D001E" };
- }
-
- if (data.includes(".mp4")) {
- // mp4 file use h265, but sometimes h264
- const mp4Request = await fetch(url.replace("index-dvr.m3u8", "init-0.mp4"));
-
- if (mp4Request.ok) {
- const content = await mp4Request.text();
-
- return { codec: content.includes("hev1") ? "hev1.1.6.L93.B0" : "avc1.4D001E" };
- }
-
- return { codec: "hev1.1.6.L93.B0" };
- }
+ return data.includes(".ts");
}
- return null;
+ return false;
}
const oldFetch = self.fetch;
-self.fetch = async function (input, opt) {
- let url = input instanceof Request ? input.url : input.toString();
- let response = await oldFetch(input, opt);
+self.fetch = async function (url, opt) {
+ let response = await oldFetch(url, opt);
+
- // Patch playlist from unmuted to muted segments
if (url.includes("cloudfront") && url.includes(".m3u8")) {
const body = await response.text();
@@ -72,7 +55,7 @@ self.fetch = async function (input, opt) {
const data = await fetchTwitchDataGQL(vodId);
if (data == undefined) {
- return new Response("Unable to fetch twitch data API", { status: 403 });
+ return new Response("Unable to fetch twitch data API", 403);
}
const vodData = data.data.video;
@@ -110,7 +93,7 @@ self.fetch = async function (input, opt) {
let ordered_resolutions = {};
- for (const key in sorted_dict) {
+ for (key in sorted_dict) {
ordered_resolutions[sorted_dict[key]] = resolutions[sorted_dict[key]];
}
@@ -135,8 +118,8 @@ self.fetch = async function (input, opt) {
let startQuality = 8534030;
- for (const [resKey, resValue] of Object.entries(resolutions)) {
- url = undefined;
+ for ([resKey, resValue] of Object.entries(resolutions)) {
+ var url = undefined;
if (broadcastType === "highlight") {
url = `https://${domain}/${vodSpecialID}/${resKey}/highlight-${vodId}.m3u8`;
@@ -152,16 +135,14 @@ self.fetch = async function (input, opt) {
continue;
}
- const result = await isValidQuality(url);
-
- if (result) {
+ if (await isValidQuality(url)) {
const quality = resKey == "chunked" ? resValue.res.split("x")[1] + "p" : resKey;
const enabled = resKey == "chunked" ? "YES" : "NO";
const fps = resValue.fps;
fakePlaylist += `
#EXT-X-MEDIA:TYPE=VIDEO,GROUP-ID="${quality}",NAME="${quality}",AUTOSELECT=${enabled},DEFAULT=${enabled}
-#EXT-X-STREAM-INF:BANDWIDTH=${startQuality},CODECS="${result.codec},mp4a.40.2",RESOLUTION=${resValue.res},VIDEO="${quality}",FRAME-RATE=${fps}
+#EXT-X-STREAM-INF:BANDWIDTH=${startQuality},CODECS="avc1.64002A,mp4a.40.2",RESOLUTION=${resValue.res},VIDEO="${quality}",FRAME-RATE=${fps}
${url}`;
startQuality -= 100;
diff --git a/src/ts-downloader.js b/src/ts-downloader.js
new file mode 100644
index 0000000..06050cf
--- /dev/null
+++ b/src/ts-downloader.js
@@ -0,0 +1,123 @@
+
+
+
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+const readline = require('readline');
+
+
+const m3u8File = process.argv[2];
+if (!m3u8File) {
+ console.error('Usage: node ts-downloader.js ');
+ process.exit(1);
+}
+
+
+const outputDir = path.join(path.dirname(m3u8File), 'segments');
+const outputFile = path.join(path.dirname(m3u8File), path.basename(m3u8File, '.m3u8') + '.ts');
+
+try {
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir);
+ }
+} catch (error) {
+ console.error('Erreur lors de la création du dossier segments:', error);
+ process.exit(1);
+}
+
+
+const content = fs.readFileSync(m3u8File, 'utf8');
+const lines = content.split('\n');
+
+
+const segments = [];
+for (const line of lines) {
+ if (line.trim() && line.indexOf('http') === 0 && line.endsWith('.ts')) {
+ segments.push(line.trim());
+ }
+}
+
+if (segments.length === 0) {
+ console.error('Aucun segment trouvé dans le fichier M3U8');
+ process.exit(1);
+}
+
+console.log(`Nombre total de segments: ${segments.length}`);
+
+
+function downloadSegment(url, outputPath) {
+ return new Promise((resolve, reject) => {
+ https.get(url, (response) => {
+ if (response.statusCode !== 200) {
+ reject(new Error(`Erreur HTTP ${response.statusCode}`));
+ return;
+ }
+
+ const fileStream = fs.createWriteStream(outputPath);
+ response.pipe(fileStream);
+
+ fileStream.on('finish', () => {
+ fileStream.close();
+ resolve();
+ });
+
+ fileStream.on('error', (err) => {
+ fs.unlink(outputPath, () => {});
+ reject(err);
+ });
+ }).on('error', (err) => {
+ reject(err);
+ });
+ });
+}
+
+
+async function downloadAllSegments() {
+
+ const combinedFile = fs.createWriteStream(outputFile);
+
+
+ let completed = 0;
+ const total = segments.length;
+ const updateProgress = () => {
+ const percent = Math.round((completed / total) * 100);
+ readline.clearLine(process.stdout, 0);
+ readline.cursorTo(process.stdout, 0);
+ process.stdout.write(`Progression: ${completed}/${total} (${percent}%)`);
+ };
+
+
+ for (let i = 0; i < segments.length; i++) {
+ const segmentUrl = segments[i];
+ const segmentPath = path.join(outputDir, `segment_${i}.ts`);
+
+ try {
+
+ await downloadSegment(segmentUrl, segmentPath);
+
+
+ const segmentData = fs.readFileSync(segmentPath);
+ combinedFile.write(segmentData);
+
+
+ fs.unlinkSync(segmentPath);
+
+
+ completed++;
+ updateProgress();
+
+ } catch (error) {
+ console.error(`\nErreur lors du téléchargement du segment ${i}:`, error.message);
+
+ }
+ }
+
+ combinedFile.end();
+ console.log(`\nTéléchargement terminé. Fichier enregistré: ${outputFile}`);
+}
+
+
+downloadAllSegments().catch(err => {
+ console.error('\nErreur:', err);
+});
diff --git a/src/twitchnosub.js b/src/twitchnosub.js
index 406dc81..6c85f4b 100644
--- a/src/twitchnosub.js
+++ b/src/twitchnosub.js
@@ -5,9 +5,5 @@ function injectScript(src) {
(document.head || document.documentElement).append(s);
}
-const extensionType = window.chrome !== undefined ? "chrome" : "firefox";
-
-console.log("[TNS] Found extension type : " + extensionType);
-
-injectScript(`src/${extensionType}/app.js`);
-injectScript("src/app.js");
\ No newline at end of file
+injectScript("src/app.js");
+injectScript("src/download.js");
\ No newline at end of file