Greasy Fork is available in English.

为Alist生成M3U8播放列表文件

为Alist中的视频文件生成并上传或下载一个M3U8播放列表

// ==UserScript==
// @name         为Alist生成M3U8播放列表文件
// @namespace    createM3U8forAlist.whatGUI
// @version      2024-12-23
// @description  为Alist中的视频文件生成并上传或下载一个M3U8播放列表
// @author       whatGUI
// @match        http://*/*
// @match        https://*/*
// @icon         https://alist.nn.ci/favicon.ico
// @license      MIT

// ==/UserScript==

(function () {
    "use strict";
    function addCSS() {
        const style = document.createElement("style");
        style.textContent = `
            .m3u8-toolbar {
              position: fixed;
              right: 65px;
              bottom: 20px;
            }
    
            .m3u8-toolbar-icon {
              width: 2rem;
              height: 2rem;
              color: #ff8718;
              padding: 4px;
              border-radius: 0.5rem;
              cursor: pointer;
              margin-top: 0.25rem;
            }
            .m3u8-toolbar-icon:hover {
              color: #ffffff;
              background-color: #ff8718;
            }
    
            .m3u8-dialog {
              position: fixed;
              z-index: 9999;
              opacity: 0;
              animation: fadeIn 0.3s forwards;
            }
            .m3u8-dialog-overlay {
              position: fixed;
              top: 0;
              left: 0;
              width: 100%;
              height: 100%;
              background-color: rgba(0, 0, 0, 0.65);
            }
    
            .m3u8-dialog-content {
              position: fixed;
              top: 50%;
              left: 50%;
              transform: translate(-50%, -50%);
              background: white;
              padding: 20px;
              border-radius: 0.5rem;
              box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
              max-width: 90%;
              width: 24rem;
              text-align: center;
            }
    
            .m3u8-dialog-content h2 {
              margin-bottom: 1rem;
              font-size: 1.5em;
              color: #11181c;
            }
    
            .m3u8-dialog-content button {
              background-color: #ffe5cc;
              color: #ff8718;
              border: none;
              padding: 10px 20px;
              margin: 10px;
              border-radius: 5px;
              cursor: pointer;
              font-size: 1em;
            }
    
            .m3u8-dialog-content button:hover {
              background-color: #ffd1a3;
            }
    
            .m3u8-dialog-content button#closeDialog {
              background-color: #eceef0;
              color: #11181c;
            }
    
            .m3u8-dialog-content button#closeDialog:hover {
              background-color: #e6e8eb;
            }
    
            @keyframes fadeIn {
              to {
                opacity: 1;
              }
            }
    
            @keyframes fadeOut {
              from {
                opacity: 1;
              }
              to {
                opacity: 0;
              }
            }
    
            .fade-out {
                animation: fadeOut 0.3s forwards;
            }
        `;
        document.head.appendChild(style);
    }

    function addButton() {
        const buttonDiv = document.createElement("div");
        buttonDiv.className = "m3u8-toolbar";
        buttonDiv.innerHTML = `<svg fill="none" stroke-width="0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="m3u8-toolbar-icon" height="1em" width="1em" style="overflow: visible;"><path fill="currentColor" d="M7 14a2 2 0 100-4 2 2 0 000 4zM14 12a2 2 0 11-4 0 2 2 0 014 0zM17 14a2 2 0 100-4 2 2 0 000 4z"></path><path fill="currentColor" fill-rule="evenodd" d="M24 12c0 6.627-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0s12 5.373 12 12zm-2 0c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10z" clip-rule="evenodd"></path></svg>`;
        document.body.appendChild(buttonDiv);
        buttonDiv.addEventListener("click", addDialog);

        const showBtn = localStorage.getItem("more-open") === "true";
        buttonDiv.style.display = showBtn ? "block" : "none";

        waitForElement(".left-toolbar-box", (element) => {
            element.addEventListener("click", (e) => {
                const svgElement = e.target.closest("svg");
                if (svgElement) {
                    if (svgElement.getAttribute("tips") === "more") {
                        buttonDiv.style.display = "none";
                    } else if (
                        svgElement.classList.contains("toolbar-toggle")
                    ) {
                        buttonDiv.style.display = "block";
                    }
                }
            });
        });
    }

    function waitForElement(
        selector,
        callback,
        waitTime = 250,
        maxAttempts = 10
    ) {
        let attempts = 0;
        const interval = setInterval(() => {
            const element = document.querySelector(selector);
            if (element) {
                console.log("Element is now available.");
                clearInterval(interval);
                callback(element);
            } else if (attempts >= maxAttempts) {
                console.log("Element not found after maximum attempts.");
                clearInterval(interval);
            }
            attempts++;
        }, waitTime);
    }

    const DIALOG_HTML = `
        <div class="m3u8-dialog-overlay"></div>
        <div class="m3u8-dialog-content">
            <h2>✨生成M3U8播放列表✨</h2>
            <div>
                <h3>仅当前文件夹内容</h3>
                <button id="uploadM3U8Current">上传m3u8</button>
                <button id="downloadM3U8Current">下载m3u8</button>
            </div>
            <div>
                <h3>当前文件夹及其所有子文件夹内容</h3>
                <button id="uploadM3U8All">上传m3u8</button>
                <button id="downloadM3U8All">下载m3u8</button>
            </div>
            <button id="closeDialog">关闭</button>
        </div>
    `;

    function addDialog() {
        const dialogDiv = document.createElement("div");
        dialogDiv.className = "m3u8-dialog";
        dialogDiv.innerHTML = DIALOG_HTML;

        document.body.appendChild(dialogDiv);

        function removeDialog() {
            dialogDiv.classList.add("fade-out");
            dialogDiv.addEventListener(
                "animationend",
                () => dialogDiv.remove(),
                {
                    once: true,
                }
            );
        }

        dialogDiv.addEventListener("click", (event) => {
            if (
                event.target.classList.contains("m3u8-dialog-overlay") ||
                event.target.id === "closeDialog"
            ) {
                removeDialog();
            } else if (event.target.id === "uploadM3U8Current") {
                event.target.innerText = "执行中...";
                uploadM3U8(false).then(removeDialog);
            } else if (event.target.id === "downloadM3U8Current") {
                event.target.innerText = "执行中...";
                downloadM3U8(false).then(removeDialog);
            } else if (event.target.id === "uploadM3U8All") {
                event.target.innerText = "执行中...";
                uploadM3U8(true).then(removeDialog);
            } else if (event.target.id === "downloadM3U8All") {
                event.target.innerText = "执行中...";
                downloadM3U8(true).then(removeDialog);
            }
        });
    }

    async function uploadM3U8(includeSubfolders) {
        try {
            let fileList = await getFileList(includeSubfolders);
            let m3u8Blob = generateM3U8(fileList);
            await sendM3U8ToAlist(m3u8Blob.blob);
            clickRefreshBtn();
        } catch (error) {
            alert(error.message);
        }
    }

    async function downloadM3U8(includeSubfolders) {
        try {
            let files = await getFileList(includeSubfolders);
            let m3u8Blob = generateM3U8(files);
            // 创建一个隐藏的 <a> 标签
            const link = document.createElement("a");
            link.href = m3u8Blob.href;
            link.download = "playlist.m3u8";
            link.style.display = "none";
            document.body.appendChild(link);
            // 触发点击事件来下载文件
            link.click();
            // 清除元素
            document.body.removeChild(link);
        } catch (error) {
            alert(error.message);
        }
    }

    async function getFileList(includeSubfolders) {
        const folderPath = decodeURIComponent(window.location.pathname);
        const result = await fetchFilesInfo(folderPath);

        let fileList = [];
        let foldersToProcess = [];

        result.data?.content.forEach((file) => {
            if (!file.is_dir) {
                fileList.push({
                    name: file.name,
                    url: `${window.location.origin}/d${folderPath}/${file.name}?sign=${file.sign}`,
                });
            } else if (includeSubfolders) {
                foldersToProcess.push(folderPath + "/" + file.name);
            }
        });

        while (foldersToProcess.length > 0) {
            const currentFolderPath = foldersToProcess.shift();
            const subfolderResult = await fetchFilesInfo(currentFolderPath);

            subfolderResult.data?.content.forEach((file) => {
                if (!file.is_dir) {
                    fileList.push({
                        name: file.name,
                        url: `${window.location.origin}/d${currentFolderPath}/${file.name}?sign=${file.sign}`,
                    });
                } else {
                    foldersToProcess.push(currentFolderPath + "/" + file.name);
                }
            });
        }
        return fileList;
    }

    async function fetchFilesInfo(decodedPath) {
        const alistListAPI = "/api/fs/list";
        const alistToken = localStorage.getItem("token");

        if (!alistToken) {
            throw new Error("未找到Token,请先登录Alist后再试");
        }

        const headers = new Headers({
            Authorization: alistToken,
            "Content-Type": "application/json",
        });

        const body = JSON.stringify({
            path: decodedPath,
            password: "",
            page: 1,
            per_page: 0,
            refresh: false,
        });

        const requestOptions = {
            method: "POST",
            headers,
            body,
            redirect: "follow",
        };
        const response = await fetch(alistListAPI, requestOptions);
        return await response.json();
    }

    function checkIfMediaFile(filename) {
        // 定义常见的影音文件扩展名
        const mediaExtensions = [
            ".mp4",
            ".mkv",
            ".mov",
            ".avi",
            ".flv",
            ".wmv",
            ".webm",
        ];
        // 获取文件扩展名
        const extension = filename.slice(
            ((filename.lastIndexOf(".") - 1) >>> 0) + 2
        );
        // 检查扩展名是否在常见的影音类型列表中
        return mediaExtensions.includes("." + extension.toLowerCase());
    }

    function generateM3U8(fileList) {
        if (fileList.length === 0) {
            throw new Error("m3u8生成失败:当前页面没有文件");
        }
        let m3u8Content = "#EXTM3U\n";
        let videoCount = 0;
        fileList.forEach((file) => {
            if (checkIfMediaFile(file.name)) {
                videoCount++;
                m3u8Content += `#EXTINF:-1,${file.name}\n${file.url}\n`;
            }
        });

        if (videoCount === 0) {
            throw new Error("m3u8生成失败:当前页面没有音视频文件");
        }
        // 创建一个新的 Blob 对象,将 M3U8 内容包装起来
        const blob = new Blob([m3u8Content], { type: "application/x-mpegURL" });
        // 创建一个下载链接
        const href = URL.createObjectURL(blob);
        return { blob, href };
    }

    async function sendM3U8ToAlist(blob) {
        const alistUploadAPI = "/api/fs/put";
        const alistToken = localStorage.getItem("token");
        const currentURL = decodeURIComponent(window.location.pathname);
        const path = encodeURIComponent(currentURL + "/playlist.m3u8");
        // 设置请求头
        const headers = new Headers({
            Authorization: alistToken,
            "File-Path": path, // 注意路径需要 URL 编码
            "Content-Type": "application/x-mpegURL", // M3U8 文件的 Content-Type
            "Content-Length": blob.size.toString(),
            As_Task: "false", // 可选,是否作为任务
        });
        // 创建请求体
        const body = blob;
        const response = await fetch(alistUploadAPI, {
            method: "PUT",
            headers,
            body,
        });
        if (!response.ok) {
            throw new Error(`上传失败: ${response.statusText}`);
        }
    }

    function clickRefreshBtn() {
        let toggleBtn = document.querySelector("svg.toolbar-toggle");
        if (toggleBtn) {
            toggleBtn.$$click();
            let refreshBtn = document.querySelector('svg[tips="refresh"]');
            refreshBtn.$$click();
            let moreBtn = document.querySelector('svg[tips="more"]');
            moreBtn.$$click();
        } else {
            let refreshBtn = document.querySelector('svg[tips="refresh"]');
            refreshBtn.$$click();
        }
    }

    addCSS();
    addButton();
})();