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 :

+ + + + +
+
+ +
+ `; + + 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