// ==UserScript== // @name YouTube: Playlist-Gesamtlänge // @namespace https://git.ponywave.de/Akamaru/Userscripts // @version 1.1 // @description Berechnet die Gesamtlänge einer YouTube-Playlist // @author Akamaru // @match https://www.youtube.com/playlist?* // @icon https://www.google.com/s2/favicons?domain=youtube.com&sz=32 // @grant GM_getValue // @grant GM_setValue // @connect googleapis.com // @updateURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/youtube-playlist-duration.user.js // @downloadURL https://git.ponywave.de/Akamaru/Userscripts/raw/branch/master/youtube-playlist-duration.user.js // ==/UserScript== (function() { 'use strict'; // YouTube API Key (optional) - in localStorage speichern const API_KEY_STORAGE = 'youtube_playlist_api_key'; // Warte bis die Seite geladen ist if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } function init() { // Versuche mehrmals den Button hinzuzufügen let attempts = 0; const maxAttempts = 10; const tryAddButton = () => { attempts++; console.log(`[Playlist Duration] Versuch ${attempts}/${maxAttempts}`); if (addCalculateButton()) { console.log('[Playlist Duration] Button erfolgreich hinzugefügt'); return true; } if (attempts < maxAttempts) { setTimeout(tryAddButton, 1000); } else { console.error('[Playlist Duration] Konnte Button nicht hinzufügen nach', maxAttempts, 'Versuchen'); } return false; }; // Starte erste Versuche setTimeout(tryAddButton, 1000); } function addCalculateButton() { // Prüfe ob Button bereits existiert if (document.getElementById('playlist-duration-button')) { console.log('[Playlist Duration] Button existiert bereits'); return true; } // Prüfe ob wir auf einer echten Playlist-Seite sind (nicht watch) if (!window.location.pathname.includes('/playlist') || !window.location.href.includes('list=')) { console.log('[Playlist Duration] Nicht auf Playlist-Seite'); return false; } console.log('[Playlist Duration] Erstelle Floating Button'); // Erstelle Floating Button (fixiert rechts oben) const button = document.createElement('button'); button.id = 'playlist-duration-button'; button.textContent = '⏱️ Playlist-Länge berechnen'; button.style.cssText = ` position: fixed; top: 80px; right: 20px; z-index: 9998; background: #065fd4; color: white; border: none; border-radius: 18px; padding: 10px 16px; font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: background 0.2s; `; button.addEventListener('mouseenter', () => { button.style.background = '#0656c7'; }); button.addEventListener('mouseleave', () => { button.style.background = '#065fd4'; }); button.addEventListener('click', openModal); document.body.appendChild(button); console.log('[Playlist Duration] Button hinzugefügt'); return true; } function openModal() { // Erstelle Modal-Overlay const overlay = document.createElement('div'); overlay.id = 'playlist-duration-modal'; overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 9999; display: flex; align-items: center; justify-content: center;'; // Erstelle Modal-Content const modal = document.createElement('div'); modal.style.cssText = 'background: var(--yt-spec-base-background, white); padding: 25px; border-radius: 12px; max-width: 600px; width: 90%; box-shadow: 0 4px 16px rgba(0,0,0,0.3); color: var(--yt-spec-text-primary, black); font-size: 16px;'; const title = document.createElement('h2'); title.textContent = 'Playlist-Gesamtlänge'; title.style.cssText = 'margin: 0 0 20px 0; font-size: 24px;'; const statusText = document.createElement('p'); statusText.id = 'duration-status'; statusText.textContent = 'Scrollen und laden der Videos...'; statusText.style.cssText = 'margin: 15px 0; font-size: 16px;'; const infoDiv = document.createElement('div'); infoDiv.id = 'duration-info'; infoDiv.style.cssText = 'margin: 20px 0; display: none;'; const wikiDiv = document.createElement('div'); wikiDiv.id = 'wiki-format'; wikiDiv.style.cssText = 'margin: 20px 0; display: none;'; // API Key Eingabe (ausklappbar) const apiKeySection = document.createElement('div'); apiKeySection.style.cssText = 'margin: 15px 0; padding: 15px; background: rgba(0,0,0,0.05); border-radius: 8px;'; const apiKeyHeader = document.createElement('div'); apiKeyHeader.style.cssText = 'display: flex; align-items: center; justify-content: space-between; cursor: pointer;'; apiKeyHeader.innerHTML = '⚙️ YouTube API Key (optional)▼'; const apiKeyContent = document.createElement('div'); apiKeyContent.id = 'api-key-content'; apiKeyContent.style.cssText = 'display: none; margin-top: 10px;'; const apiKeyInfo = document.createElement('div'); apiKeyInfo.style.cssText = 'font-size: 14px; color: #666; margin-bottom: 8px;'; apiKeyInfo.textContent = 'Mit einem API-Key werden die exakten Upload-Daten automatisch abgerufen.'; const apiKeyInputDiv = document.createElement('div'); apiKeyInputDiv.style.cssText = 'display: flex; gap: 8px; margin-bottom: 8px;'; const apiKeyInput = document.createElement('input'); apiKeyInput.type = 'password'; apiKeyInput.id = 'api-key-input'; apiKeyInput.placeholder = 'API-Key eingeben...'; apiKeyInput.style.cssText = 'flex: 1; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace;'; apiKeyInput.value = GM_getValue(API_KEY_STORAGE, ''); const toggleVisibilityBtn = document.createElement('button'); toggleVisibilityBtn.textContent = '👁️'; toggleVisibilityBtn.style.cssText = 'padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; cursor: pointer; background: white;'; toggleVisibilityBtn.onclick = (e) => { e.preventDefault(); apiKeyInput.type = apiKeyInput.type === 'password' ? 'text' : 'password'; }; const saveApiKeyBtn = document.createElement('button'); saveApiKeyBtn.textContent = 'Speichern'; saveApiKeyBtn.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--filled yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m'; saveApiKeyBtn.style.cssText = 'padding: 6px 12px;'; saveApiKeyBtn.onclick = (e) => { e.preventDefault(); const key = apiKeyInput.value.trim(); if (key) { GM_setValue(API_KEY_STORAGE, key); alert('API-Key gespeichert!'); } else { GM_setValue(API_KEY_STORAGE, ''); alert('API-Key gelöscht!'); } }; apiKeyInputDiv.appendChild(apiKeyInput); apiKeyInputDiv.appendChild(toggleVisibilityBtn); apiKeyInputDiv.appendChild(saveApiKeyBtn); const apiKeyHelp = document.createElement('div'); apiKeyHelp.style.cssText = 'font-size: 13px; color: #999; margin-top: 5px;'; apiKeyHelp.innerHTML = 'Wie erstelle ich einen API-Key? Anleitung anzeigen'; apiKeyContent.appendChild(apiKeyInfo); apiKeyContent.appendChild(apiKeyInputDiv); apiKeyContent.appendChild(apiKeyHelp); apiKeySection.appendChild(apiKeyHeader); apiKeySection.appendChild(apiKeyContent); // Toggle für API-Key Sektion apiKeyHeader.onclick = () => { const isOpen = apiKeyContent.style.display === 'block'; apiKeyContent.style.display = isOpen ? 'none' : 'block'; document.getElementById('api-toggle').textContent = isOpen ? '▼' : '▲'; }; // Datum-Eingabefelder const dateInputsDiv = document.createElement('div'); dateInputsDiv.style.cssText = 'margin: 15px 0; padding: 15px; background: rgba(0,0,0,0.05); border-radius: 8px;'; const dateTitle = document.createElement('div'); dateTitle.textContent = 'Zeitraum:'; dateTitle.style.cssText = 'font-weight: bold; margin-bottom: 10px;'; const startDateDiv = document.createElement('div'); startDateDiv.style.cssText = 'margin: 8px 0;'; const startDateLabel = document.createElement('label'); startDateLabel.textContent = 'Start-Datum: '; startDateLabel.style.marginRight = '8px'; const startDateInput = document.createElement('input'); startDateInput.type = 'date'; startDateInput.id = 'start-date-input'; startDateInput.style.cssText = 'padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;'; startDateDiv.appendChild(startDateLabel); startDateDiv.appendChild(startDateInput); const endDateDiv = document.createElement('div'); endDateDiv.style.cssText = 'margin: 8px 0;'; const endDateLabel = document.createElement('label'); endDateLabel.textContent = 'End-Datum: '; endDateLabel.style.marginRight = '8px'; const endDateInput = document.createElement('input'); endDateInput.type = 'date'; endDateInput.id = 'end-date-input'; endDateInput.style.cssText = 'padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;'; endDateDiv.appendChild(endDateLabel); endDateDiv.appendChild(endDateInput); dateInputsDiv.appendChild(dateTitle); dateInputsDiv.appendChild(startDateDiv); dateInputsDiv.appendChild(endDateDiv); const wikiCheckboxDiv = document.createElement('div'); wikiCheckboxDiv.style.cssText = 'margin: 10px 0;'; const finishedCheckbox = document.createElement('input'); finishedCheckbox.type = 'checkbox'; finishedCheckbox.id = 'series-finished'; finishedCheckbox.style.marginRight = '8px'; const finishedLabel = document.createElement('label'); finishedLabel.htmlFor = 'series-finished'; finishedLabel.textContent = 'Serie beendet'; wikiCheckboxDiv.appendChild(finishedCheckbox); wikiCheckboxDiv.appendChild(finishedLabel); const wikiTextarea = document.createElement('textarea'); wikiTextarea.id = 'wiki-output'; wikiTextarea.readOnly = true; wikiTextarea.style.cssText = 'width: 100%; height: 150px; font-family: monospace; font-size: 14px; padding: 10px; border: 1px solid #ccc; border-radius: 4px; resize: vertical;'; const copyBtn = document.createElement('button'); copyBtn.textContent = 'Wiki-Format kopieren'; copyBtn.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--filled yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m'; copyBtn.style.cssText = 'margin-top: 10px; margin-right: 10px;'; copyBtn.onclick = () => { wikiTextarea.select(); document.execCommand('copy'); copyBtn.textContent = 'Kopiert!'; setTimeout(() => copyBtn.textContent = 'Wiki-Format kopieren', 2000); }; wikiDiv.appendChild(apiKeySection); wikiDiv.appendChild(dateInputsDiv); wikiDiv.appendChild(wikiCheckboxDiv); wikiDiv.appendChild(wikiTextarea); wikiDiv.appendChild(copyBtn); // API-Help Link Handler setTimeout(() => { document.getElementById('api-help-link')?.addEventListener('click', (e) => { e.preventDefault(); showApiHelp(); }); }, 100); const closeBtn = document.createElement('button'); closeBtn.textContent = 'Schließen'; closeBtn.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m'; closeBtn.style.cssText = 'margin-top: 20px;'; closeBtn.onclick = () => overlay.remove(); modal.appendChild(title); modal.appendChild(statusText); modal.appendChild(infoDiv); modal.appendChild(wikiDiv); modal.appendChild(closeBtn); overlay.appendChild(modal); document.body.appendChild(overlay); // Schließe Modal bei Klick auf Overlay overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); // Starte Berechnung calculateDuration(finishedCheckbox, startDateInput, endDateInput); } async function calculateDuration(finishedCheckbox, startDateInput, endDateInput) { const statusText = document.getElementById('duration-status'); const infoDiv = document.getElementById('duration-info'); const wikiDiv = document.getElementById('wiki-format'); const wikiOutput = document.getElementById('wiki-output'); // Automatisches Scrollen bis alle Videos geladen sind statusText.textContent = 'Scrolle bis alle Videos geladen sind...'; await autoScrollToEnd(); statusText.textContent = 'Berechne Gesamtlänge...'; // Sammle alle Video-IDs und Dauern const videoData = new Map(); // Map um Duplikate zu vermeiden const videoElements = document.querySelectorAll('ytd-playlist-video-renderer'); videoElements.forEach(element => { // Extrahiere Video-ID const videoLink = element.querySelector('a#video-title'); if (!videoLink) return; const videoId = new URLSearchParams(videoLink.href.split('?')[1]).get('v'); if (!videoId) return; // Extrahiere Dauer const durationElement = element.querySelector('ytd-thumbnail-overlay-time-status-renderer span'); if (!durationElement) return; const durationText = durationElement.textContent.trim(); if (!durationText) return; // Speichere nur einzigartige Videos if (!videoData.has(videoId)) { videoData.set(videoId, durationText); } }); // Versuche Daten via API zu holen, falls API-Key vorhanden const apiKey = GM_getValue(API_KEY_STORAGE, ''); let oldestDate = null; let newestDate = null; if (apiKey) { statusText.textContent = 'Lade Upload-Daten von YouTube API...'; const playlistId = new URLSearchParams(window.location.search).get('list'); const dates = await fetchPlaylistDates(playlistId, apiKey); if (dates && dates.length > 0) { dates.sort((a, b) => a - b); oldestDate = dates[0]; newestDate = dates[dates.length - 1]; console.log('[Playlist Duration] API-Daten erfolgreich geladen:', { oldestDate, newestDate }); } else { console.log('[Playlist Duration] API-Aufruf fehlgeschlagen oder keine Daten'); } } // Berechne Gesamtdauer let totalMinutes = 0; videoData.forEach((durationText) => { const parts = durationText.split(':').map(Number); let seconds = 0; if (parts.length === 3) { seconds = parts[0] * 3600 + parts[1] * 60 + parts[2]; } else if (parts.length === 2) { seconds = parts[0] * 60 + parts[1]; } totalMinutes += Math.ceil(seconds / 60); // Aufrunden auf volle Minuten }); const videoCount = videoData.size; // Zeige Informationen statusText.style.display = 'none'; infoDiv.style.display = 'block'; infoDiv.innerHTML = `
Videos: ${videoCount} Folgen
Gesamtlänge: ~${totalMinutes} Minuten
`; // Setze Daten: API-Daten falls vorhanden, sonst heutiges Datum if (oldestDate && newestDate) { startDateInput.valueAsDate = oldestDate; endDateInput.valueAsDate = newestDate; } else { const today = new Date(); startDateInput.valueAsDate = today; endDateInput.valueAsDate = today; } // Generiere Wiki-Format wikiDiv.style.display = 'block'; const updateWiki = () => { generateWikiFormat(videoCount, totalMinutes, finishedCheckbox, wikiOutput, startDateInput, endDateInput); }; updateWiki(); // Update Wiki-Format bei Änderungen finishedCheckbox.addEventListener('change', updateWiki); startDateInput.addEventListener('change', updateWiki); endDateInput.addEventListener('change', updateWiki); } function generateWikiFormat(videoCount, totalMinutes, finishedCheckbox, wikiOutput, startDateInput, endDateInput) { const playlistUrl = window.location.href; // Verwende eingegebene Daten const startDate = startDateInput.value ? parseDateInput(startDateInput.value) : formatDate(new Date()); const endDate = finishedCheckbox.checked ? (endDateInput.value ? parseDateInput(endDateInput.value) : formatDate(new Date())) : null; const finishedClass = finishedCheckbox.checked ? ' lp-finished' : ''; // Wiki-Text erstellen let wikiLines = []; wikiLines.push(`|class="dates"| {{SortKey|${startDate.iso}|${startDate.display}}}`); // Nur End-Datum Zeile hinzufügen, wenn Serie beendet if (finishedCheckbox.checked && endDate) { wikiLines.push(`|class="dates${finishedClass}"| {{SortKey|${endDate.iso}|${endDate.display}}}`); } wikiLines.push(`|class="episodes"| {{nts|${videoCount}}} Folgen`); wikiLines.push(`|class="length"| ~ {{nts|${totalMinutes}}} Minuten`); wikiLines.push(`|class="link"|Gehe zu: https://console.cloud.google.com/
Hinweis: Die YouTube API hat ein kostenloses Kontingent von 10.000 Einheiten/Tag. Eine Playlist-Abfrage kostet ca. 1-3 Einheiten pro 50 Videos.
`; helpOverlay.appendChild(helpModal); document.body.appendChild(helpOverlay); helpOverlay.addEventListener('click', (e) => { if (e.target === helpOverlay || e.target.id === 'close-help') { helpOverlay.remove(); } }); } })();