YouTube: iCal Calendar Export for Livestreams and Premieres

Adds a "Export to iCal" button to to YouTube Livestreams and Premieres which creates Calendar-compatible .ics files.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name            YouTube: iCal Calendar Export for Livestreams and Premieres
// @namespace       org.sidneys.userscripts
// @homepage        https://gist.githubusercontent.com/sidneys/293fe8e9c3afdf50fe1db5be9346ac5a/raw/
// @version         0.7.4
// @description     Adds a "Export to iCal" button to to YouTube Livestreams and Premieres which creates Calendar-compatible .ics files.
// @author          sidneys
// @icon            https://www.youtube.com/favicon.ico
// @noframes
// @match           http*://www.youtube.com/*
// @require         https://greasyfork.org/scripts/38888-greasemonkey-color-log/code/Greasemonkey%20%7C%20Color%20Log.js
// @require         https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js
// @require         https://cdn.jsdelivr.net/npm/[email protected]/moment.min.js
// @require         https://cdn.jsdelivr.net/npm/[email protected]/src/FileSaver.js
// @require         https://cdn.jsdelivr.net/npm/[email protected]/dist/umd/uuidv4.min.js
// @require         https://gitcdn.link/cdn/jamesbrond/ics.js/0b27e3cca5670758b63e880de9e49207d1f12290/ics.js
// @run-at          document-start
// @grant           unsafeWindow
// ==/UserScript==


/**
 * ESLint
 * @global
 */
/* global Debug, onElementReady, uuidv4, ics */
Debug = false


/**
 * API Credentials
 * @default
 * @constant
 */
const apiKey = 'AIzaSyAxkkQLcQcshBDog7ev3jvjZmsjdDycgsQ'

/**
 * API URL
 * @constant
 */
const apiBaseUrl = 'https://youtube.googleapis.com/youtube/v3'
const apiEndpoint = '/videos'
const apiBaseQuery = `part=snippet,liveStreamingDetails&key=${apiKey}`


/**
 * Applicable URL paths
 * @default
 * @constant
 */
const urlPathList = [
    '/channel',
    '/watch'
]

/**
 * Local Filename of iCalendar entry
 * @constant
 */
const fileNameBase = 'youtube-calendar-event-'
const fileExtension = 'ics'


/**
 * Create iCal Calendar Event
 * @param {String} subject - Subject/Title
 * @param {String} description - Description
 * @param {String} location - Location
 * @param {String} begin - Beginning date
 * @param {String} end - Ending date
 * @param {Object=} rrule - Recurrence rule
 * @param {String=} filename - Local iCalendar File Name
 * @param {String=} extension - Local iCalendar File Extension
 */
let createCalendarEvent = (subject, description, location, begin, end, filename, extension = fileExtension) => {
    console.debug('createCalendarEvent')

    // Create iCal entry
    const icalEntry = new ics(uuidv4(), 'Calendar')

    // Add calendar event
    // icalEntry.addEvent(subject, description, location, begin, end, rrule, url)
    icalEntry.addEvent(subject, description, location, false, begin, end)

    // DEBUG
    console.debug('New iCalendar entry:')
    console.debug('filename:', filename)
    console.debug('extension:', extension)
    console.debug('subject:', subject)
    console.debug('location:', location)
    console.debug('begin:', (new Date(begin)).toString())
    console.debug('end:', (new Date(end)).toString())
    console.debug('description:', `${description.substring(0, 50)}…`)

    // Download .ics file
    icalEntry.download(filename, extension)
}


/**
 * On Button Click
 */
let onClickButton = () => {
    console.debug('onClickButton')

    // Lookup YouTube video Id
    const videoId = document.querySelector('ytd-watch-flexy').getAttribute('video-id')

    // Construct API URL for request
    const apiUrl = `${apiBaseUrl}${apiEndpoint}?${apiBaseQuery}&id=${videoId}`

    // DEBUG
    console.debug('videoId', videoId)
    console.debug('apiUrl', apiUrl)

    fetch(apiUrl)
        .then(res => res.json())
        .then(data => {
            console.debug('data', data)
            const json = data
            const snippet = json?.items[0].snippet
            const liveStreamingDetails = json?.items[0].liveStreamingDetails

            if (!snippet) {
                console.error('API Error', 'Video:', 'snippet not found.')
                return
            }

            if (!liveStreamingDetails) {
                console.error('API Error', 'Video:', 'liveStreamingDetails not found.')
                return
            }

            // Calculate start & end time
            const startTimestamp = liveStreamingDetails.actualStartTime || liveStreamingDetails.scheduledStartTime
            const startDate = new Date(startTimestamp)
            const defaultEndDate = new Date(startDate.setSeconds(startDate.getSeconds() + 1800))
            const defaultEndTimestamp = defaultEndDate.toISOString()
            const endTimestamp = liveStreamingDetails.actualEndTime || defaultEndTimestamp

            // Format metadata
            const subject = snippet.title.trim()
            const description = snippet.description.trim()
            const location = snippet.channelTitle.trim()
            const begin = startTimestamp
            const end = endTimestamp

            // Add custom metadata
            const url = `https://youtu.be/${videoId}`
            const urlAndDescription = `Link:\n${url}\n\n${description}`
            const filename = `${fileNameBase}${videoId}`

            // Create calendar event
            // createCalendarEvent(subject, description, location, begin, end, null, url, filename)
            createCalendarEvent(subject, urlAndDescription, location, begin, end, filename)
        })
}

/**
 * Render Button 'Add to Playlist'
 * @param {Element} element - Target Element
 */
let renderButton = (element) => {
    console.debug('renderButton')

    // Create button element
    const buttonElement = document.createElement('div')
    buttonElement.innerHTML =
    `
    <button class="ytp-offline-slate-button ytp-button">
        <div class="ytp-offline-slate-button-icon">
            <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#fff"><path d="M0 0h24v24H0z" fill="none"/>
                <path d="M17 12h-5v5h5v-5zM16 1v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-1V1h-2zm3 18H5V8h14v11z"/>
            </svg>
        </div>
        <div class="ytp-offline-slate-button-text">Export to iCal Calendar (.ics)</div>
    </button>
    `

    // Add button element
    element.after(buttonElement)

    // Handle button click
    buttonElement.onclick = onClickButton

    // Status
    console.debug('rendered button')
}


/**
 * Init
 */
let init = () => {
    console.info('init')

    // Verify URL path
    if (!urlPathList.some(urlPath => window.location.pathname.startsWith(urlPath))) { return }

    // Wair for container
    onElementReady('.ytp-offline-slate-buttons', false, (element) => {
        // Render button
        renderButton(element)
    })
}


/**
 * Handle in-page navigation (modern YouTube)
 * @listens window:Event#yt-navigate-finish
 */
window.addEventListener('yt-navigate-finish', () => {
    console.debug('window#yt-navigate-finish')

    init()
})