diff --git a/youtube-playlist-duration.user.js b/youtube-playlist-duration.user.js new file mode 100644 index 0000000..2db8bbc --- /dev/null +++ b/youtube-playlist-duration.user.js @@ -0,0 +1,585 @@ +// ==UserScript== +// @name YouTube: Playlist-Gesamtlänge +// @namespace https://git.ponywave.de/Akamaru/Userscripts +// @version 1.0 +// @description Berechnet die Gesamtlänge einer YouTube-Playlist +// @author Akamaru +// @match https://www.youtube.com/playlist?* +// @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 Playlist-Seite sind + if (!window.location.href.includes('playlist?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"|
[[Datei:YouTubeIcon.png|link=${playlistUrl}]]
`); + + wikiOutput.value = wikiLines.join('\n'); + } + + function parseDateInput(dateString) { + // Date Input Format: YYYY-MM-DD + const date = new Date(dateString); + return formatDate(date); + } + + function formatDate(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return { + iso: `${year}-${month}-${day}`, + display: `${day}.${month}.${year}` + }; + } + + async function autoScrollToEnd() { + let lastHeight = 0; + let sameHeightCount = 0; + + while (sameHeightCount < 3) { // Warte auf 3 gleiche Höhen um sicher zu sein + window.scrollTo(0, document.documentElement.scrollHeight); + await new Promise(r => setTimeout(r, 1000)); + + const newHeight = document.documentElement.scrollHeight; + if (newHeight === lastHeight) { + sameHeightCount++; + } else { + sameHeightCount = 0; + } + lastHeight = newHeight; + } + + // Scrolle zurück nach oben + window.scrollTo(0, 0); + } + + async function fetchPlaylistDates(playlistId, apiKey) { + try { + const dates = []; + let pageToken = ''; + const maxResults = 50; + + // Hole alle Videos der Playlist (paginiert) + do { + const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&playlistId=${playlistId}&maxResults=${maxResults}&pageToken=${pageToken}&key=${apiKey}`; + + const response = await fetch(url); + if (!response.ok) { + console.error('[Playlist Duration] API Error:', response.status, response.statusText); + return null; + } + + const data = await response.json(); + + // Sammle Video-IDs + const videoIds = data.items.map(item => item.contentDetails.videoId); + + // Hole Video-Details (inkl. Upload-Datum) + const videoDetailsUrl = `https://www.googleapis.com/youtube/v3/videos?part=snippet&id=${videoIds.join(',')}&key=${apiKey}`; + const videoResponse = await fetch(videoDetailsUrl); + + if (!videoResponse.ok) { + console.error('[Playlist Duration] Video API Error:', videoResponse.status); + return null; + } + + const videoData = await videoResponse.json(); + + // Extrahiere Upload-Daten + videoData.items.forEach(video => { + if (video.snippet && video.snippet.publishedAt) { + dates.push(new Date(video.snippet.publishedAt)); + } + }); + + pageToken = data.nextPageToken || ''; + } while (pageToken); + + return dates; + } catch (error) { + console.error('[Playlist Duration] Fehler beim API-Aufruf:', error); + return null; + } + } + + function showApiHelp() { + const helpOverlay = document.createElement('div'); + helpOverlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 10000; display: flex; align-items: center; justify-content: center;'; + + const helpModal = document.createElement('div'); + helpModal.style.cssText = 'background: white; padding: 30px; border-radius: 12px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; color: black;'; + + helpModal.innerHTML = ` +

YouTube API Key erstellen

+ +

1. Google Cloud Console öffnen

+

Gehe zu: https://console.cloud.google.com/

+ +

2. Neues Projekt erstellen

+ + +

3. YouTube Data API v3 aktivieren

+ + +

4. API-Key erstellen

+ + +

5. API-Key einschränken (empfohlen)

+ + +

+ 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(); + } + }); + } +})();