YT Playlists Delete Enhancer

Add a button to remove videos watched with more than X percent from watch later playlist.

目前为 2021-03-10 提交的版本。查看 最新版本

// ==UserScript==
// @name         YT Playlists Delete Enhancer
// @version      1.4.1
// @description  Add a button to remove videos watched with more than X percent from watch later playlist.
// @author       avallete
// @homepage     https://github.com/avallete/yt-playlists-delete-enhancer
// @support      https://github.com/avallete/yt-playlists-delete-enhancer/issues
// @require      https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.8.7/polyfill.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.15/lodash.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/js-sha1/0.6.0/sha1.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/src/js.cookie.min.js
// @grant        none
// @include      *//www.youtube.com/*
// @namespace    https://greasyfork.org/fr/users/70224-avallete
// @noframes     false
// @run-at       document-idle
// @licence      MIT
// ==/UserScript==


class GMScript {

    constructor(ytcfgdata, playlistVideos, playlistName) {
        this.ytcfgdata = ytcfgdata;
        this.playlistVideos = playlistVideos;
        this.playlistName = playlistName;
        this.baseRequestHeaders = {
            "Content-Type": "application/json",
            "X-Goog-Visitor-Id": this.ytcfgdata["VISITOR_DATA"],
            "X-Youtube-Client-Name": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
            "X-Youtube-Client-Version": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
            // Those two are mandatory together to successfully perform request
            "X-Goog-AuthUser": "0",
            "X-Goog-PageId": this.ytcfgdata["DELEGATED_SESSION_ID"],
        };
    }

    // Get the array of "playlistVideoRenderer" either from continuationsItems or playlistVideoListRenderer
    // The last one should contain the continuation token if there is any.
    // Return null otherwise
    getPlaylistContinuationToken(playlistVideoListRendererContents) {
        const lastItem = playlistVideoListRendererContents[playlistVideoListRendererContents.length - 1];
        if (lastItem && lastItem["continuationItemRenderer"]) {
            return _.get(lastItem, "continuationItemRenderer.continuationEndpoint.continuationCommand.token");
        }
        return null;
    }

    enableRemoveButton() {
        const button = document.getElementById("removeVideosEnhancerButton");
        if (button) {
            button.disabled = false;
        }
    }

    disableRemoveButton() {
        const button = document.getElementById("removeVideosEnhancerButton");
        if (button) {
            button.disabled = true;
        }
    }

    // Generate SAPISIDHASH header
    getAuthorizationHeader() {
        const time = Math.floor(Date.now() / 1000);
        const origin = new URL(document.URL).origin;
        const apisid = window.Cookies.get("SAPISID");
        const shash = window.sha1(`${time} ${apisid} ${origin}`);
        return `SAPISIDHASH ${time}_${shash}`;
    }

    getRequestHeaders() {
        return {
            ...this.baseRequestHeaders,
            "Authorization": this.getAuthorizationHeader(),
        }
    }

    async getAllPlaylistVideos() {
        let playlistItems = this.playlistVideos;
        let continuationToken = this.getPlaylistContinuationToken(playlistItems);

        // If there is continuations, it mean that the playlist is not fully loaded,
        // Request additional data until not futher videos to fetch
        while (continuationToken) {
            // Remove the last item from the playlist content wich is not a video but the object with continuation data
            playlistItems.pop()
            const body = {
                "context": {
                    // The only mandatory context are those two client infos
                    "client": {
                        "clientName": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
                        "clientVersion": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
                    }
                },
                "continuation": continuationToken,
            }
            let resp = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${this.ytcfgdata["INNERTUBE_API_KEY"]}`, {
                "credentials": "include",
                "headers": this.getRequestHeaders(),
                "body": JSON.stringify(body),
                "referrer": `https://www.youtube.com/playlist?list=${this.playlistName}`,
                "method": "POST",
                "mode": "cors"
            });
            if (resp.status === 200) {
                const respjson = await resp.json();
                const data = _.get(respjson, "onResponseReceivedActions[0].appendContinuationItemsAction.continuationItems")
                playlistItems = playlistItems.concat(data);
                continuationToken = this.getPlaylistContinuationToken(data);
            }
        }
        return playlistItems;
    }

    async removeVideosFromPlaylist(playlistId, videoIds) {
        const body = {
            actions: videoIds.map((vid) => ({"setVideoId": vid, "action": "ACTION_REMOVE_VIDEO"})),
            "context": {
                // The only mandatory context are those two client infos
                "client": {
                    "clientName": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
                    "clientVersion": this.ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
                }
            },
            params: "CAFAAQ%3D%3D",
            playlistId: playlistId,
        }
        const params = {
            "credentials": "include",
            "headers": this.getRequestHeaders(),
            "referrer": `https://www.youtube.com/playlist?list=${this.playlistName}`,
            "body": JSON.stringify(body),
            "method": "POST",
            "mode": "cors"
        };
        const resp = await fetch(`https://www.youtube.com/youtubei/v1/browse/edit_playlist?key=${this.ytcfgdata["INNERTUBE_API_KEY"]}`, params);
        if (resp.status === 200) {
            return await resp.json();
        }
        return false;
    }

    getVideosIdsToDelete(watchTimeValue, playlistVideos) {
        return playlistVideos
            .filter((itm) => !!_.get({itm}, 'itm.playlistVideoRenderer.thumbnailOverlays'))
            .filter(
                ({playlistVideoRenderer: {thumbnailOverlays: [, overlay]}}) => (
                    // If it's not the second element in array, the videos haven't been played yet
                    overlay.thumbnailOverlayResumePlaybackRenderer
                    && overlay.thumbnailOverlayResumePlaybackRenderer.percentDurationWatched >= watchTimeValue
                )
            )
            // There was a reason for this "setVideoId", it's because they are not the same with videoId
            // And we DO NEED the "sedVideoId" value to perform remove requests.
            .map(({playlistVideoRenderer: vid}) => (vid.setVideoId || vid.videoId));
    }

    async handleRemoveVideosClickedEvent(watchTimeValue) {
        this.disableRemoveButton();
        let idsToDelete = this.getVideosIdsToDelete(watchTimeValue, this.playlistVideos);
        if (idsToDelete.length) {
          const respjson = await this.removeVideosFromPlaylist(this.playlistName, idsToDelete);
          if (respjson.status === "STATUS_SUCCEEDED") {
            idsToDelete.forEach(id => {
              const videoId = this.playlistVideos.filter(v => v.playlistVideoRenderer.setVideoId == id)[0].playlistVideoRenderer.videoId;
              const videoRenderer = document.querySelector(`ytd-playlist-video-renderer a[href^="/watch?v=${videoId}"]#thumbnail`);
              if (videoRenderer) {
                videoRenderer.parentElement.parentElement.parentElement.parentElement.style.display = 'none';
              } else {
                window.location.reload();
              }
            }, this);
          }
        }
        this.enableRemoveButton();
    }

    constructDOM() {
        return document.createRange().createContextualFragment(`
            <div id="yt-remove-video-enhancer-container" class="style-scope ytd-playlist-sidebar-renderer">
                <div class="style-scope ytd-menu-service-item-renderer" role="option" tabindex="0" aria-disabled="false">
                    <p>Remove all videos who has been watched at more or equal X percent</p>
                    <input id="removeVideosEnhancerValue" type="number" min="0" max="100" value="99">
                    <button id="removeVideosEnhancerButton">Remove !</button>
                </div>
            </div>`
        );
    }

    createEventsListeners(DOMFragment) {
        const input = DOMFragment.getElementById("removeVideosEnhancerValue");
        const button = DOMFragment.getElementById("removeVideosEnhancerButton");
        button.addEventListener('click', () => this.handleRemoveVideosClickedEvent(input.value));
    }

    appendDOM(DOMFragment) {
        const container = document.evaluate('//ytd-playlist-sidebar-renderer/div[@id="items"]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
        container.appendChild(DOMFragment);
    }

    run() {
        const domFragment = this.constructDOM();
        this.createEventsListeners(domFragment);
        this.appendDOM(domFragment);
        this.disableRemoveButton();
        this.getAllPlaylistVideos()
            .then((playlistContent) => {
                this.playlistVideos = playlistContent;
                this.enableRemoveButton();
            })
            .catch((err) => {
                console.error(err);
                console.error(this);
            });
    }
}

function cleanupDOM() {
    // Destroy every DOM elements created by the script
    const extendedDOM = document.getElementById("yt-remove-video-enhancer-container");
    if (extendedDOM) {
        extendedDOM.parentNode.removeChild(extendedDOM);
    }
}

async function getFirstPlaylistData(ytcfgdata, playlistName) {
    const url = `https://www.youtube.com/playlist?list=${playlistName}&pbj=1`;
    let resp = await fetch(url, {
        "credentials": "include",
        "headers": {
            "X-YouTube-Client-Name": ytcfgdata["INNERTUBE_CONTEXT_CLIENT_NAME"],
            "X-YouTube-Client-Version": ytcfgdata["INNERTUBE_CONTEXT_CLIENT_VERSION"],
            "X-YouTube-Device": ytcfgdata["DEVICE"],
            "X-Youtube-Identity-Token": ytcfgdata["ID_TOKEN"],
            "X-YouTube-Page-CL": ytcfgdata["PAGE_CL"],
            "X-YouTube-Page-Label": ytcfgdata["PAGE_BUILD_LABEL"],
        },
        "referrer": `https://www.youtube.com/playlist?list=${playlistName}`,
        "method": "GET",
        "mode": "cors"
    });
    const jsondata = await resp.json();
    return _.get({jsondata}, 'jsondata[1].response.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].itemSectionRenderer.contents[0].playlistVideoListRenderer');
}

async function getInitiaPlaylistVideoListRenderer(ytcfgdata, playlistName) {
    return await getFirstPlaylistData(ytcfgdata, playlistName);
}

async function main(playlistName) {
    try {
        // Prefetched initial datas present in the page
        const ytcfgdata = window.ytcfg.data_; // configuration of youtube app containing auth tokens
        const playlistVideoRenderer = await getInitiaPlaylistVideoListRenderer(ytcfgdata, playlistName);

        if (ytcfgdata && playlistVideoRenderer && playlistVideoRenderer.isEditable) {
            const script = new GMScript(ytcfgdata, playlistVideoRenderer.contents || [], playlistName);
            script.run();
        } else {
            console.error('Missing ytconfig or playlist data or playlist is not editable: ', ytcfgdata, playlistVideoRenderer);
        }
    } catch (err) {
        console.error(err);
    }
}


// The following conditions and check are here to mitigate the "virtual" navigation of youtube
// Without this fix, Tampermonkey fail to load our script on youtube without a full page reload.
let url = new URL(window.location.href);
if (url.pathname === '/playlist' && url.searchParams.get("list") !== null) {
    const playlistName = url.searchParams.get("list");
    main(playlistName).catch(console.error);
}

history.pushState = (f => function pushState() {
    let ret = f.apply(this, arguments);
    window.dispatchEvent(new Event('pushstate'));
    window.dispatchEvent(new Event('locationchange'));
    return ret;
})(history.pushState);

history.replaceState = (f => function replaceState() {
    let ret = f.apply(this, arguments);
    window.dispatchEvent(new Event('replacestate'));
    window.dispatchEvent(new Event('locationchange'));
    return ret;
})(history.replaceState);

window.addEventListener('popstate', () => {
    window.dispatchEvent(new Event('locationchange'))
});

window.addEventListener('yt-navigate-finish', () => {
    window.dispatchEvent(new Event('locationchange'))
});

window.addEventListener('locationchange', function () {
    url = new URL(window.location.href);
    if (url.pathname === '/playlist' && url.searchParams.get("list") !== null) {
        const playlistName = url.searchParams.get("list");
        main(playlistName).catch(console.error);
    } else {
        cleanupDOM();
    }
});