diff --git a/README.md b/README.md new file mode 100644 index 0000000..10f6a6c --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# MTH Video Manifest Capture + +A Chrome extension that captures video manifest URLs (e.g., HLS `.m3u8`, MPEG-DASH `.mpd`, and SharePoint DASH manifests) from web pages, processes them, and generates FFmpeg and yt-dlp commands for downloading. It also includes a simple UI for customization and a button to download or open the manifest URL directly. + +## Overview + +**MTH Video Manifest Capture** is designed to assist users in capturing and processing video streaming manifests, particularly for SharePoint, HLS, and DASH formats. The extension provides a user-friendly popup interface to view cleaned manifest URLs, select quality and format options, set custom filenames, and copy commands for FFmpeg and yt-dlp to download the video content. It also includes a **"Download Manifest"** button to open the cleaned manifest URL in a new tab or initiate a basic download. + +This extension is built using Chrome's **Manifest V3**, ensuring modern security and performance standards. + +## Features + +- **Captures video manifest URLs** (`.m3u8`, `.mpd`, and SharePoint `/transform/videomanifest` with `format=dash`). +- **Cleans URLs** by removing unnecessary parameters (e.g., after `format=dash`). +- **Generates customizable FFmpeg and yt-dlp commands** with options for quality (Best, 1080p, 720p, 480p) and format (MP4, MKV, TS). +- **Allows custom output filenames** or uses a timestamp-based default (e.g., `video_YYYYMMDD_HHMMSS`). +- **"Download Manifest" button** to open the cleaned manifest URL in a new tab or initiate a basic download of the manifest file. +- **Toggle buttons** to expand/collapse long URLs and commands for better readability. +- **Copy buttons** for FFmpeg and yt-dlp commands to easily paste into a terminal. + +## Installation + +### Prerequisites + +- Google Chrome or Chromium browser (**Manifest V3 compatible, version 88 or later**). +- FFmpeg and yt-dlp installed on your system (**optional, for running the generated commands**). + +### Steps + +1. **Clone or Download the Repository** + ```bash + git clone https://github.com/MiniduTH/Sharepoint-Downloader.git + ``` + Or download the ZIP file and extract it. + +2. **Load the Extension in Chrome** + - Open Chrome and navigate to `chrome://extensions/`. + - Enable **"Developer mode"** in the top-right corner. + - Click **"Load unpacked"** and select the `Sharepoint-Downloader` folder. + +3. **Install Dependencies** *(optional, for using generated commands)* + - **FFmpeg:** Install via your package manager (e.g., `sudo apt install ffmpeg` on Ubuntu) or download from [ffmpeg.org](https://ffmpeg.org/). + - **yt-dlp:** Install via `pip install yt-dlp` or download from [yt-dlp.org](https://yt-dlp.org/). + +## Usage + +1. **Open a Web Page with Video Manifests** + - Visit a website streaming video content (e.g., SharePoint, HLS, or DASH streams). + +2. **Activate the Extension** + - Click the **"MTH Video Manifest Capture"** icon in the Chrome toolbar. + +3. **Capture Manifests** + - The extension automatically detects and captures video manifest URLs (`.m3u8`, `.mpd`, or SharePoint DASH manifests). + - The cleaned URL, FFmpeg command, and yt-dlp command will appear in the popup. + +4. **Customize Options** + - Use the dropdowns to select video quality (**Best, 1080p, 720p, 480p**) and format (**MP4, MKV, TS**). + - Enter a custom output filename, or leave it blank to use the timestamp-based default (e.g., `video_20250305_143022.mp4`). + +5. **Copy Commands** + - Click **"Copy FFmpeg"** or **"Copy yt-dlp"** to copy the respective commands to your clipboard. + - Paste the commands into a terminal to download the video using FFmpeg or yt-dlp. + +6. **Download Manifest** *(optional)* + - Click **"Download Manifest"** to open the cleaned manifest URL in a new tab or download the manifest file (**note:** this downloads the manifest, not the full video; further processing is required using FFmpeg or yt-dlp). + +## Screenshots + +_Screenshot of the popup interface showing captured manifest, commands, and options._ + +![Screenshot](https://i.ibb.co/wZmMQ04P/Screenshot-2025-03-05-151334.png) + +## Known Limitations + +- **"Download Manifest" only saves the manifest file** (e.g., `.m3u8` or `.mpd`), not the full video. You need tools like **FFmpeg or yt-dlp** to process it. +- **Authenticated manifest URLs** (e.g., SharePoint temp auth tokens) may require user login or additional configuration. +- **Direct video downloads** without external tools are **not supported** due to browser security restrictions. + +## Contributing + +Contributions are welcome! Please follow these steps: + +1. **Fork the repository**. +2. **Create a new branch** for your feature or bug fix: + ```bash + git checkout -b feature/your-feature-name + ``` +3. **Make your changes and commit them**: + ```bash + git commit -m "Add your commit message here" + ``` +4. **Push to the branch**: + ```bash + git push origin feature/your-feature-name + ``` +5. **Submit a pull request** to the main branch. + +## License + +This project is licensed under the **MIT License**. See the `LICENSE` file for details. + +## Contact + +- **Author:** Minidu Weerasinghe +- **GitHub:** [MiniduTH](https://github.com/MiniduTH) +- **LinkedIn:** [linkedin.com/in/minidu0th](https://linkedin.com/in/minidu0th) diff --git a/background.js b/background.js new file mode 100644 index 0000000..76606ce --- /dev/null +++ b/background.js @@ -0,0 +1,75 @@ +console.log('Background script loaded'); + +chrome.webRequest.onBeforeRequest.addListener( + (details) => { + try { + const originalUrl = details.url; + if ( + (originalUrl.includes('/transform/videomanifest') && originalUrl.includes('format=dash')) || + originalUrl.endsWith('.m3u8') || + originalUrl.endsWith('.mpd') + ) { + console.log('Video manifest detected:', originalUrl); + + let cleanedUrl = originalUrl; + let type = 'unknown'; + if (originalUrl.includes('/transform/videomanifest') && originalUrl.includes('format=dash')) { + const index = originalUrl.indexOf('format=dash'); + cleanedUrl = originalUrl.substring(0, index + 'format=dash'.length); + type = 'dash'; + } else if (originalUrl.endsWith('.mpd')) { + const index = originalUrl.indexOf('.mpd') + '.mpd'.length; + cleanedUrl = originalUrl.substring(0, index); + type = 'dash'; + } else if (originalUrl.endsWith('.m3u8')) { + const index = originalUrl.indexOf('.m3u8') + '.m3u8'.length; + cleanedUrl = originalUrl.substring(0, index); + type = 'hls'; + } + + const manifestData = { + originalUrl: originalUrl, + cleanedUrl: cleanedUrl, + type: type + }; + chrome.storage.local.set({ lastManifest: manifestData }, (result) => { + if (chrome.runtime.lastError) { + console.error('Error saving manifest:', chrome.runtime.lastError.message); + } else { + console.log('Manifest saved:', manifestData); + } + }); + + chrome.runtime.sendMessage({ type: 'manifestDetected', data: manifestData }, (response) => { + if (chrome.runtime.lastError) { + console.error('Error sending message:', chrome.runtime.lastError.message); + } + }); + } + } catch (error) { + console.error('Error in webRequest listener:', error); + } + }, + { urls: [""] }, + ["requestBody"] +); + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + console.log('Message received:', message); + if (message.type === 'getLastManifest') { + try { + chrome.storage.local.get('lastManifest', (result) => { + if (chrome.runtime.lastError) { + console.error('Error getting manifest:', chrome.runtime.lastError.message); + sendResponse({ data: null }); + } else { + sendResponse({ data: result.lastManifest || null }); + } + }); + } catch (error) { + console.error('Error in message listener:', error); + sendResponse({ data: null }); + } + return true; // Keep the channel open for async response + } +}); \ No newline at end of file diff --git a/icon128.png b/icon128.png new file mode 100644 index 0000000..fe0ee9b Binary files /dev/null and b/icon128.png differ diff --git a/icon16.png b/icon16.png new file mode 100644 index 0000000..e286466 Binary files /dev/null and b/icon16.png differ diff --git a/icon48.png b/icon48.png new file mode 100644 index 0000000..0b33c61 Binary files /dev/null and b/icon48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..d6cdee1 --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "MTH Video Manifest Capture", + "version": "1.0", + "description": "Captures video manifest URLs.", + "permissions": [ + "webRequest", + "activeTab", + "storage" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icon16.png", + "48": "icon48.png", + "128": "icon128.png" + } + } +} \ No newline at end of file diff --git a/popup.css b/popup.css new file mode 100644 index 0000000..689fe9e --- /dev/null +++ b/popup.css @@ -0,0 +1,193 @@ +body { + width: 350px; + padding: 0; + margin: 0; + font-family: 'Arial', sans-serif; + background-color: #f8f9fa; + color: #333; + box-sizing: border-box; +} + +.container { + padding: 15px; +} + +.title { + margin: 0 0 15px; + font-size: 20px; + font-weight: 600; + color: #1a73e8; + text-align: center; +} + +.btn { + display: block; + padding: 10px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s ease, transform 0.1s ease; +} + +.btn.primary { + width: 100%; + background-color: #1a73e8; + color: white; +} + +.btn.primary:hover { + background-color: #1557b0; +} + +.btn.primary:active { + background-color: #0d47a1; + transform: scale(0.98); +} + +.btn.secondary { + background-color: #4caf50; + color: white; +} + +.btn.secondary:hover { + background-color: #388e3c; +} + +.btn.secondary:active { + background-color: #2e7d32; + transform: scale(0.98); +} + +.btn.small { + width: auto; + padding: 6px 12px; + margin-top: 5px; + font-size: 12px; +} + +.btn.toggle { + width: 100%; + background-color: #f5f5f5; + color: #1a73e8; + margin-top: 5px; + border: 1px solid #ddd; +} + +.btn.toggle:hover { + background-color: #e8f0fe; +} + +.btn.toggle:active { + background-color: #d9e6ff; + transform: scale(0.98); +} + +.card { + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 15px; + margin-top: 10px; +} + +.card h2 { + margin: 0 0 10px; + font-size: 16px; + color: #555; + border-bottom: 1px solid #eee; + padding-bottom: 10px; +} + +.manifest-section { + margin-bottom: 15px; +} + +.manifest-section label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #666; + font-size: 14px; +} + +.text-container { + position: relative; + margin-bottom: 5px; +} + +.text { + margin: 0; + padding: 8px; + background-color: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 12px; + line-height: 1.4; + word-wrap: break-word; + max-height: 100px; + overflow-y: hidden; + white-space: nowrap; + text-overflow: ellipsis; + transition: max-height 0.3s ease, white-space 0.3s ease; +} + +.text.expanded { + max-height: 300px; + overflow-y: auto; + white-space: normal; + text-overflow: clip; +} + +.options-section { + margin: 15px 0; + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.options-section label { + font-size: 14px; + color: #555; + margin-right: 5px; +} + +.select, .input { + padding: 6px; + font-size: 13px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: #fff; + cursor: pointer; +} + +.input { + width: 150px; + cursor: text; +} + +.select:focus, .input:focus { + outline: none; + border-color: #1a73e8; + box-shadow: 0 0 0 2px #e8f0fe; +} + +@media (max-width: 350px) { + body { + width: 100%; + } + .container { + padding: 10px; + } + .card { + padding: 10px; + } + .input { + width: 120px; + } + .btn.small { + width: 100%; + padding: 8px; + } +} \ No newline at end of file diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..6179278 --- /dev/null +++ b/popup.html @@ -0,0 +1,73 @@ + + + + Video Manifest Capture + + + + + +
+

Video Manifest Capture

+ + + +
+

Manifest Details

+
+ +
+

No manifest captured yet.

+
+ +
+ +
+ + + + + + + + +
+ +
+ +
+

FFmpeg command will appear here.

+
+ + +
+ +
+ +
+

yt-dlp command will appear here.

+
+ + +
+
+
+ + + + \ No newline at end of file diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..9020e54 --- /dev/null +++ b/popup.js @@ -0,0 +1,249 @@ +document.addEventListener('DOMContentLoaded', () => { + // Get DOM elements with null checks + const manifestUrlDiv = document.getElementById('manifestUrl'); + const ffmpegCommandDiv = document.getElementById('ffmpegCommand'); + const ytdlpCommandDiv = document.getElementById('ytdlpCommand'); + const refreshButton = document.getElementById('refresh'); + const copyFffmpegBtn = document.getElementById('copyFffmpegBtn'); // Fixed typo + const copyYtdlpBtn = document.getElementById('copyYtdlpBtn'); + const qualitySelect = document.getElementById('quality'); + const formatSelect = document.getElementById('format'); + const filenameInput = document.getElementById('filename'); + const toggleUrlBtn = document.getElementById('toggleUrl'); + const toggleFffmpegBtn = document.getElementById('toggleFffmpeg'); // Fixed typo + const toggleYtdlpBtn = document.getElementById('toggleYtdlp'); + + // Check if all elements exist + if (!manifestUrlDiv || !ffmpegCommandDiv || !ytdlpCommandDiv || !refreshButton || !copyFffmpegBtn || + !copyYtdlpBtn || !qualitySelect || !formatSelect || !filenameInput || !toggleUrlBtn || + !toggleFffmpegBtn || !toggleYtdlpBtn) { + console.error('One or more DOM elements not found in popup.html'); + return; + } + + let currentManifestData = null; + + // Function to generate a timestamp-based filename + function generateTimestampFilename() { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + return `video_${year}${month}${day}_${hours}${minutes}${seconds}`; + } + + // Function to generate FFmpeg command + function generateFffmpegCommand(cleanedUrl, type, quality, format) { + const userFilename = filenameInput.value.trim(); + const filename = userFilename || `${generateTimestampFilename()}.${format}`; + return `ffmpeg -i "${cleanedUrl}" -c copy ${filename}`; + } + + // Function to generate yt-dlp command + function generateYtdlpCommand(cleanedUrl, quality, format) { + const userFilename = filenameInput.value.trim(); + const filename = userFilename || `${generateTimestampFilename()}.${format}`; + let qualityParam = ''; + if (quality !== 'best') { + qualityParam = `--recode-video ${format} -f bestvideo[height<=?${quality.replace('p', '')}]+bestaudio/best[height<=?${quality.replace('p', '')}]`; + } else { + qualityParam = `--recode-video ${format} -f bestvideo+bestaudio/best`; + } + return `yt-dlp "${cleanedUrl}" ${qualityParam} -o "${filename}"`; + } + + function updateUI(data) { + if (data) { + currentManifestData = data; + if (manifestUrlDiv) manifestUrlDiv.textContent = data.cleanedUrl; // Add null check + const ffmpegCommand = generateFffmpegCommand( + data.cleanedUrl, + data.type, + qualitySelect.value, + formatSelect.value + ); + const ytdlpCommand = generateYtdlpCommand( + data.cleanedUrl, + qualitySelect.value, + formatSelect.value + ); + if (ffmpegCommandDiv) ffmpegCommandDiv.textContent = ffmpegCommand; // Add null check + if (ytdlpCommandDiv) ytdlpCommandDiv.textContent = ytdlpCommand; // Add null check + if (filenameInput && !filenameInput.value.trim()) { + filenameInput.placeholder = generateTimestampFilename(); + } + } else { + if (manifestUrlDiv) manifestUrlDiv.textContent = 'No manifest captured yet.'; + if (ffmpegCommandDiv) ffmpegCommandDiv.textContent = 'FFmpeg command will appear here.'; + if (ytdlpCommandDiv) ytdlpCommandDiv.textContent = 'yt-dlp command will appear here.'; + if (filenameInput) { + filenameInput.value = ''; + filenameInput.placeholder = 'video_YYYYMMDD_HHMMSS'; + } + } + // Reset toggle states with null checks + if (manifestUrlDiv) manifestUrlDiv.classList.remove('expanded'); + if (ffmpegCommandDiv) ffmpegCommandDiv.classList.remove('expanded'); + if (ytdlpCommandDiv) ytdlpCommandDiv.classList.remove('expanded'); + if (toggleUrlBtn) toggleUrlBtn.textContent = 'Expand'; + if (toggleFffmpegBtn) toggleFffmpegBtn.textContent = 'Expand'; + if (toggleYtdlpBtn) toggleYtdlpBtn.textContent = 'Expand'; + } + + function fetchLastManifest() { + try { + chrome.runtime.sendMessage({ type: 'getLastManifest' }, (response) => { + if (chrome.runtime.lastError) { + console.error('Runtime error:', chrome.runtime.lastError.message); + updateUI(null); + return; + } + updateUI(response.data); + }); + } catch (error) { + console.error('Error sending message:', error); + updateUI(null); + } + } + + // Initial fetch + fetchLastManifest(); + + // Listen for new manifests + chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'manifestDetected') { + updateUI(message.data); + } + }); + + // Add event listeners with null checks + if (refreshButton) { + refreshButton.addEventListener('click', fetchLastManifest); + } + + // Update commands on quality/format/filename change with null checks + if (qualitySelect) { + qualitySelect.addEventListener('change', () => { + if (currentManifestData) { + const ffmpegCommand = generateFffmpegCommand( + currentManifestData.cleanedUrl, + currentManifestData.type, + qualitySelect.value, + formatSelect.value + ); + const ytdlpCommand = generateYtdlpCommand( + currentManifestData.cleanedUrl, + qualitySelect.value, + formatSelect.value + ); + if (ffmpegCommandDiv) ffmpegCommandDiv.textContent = ffmpegCommand; + if (ytdlpCommandDiv) ytdlpCommandDiv.textContent = ytdlpCommand; + } + }); + } + + if (formatSelect) { + formatSelect.addEventListener('change', () => { + if (currentManifestData) { + const ffmpegCommand = generateFffmpegCommand( + currentManifestData.cleanedUrl, + currentManifestData.type, + qualitySelect.value, + formatSelect.value + ); + const ytdlpCommand = generateYtdlpCommand( + currentManifestData.cleanedUrl, + qualitySelect.value, + formatSelect.value + ); + if (ffmpegCommandDiv) ffmpegCommandDiv.textContent = ffmpegCommand; + if (ytdlpCommandDiv) ytdlpCommandDiv.textContent = ytdlpCommand; + } + }); + } + + if (filenameInput) { + filenameInput.addEventListener('change', () => { + if (currentManifestData) { + const ffmpegCommand = generateFffmpegCommand( + currentManifestData.cleanedUrl, + currentManifestData.type, + qualitySelect.value, + formatSelect.value + ); + const ytdlpCommand = generateYtdlpCommand( + currentManifestData.cleanedUrl, + qualitySelect.value, + formatSelect.value + ); + if (ffmpegCommandDiv) ffmpegCommandDiv.textContent = ffmpegCommand; + if (ytdlpCommandDiv) ytdlpCommandDiv.textContent = ytdlpCommand; + } + }); + } + + // Copy FFmpeg button with null check + if (copyFffmpegBtn) { + copyFffmpegBtn.addEventListener('click', () => { + if (ffmpegCommandDiv) { + const commandText = ffmpegCommandDiv.textContent; + navigator.clipboard.writeText(commandText).then(() => { + copyFffmpegBtn.textContent = 'Copied!'; + setTimeout(() => copyFffmpegBtn.textContent = 'Copy FFmpeg', 2000); + }).catch((err) => { + console.error('Failed to copy FFmpeg:', err); + copyFffmpegBtn.textContent = 'Copy Failed'; + }); + } + }); + } + + // Copy yt-dlp button with null check + if (copyYtdlpBtn) { + copyYtdlpBtn.addEventListener('click', () => { + if (ytdlpCommandDiv) { + const commandText = ytdlpCommandDiv.textContent; + navigator.clipboard.writeText(commandText).then(() => { + copyYtdlpBtn.textContent = 'Copied!'; + setTimeout(() => copyYtdlpBtn.textContent = 'Copy yt-dlp', 2000); + }).catch((err) => { + console.error('Failed to copy yt-dlp:', err); + copyYtdlpBtn.textContent = 'Copy Failed'; + }); + } + }); + } + + // Toggle URL visibility with null check + if (toggleUrlBtn) { + toggleUrlBtn.addEventListener('click', () => { + if (manifestUrlDiv) { + manifestUrlDiv.classList.toggle('expanded'); + toggleUrlBtn.textContent = manifestUrlDiv.classList.contains('expanded') ? 'Collapse' : 'Expand'; + } + }); + } + + // Toggle FFmpeg visibility with null check + if (toggleFffmpegBtn) { + toggleFffmpegBtn.addEventListener('click', () => { + if (ffmpegCommandDiv) { + ffmpegCommandDiv.classList.toggle('expanded'); + toggleFffmpegBtn.textContent = ffmpegCommandDiv.classList.contains('expanded') ? 'Collapse' : 'Expand'; + } + }); + } + + // Toggle yt-dlp visibility with null check + if (toggleYtdlpBtn) { + toggleYtdlpBtn.addEventListener('click', () => { + if (ytdlpCommandDiv) { + ytdlpCommandDiv.classList.toggle('expanded'); + toggleYtdlpBtn.textContent = ytdlpCommandDiv.classList.contains('expanded') ? 'Collapse' : 'Expand'; + } + }); + } +}); \ No newline at end of file