1
0
Files
Userscripts/youtube-playlist-duration.user.js
2025-11-11 00:58:22 +01:00

587 lines
24 KiB
JavaScript

// ==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 = '<span style="font-weight: bold;">⚙️ YouTube API Key (optional)</span><span id="api-toggle">▼</span>';
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? <a href="#" id="api-help-link" style="color: #065fd4;">Anleitung anzeigen</a>';
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 = `
<p style="margin: 5px 0;"><strong>Videos:</strong> ${videoCount} Folgen</p>
<p style="margin: 5px 0;"><strong>Gesamtlänge:</strong> ~${totalMinutes} Minuten</p>
`;
// 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"| <div align="center">[[Datei:YouTubeIcon.png|link=${playlistUrl}]]</div>`);
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 = `
<h2 style="margin-top: 0;">YouTube API Key erstellen</h2>
<h3>1. Google Cloud Console öffnen</h3>
<p>Gehe zu: <a href="https://console.cloud.google.com/" target="_blank" style="color: #065fd4;">https://console.cloud.google.com/</a></p>
<h3>2. Neues Projekt erstellen</h3>
<ul>
<li>Klicke oben auf "Projekt auswählen" → "Neues Projekt"</li>
<li>Name: z.B. "YouTube Playlist Duration"</li>
<li>Klicke auf "Erstellen"</li>
</ul>
<h3>3. YouTube Data API v3 aktivieren</h3>
<ul>
<li>Gehe zu "APIs & Dienste" → "Bibliothek"</li>
<li>Suche nach "YouTube Data API v3"</li>
<li>Klicke auf "Aktivieren"</li>
</ul>
<h3>4. API-Key erstellen</h3>
<ul>
<li>Gehe zu "APIs & Dienste" → "Anmeldedaten"</li>
<li>Klicke auf "+ Anmeldedaten erstellen" → "API-Schlüssel"</li>
<li>Kopiere den generierten Key</li>
</ul>
<h3>5. API-Key einschränken (empfohlen)</h3>
<ul>
<li>Klicke auf den erstellten Key</li>
<li>Bei "API-Einschränkungen": Wähle "Schlüssel einschränken"</li>
<li>Aktiviere nur "YouTube Data API v3"</li>
<li>Speichern</li>
</ul>
<p style="margin-top: 20px; padding: 10px; background: #f0f0f0; border-radius: 5px; font-size: 12px;">
<strong>Hinweis:</strong> Die YouTube API hat ein kostenloses Kontingent von 10.000 Einheiten/Tag.
Eine Playlist-Abfrage kostet ca. 1-3 Einheiten pro 50 Videos.
</p>
<button id="close-help" style="margin-top: 20px; padding: 10px 20px; background: #065fd4; color: white; border: none; border-radius: 5px; cursor: pointer;">Schließen</button>
`;
helpOverlay.appendChild(helpModal);
document.body.appendChild(helpOverlay);
helpOverlay.addEventListener('click', (e) => {
if (e.target === helpOverlay || e.target.id === 'close-help') {
helpOverlay.remove();
}
});
}
})();