Organize, sort, filter, and search your YouTube playlists with powerful tools
Size
23.3 KB
Version
1.0.1
Created
Nov 21, 2025
Updated
26 days ago
1// ==UserScript==
2// @name YouTube Playlist Organizer
3// @description Organize, sort, filter, and search your YouTube playlists with powerful tools
4// @version 1.0.1
5// @match https://*.youtube.com/*
6// @icon https://www.youtube.com/s/desktop/bbe055b3/img/favicon_32x32.png
7// ==/UserScript==
8(function() {
9 'use strict';
10
11 console.log('YouTube Playlist Organizer loaded');
12
13 // Utility function to debounce
14 function debounce(func, wait) {
15 let timeout;
16 return function executedFunction(...args) {
17 const later = () => {
18 clearTimeout(timeout);
19 func(...args);
20 };
21 clearTimeout(timeout);
22 timeout = setTimeout(later, wait);
23 };
24 }
25
26 // Main initialization
27 async function init() {
28 console.log('Initializing YouTube Playlist Organizer');
29
30 // Wait for playlist page to load
31 if (!window.location.href.includes('/playlist?list=')) {
32 console.log('Not on a playlist page, waiting for navigation');
33 observeNavigation();
34 return;
35 }
36
37 // Wait for playlist content to be available
38 waitForPlaylistContent();
39 }
40
41 // Observe navigation changes
42 function observeNavigation() {
43 let lastUrl = location.href;
44 const observer = new MutationObserver(debounce(() => {
45 const currentUrl = location.href;
46 if (currentUrl !== lastUrl) {
47 lastUrl = currentUrl;
48 if (currentUrl.includes('/playlist?list=')) {
49 console.log('Navigated to playlist page');
50 waitForPlaylistContent();
51 }
52 }
53 }, 500));
54
55 observer.observe(document.body, {
56 childList: true,
57 subtree: true
58 });
59 }
60
61 // Wait for playlist content to load
62 function waitForPlaylistContent() {
63 const checkInterval = setInterval(() => {
64 const playlistVideos = document.querySelectorAll('ytd-playlist-video-renderer');
65 const playlistHeader = document.querySelector('ytd-playlist-header-renderer');
66
67 if (playlistVideos.length > 0 && playlistHeader) {
68 clearInterval(checkInterval);
69 console.log('Playlist content loaded, creating organizer UI');
70 createOrganizerUI();
71 }
72 }, 500);
73
74 // Stop checking after 10 seconds
75 setTimeout(() => clearInterval(checkInterval), 10000);
76 }
77
78 // Create the organizer UI
79 function createOrganizerUI() {
80 // Remove existing organizer if present
81 const existingOrganizer = document.getElementById('playlist-organizer-panel');
82 if (existingOrganizer) {
83 existingOrganizer.remove();
84 }
85
86 // Find the playlist header actions area
87 const playlistHeader = document.querySelector('ytd-playlist-header-renderer .immersive-header-content');
88 if (!playlistHeader) {
89 console.error('Could not find playlist header');
90 return;
91 }
92
93 // Create organizer panel
94 const organizerPanel = document.createElement('div');
95 organizerPanel.id = 'playlist-organizer-panel';
96 organizerPanel.innerHTML = `
97 <div class="organizer-container">
98 <div class="organizer-header">
99 <h3>šµ Playlist Organizer</h3>
100 <button id="toggle-organizer" class="organizer-btn">ā¼</button>
101 </div>
102 <div id="organizer-content" class="organizer-content">
103 <div class="organizer-section">
104 <label>š Search Videos:</label>
105 <input type="text" id="search-videos" placeholder="Search by title, channel..." />
106 <span id="search-results-count"></span>
107 </div>
108
109 <div class="organizer-section">
110 <label>š Sort By:</label>
111 <select id="sort-videos">
112 <option value="default">Default Order</option>
113 <option value="title-asc">Title (A-Z)</option>
114 <option value="title-desc">Title (Z-A)</option>
115 <option value="channel-asc">Channel (A-Z)</option>
116 <option value="channel-desc">Channel (Z-A)</option>
117 <option value="duration-asc">Duration (Shortest)</option>
118 <option value="duration-desc">Duration (Longest)</option>
119 </select>
120 </div>
121
122 <div class="organizer-section">
123 <label>š¬ Filter By:</label>
124 <select id="filter-videos">
125 <option value="all">Show All</option>
126 <option value="watched">Watched Only</option>
127 <option value="unwatched">Unwatched Only</option>
128 <option value="short">Short Videos (<5 min)</option>
129 <option value="medium">Medium Videos (5-20 min)</option>
130 <option value="long">Long Videos (>20 min)</option>
131 </select>
132 </div>
133
134 <div class="organizer-section">
135 <label>ā” Quick Actions:</label>
136 <div class="action-buttons">
137 <button id="show-all-btn" class="action-btn">Show All</button>
138 <button id="highlight-duplicates-btn" class="action-btn">Find Duplicates</button>
139 <button id="export-playlist-btn" class="action-btn">Export List</button>
140 <button id="stats-btn" class="action-btn">View Stats</button>
141 </div>
142 </div>
143
144 <div id="stats-display" class="stats-display" style="display: none;"></div>
145 </div>
146 </div>
147 `;
148
149 // Insert after playlist header
150 playlistHeader.parentNode.insertBefore(organizerPanel, playlistHeader.nextSibling);
151
152 // Add styles
153 addStyles();
154
155 // Attach event listeners
156 attachEventListeners();
157
158 console.log('Organizer UI created successfully');
159 }
160
161 // Add CSS styles
162 function addStyles() {
163 const styles = `
164 #playlist-organizer-panel {
165 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
166 border-radius: 12px;
167 padding: 20px;
168 margin: 20px 0;
169 box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
170 font-family: 'Roboto', Arial, sans-serif;
171 }
172
173 .organizer-container {
174 max-width: 100%;
175 }
176
177 .organizer-header {
178 display: flex;
179 justify-content: space-between;
180 align-items: center;
181 margin-bottom: 15px;
182 }
183
184 .organizer-header h3 {
185 color: #ffffff;
186 margin: 0;
187 font-size: 20px;
188 font-weight: 600;
189 }
190
191 #toggle-organizer {
192 background: rgba(255, 255, 255, 0.2);
193 border: none;
194 color: #ffffff;
195 padding: 8px 12px;
196 border-radius: 6px;
197 cursor: pointer;
198 font-size: 16px;
199 transition: all 0.3s ease;
200 }
201
202 #toggle-organizer:hover {
203 background: rgba(255, 255, 255, 0.3);
204 transform: scale(1.05);
205 }
206
207 .organizer-content {
208 display: block;
209 animation: slideDown 0.3s ease;
210 }
211
212 .organizer-content.collapsed {
213 display: none;
214 }
215
216 @keyframes slideDown {
217 from {
218 opacity: 0;
219 transform: translateY(-10px);
220 }
221 to {
222 opacity: 1;
223 transform: translateY(0);
224 }
225 }
226
227 .organizer-section {
228 background: rgba(255, 255, 255, 0.95);
229 padding: 15px;
230 border-radius: 8px;
231 margin-bottom: 12px;
232 }
233
234 .organizer-section label {
235 display: block;
236 color: #333;
237 font-weight: 600;
238 margin-bottom: 8px;
239 font-size: 14px;
240 }
241
242 .organizer-section input,
243 .organizer-section select {
244 width: 100%;
245 padding: 10px;
246 border: 2px solid #e0e0e0;
247 border-radius: 6px;
248 font-size: 14px;
249 transition: border-color 0.3s ease;
250 box-sizing: border-box;
251 }
252
253 .organizer-section input:focus,
254 .organizer-section select:focus {
255 outline: none;
256 border-color: #667eea;
257 }
258
259 #search-results-count {
260 display: block;
261 color: #666;
262 font-size: 12px;
263 margin-top: 5px;
264 }
265
266 .action-buttons {
267 display: grid;
268 grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
269 gap: 8px;
270 }
271
272 .action-btn {
273 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
274 color: #ffffff;
275 border: none;
276 padding: 10px 15px;
277 border-radius: 6px;
278 cursor: pointer;
279 font-size: 13px;
280 font-weight: 600;
281 transition: all 0.3s ease;
282 white-space: nowrap;
283 }
284
285 .action-btn:hover {
286 transform: translateY(-2px);
287 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
288 }
289
290 .action-btn:active {
291 transform: translateY(0);
292 }
293
294 .stats-display {
295 background: rgba(255, 255, 255, 0.95);
296 padding: 15px;
297 border-radius: 8px;
298 margin-top: 12px;
299 }
300
301 .stats-display h4 {
302 color: #333;
303 margin: 0 0 10px 0;
304 font-size: 16px;
305 }
306
307 .stats-display p {
308 color: #666;
309 margin: 5px 0;
310 font-size: 14px;
311 }
312
313 .video-hidden {
314 display: none !important;
315 }
316
317 .video-duplicate {
318 border: 3px solid #ff4444 !important;
319 background: rgba(255, 68, 68, 0.1) !important;
320 }
321 `;
322
323 TM_addStyle(styles);
324 }
325
326 // Attach event listeners
327 function attachEventListeners() {
328 // Toggle organizer
329 const toggleBtn = document.getElementById('toggle-organizer');
330 const content = document.getElementById('organizer-content');
331
332 toggleBtn.addEventListener('click', () => {
333 content.classList.toggle('collapsed');
334 toggleBtn.textContent = content.classList.contains('collapsed') ? 'ā¶' : 'ā¼';
335 });
336
337 // Search functionality
338 const searchInput = document.getElementById('search-videos');
339 searchInput.addEventListener('input', debounce(() => {
340 searchVideos(searchInput.value);
341 }, 300));
342
343 // Sort functionality
344 const sortSelect = document.getElementById('sort-videos');
345 sortSelect.addEventListener('change', () => {
346 sortVideos(sortSelect.value);
347 });
348
349 // Filter functionality
350 const filterSelect = document.getElementById('filter-videos');
351 filterSelect.addEventListener('change', () => {
352 filterVideos(filterSelect.value);
353 });
354
355 // Show all button
356 document.getElementById('show-all-btn').addEventListener('click', () => {
357 showAllVideos();
358 });
359
360 // Highlight duplicates
361 document.getElementById('highlight-duplicates-btn').addEventListener('click', () => {
362 highlightDuplicates();
363 });
364
365 // Export playlist
366 document.getElementById('export-playlist-btn').addEventListener('click', () => {
367 exportPlaylist();
368 });
369
370 // View stats
371 document.getElementById('stats-btn').addEventListener('click', () => {
372 showStats();
373 });
374 }
375
376 // Search videos
377 function searchVideos(query) {
378 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
379 const searchQuery = query.toLowerCase().trim();
380 let visibleCount = 0;
381
382 videos.forEach(video => {
383 const titleElement = video.querySelector('#video-title');
384 const channelElement = video.querySelector('#channel-name a');
385
386 const title = titleElement ? titleElement.textContent.toLowerCase() : '';
387 const channel = channelElement ? channelElement.textContent.toLowerCase() : '';
388
389 if (searchQuery === '' || title.includes(searchQuery) || channel.includes(searchQuery)) {
390 video.classList.remove('video-hidden');
391 visibleCount++;
392 } else {
393 video.classList.add('video-hidden');
394 }
395 });
396
397 const resultsCount = document.getElementById('search-results-count');
398 if (searchQuery) {
399 resultsCount.textContent = `Showing ${visibleCount} of ${videos.length} videos`;
400 } else {
401 resultsCount.textContent = '';
402 }
403
404 console.log(`Search: "${query}" - ${visibleCount} results`);
405 }
406
407 // Sort videos
408 function sortVideos(sortType) {
409 const container = document.querySelector('ytd-playlist-video-list-renderer #contents');
410 if (!container) {
411 console.error('Could not find playlist container');
412 return;
413 }
414
415 const videos = Array.from(document.querySelectorAll('ytd-playlist-video-renderer'));
416
417 if (sortType === 'default') {
418 console.log('Restoring default order');
419 return;
420 }
421
422 videos.sort((a, b) => {
423 if (sortType.startsWith('title')) {
424 const titleA = a.querySelector('#video-title')?.textContent.trim().toLowerCase() || '';
425 const titleB = b.querySelector('#video-title')?.textContent.trim().toLowerCase() || '';
426 return sortType === 'title-asc' ? titleA.localeCompare(titleB) : titleB.localeCompare(titleA);
427 } else if (sortType.startsWith('channel')) {
428 const channelA = a.querySelector('#channel-name a')?.textContent.trim().toLowerCase() || '';
429 const channelB = b.querySelector('#channel-name a')?.textContent.trim().toLowerCase() || '';
430 return sortType === 'channel-asc' ? channelA.localeCompare(channelB) : channelB.localeCompare(channelA);
431 } else if (sortType.startsWith('duration')) {
432 const durationA = parseDuration(a.querySelector('.ytd-thumbnail-overlay-time-status-renderer')?.textContent.trim() || '0:00');
433 const durationB = parseDuration(b.querySelector('.ytd-thumbnail-overlay-time-status-renderer')?.textContent.trim() || '0:00');
434 return sortType === 'duration-asc' ? durationA - durationB : durationB - durationA;
435 }
436 return 0;
437 });
438
439 // Re-append videos in sorted order
440 videos.forEach(video => container.appendChild(video));
441
442 console.log(`Sorted by: ${sortType}`);
443 }
444
445 // Parse duration string to seconds
446 function parseDuration(durationStr) {
447 const parts = durationStr.split(':').map(p => parseInt(p) || 0);
448 if (parts.length === 3) {
449 return parts[0] * 3600 + parts[1] * 60 + parts[2];
450 } else if (parts.length === 2) {
451 return parts[0] * 60 + parts[1];
452 }
453 return 0;
454 }
455
456 // Filter videos
457 function filterVideos(filterType) {
458 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
459
460 videos.forEach(video => {
461 let shouldShow = true;
462
463 if (filterType === 'all') {
464 shouldShow = true;
465 } else if (filterType === 'watched') {
466 const progressBar = video.querySelector('#progress');
467 shouldShow = progressBar && progressBar.style.width && progressBar.style.width !== '0%';
468 } else if (filterType === 'unwatched') {
469 const progressBar = video.querySelector('#progress');
470 shouldShow = !progressBar || !progressBar.style.width || progressBar.style.width === '0%';
471 } else if (filterType === 'short' || filterType === 'medium' || filterType === 'long') {
472 const durationStr = video.querySelector('.ytd-thumbnail-overlay-time-status-renderer')?.textContent.trim() || '0:00';
473 const durationSeconds = parseDuration(durationStr);
474
475 if (filterType === 'short') {
476 shouldShow = durationSeconds < 300; // Less than 5 minutes
477 } else if (filterType === 'medium') {
478 shouldShow = durationSeconds >= 300 && durationSeconds <= 1200; // 5-20 minutes
479 } else if (filterType === 'long') {
480 shouldShow = durationSeconds > 1200; // More than 20 minutes
481 }
482 }
483
484 if (shouldShow) {
485 video.classList.remove('video-hidden');
486 } else {
487 video.classList.add('video-hidden');
488 }
489 });
490
491 console.log(`Filtered by: ${filterType}`);
492 }
493
494 // Show all videos
495 function showAllVideos() {
496 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
497 videos.forEach(video => {
498 video.classList.remove('video-hidden', 'video-duplicate');
499 });
500
501 // Reset controls
502 document.getElementById('search-videos').value = '';
503 document.getElementById('sort-videos').value = 'default';
504 document.getElementById('filter-videos').value = 'all';
505 document.getElementById('search-results-count').textContent = '';
506
507 console.log('Showing all videos');
508 }
509
510 // Highlight duplicate videos
511 function highlightDuplicates() {
512 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
513 const videoTitles = new Map();
514 let duplicateCount = 0;
515
516 // First pass: collect all titles
517 videos.forEach(video => {
518 const titleElement = video.querySelector('#video-title');
519 if (titleElement) {
520 const title = titleElement.textContent.trim().toLowerCase();
521 if (!videoTitles.has(title)) {
522 videoTitles.set(title, []);
523 }
524 videoTitles.get(title).push(video);
525 }
526 });
527
528 // Second pass: highlight duplicates
529 videoTitles.forEach((videoList, title) => {
530 if (videoList.length > 1) {
531 videoList.forEach(video => {
532 video.classList.add('video-duplicate');
533 duplicateCount++;
534 });
535 }
536 });
537
538 if (duplicateCount > 0) {
539 alert(`Found ${duplicateCount} duplicate videos! They are now highlighted in red.`);
540 } else {
541 alert('No duplicate videos found in this playlist.');
542 }
543
544 console.log(`Found ${duplicateCount} duplicate videos`);
545 }
546
547 // Export playlist
548 function exportPlaylist() {
549 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
550 const playlistData = [];
551
552 videos.forEach((video, index) => {
553 const titleElement = video.querySelector('#video-title');
554 const channelElement = video.querySelector('#channel-name a');
555 const durationElement = video.querySelector('.ytd-thumbnail-overlay-time-status-renderer');
556 const linkElement = video.querySelector('#thumbnail');
557
558 playlistData.push({
559 index: index + 1,
560 title: titleElement?.textContent.trim() || 'Unknown',
561 channel: channelElement?.textContent.trim() || 'Unknown',
562 duration: durationElement?.textContent.trim() || 'Unknown',
563 url: linkElement?.href || ''
564 });
565 });
566
567 // Create CSV content
568 let csvContent = 'Index,Title,Channel,Duration,URL\n';
569 playlistData.forEach(video => {
570 csvContent += `${video.index},"${video.title.replace(/"/g, '""')}","${video.channel}","${video.duration}","${video.url}"\n`;
571 });
572
573 // Download CSV
574 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
575 const link = document.createElement('a');
576 const url = URL.createObjectURL(blob);
577 link.setAttribute('href', url);
578 link.setAttribute('download', `youtube_playlist_${Date.now()}.csv`);
579 link.style.visibility = 'hidden';
580 document.body.appendChild(link);
581 link.click();
582 document.body.removeChild(link);
583
584 console.log('Playlist exported successfully');
585 }
586
587 // Show playlist statistics
588 function showStats() {
589 const statsDisplay = document.getElementById('stats-display');
590 const videos = document.querySelectorAll('ytd-playlist-video-renderer');
591
592 let totalDuration = 0;
593 const channels = new Set();
594 let watchedCount = 0;
595
596 videos.forEach(video => {
597 const durationStr = video.querySelector('.ytd-thumbnail-overlay-time-status-renderer')?.textContent.trim() || '0:00';
598 totalDuration += parseDuration(durationStr);
599
600 const channelElement = video.querySelector('#channel-name a');
601 if (channelElement) {
602 channels.add(channelElement.textContent.trim());
603 }
604
605 const progressBar = video.querySelector('#progress');
606 if (progressBar && progressBar.style.width && progressBar.style.width !== '0%') {
607 watchedCount++;
608 }
609 });
610
611 const hours = Math.floor(totalDuration / 3600);
612 const minutes = Math.floor((totalDuration % 3600) / 60);
613
614 statsDisplay.innerHTML = `
615 <h4>š Playlist Statistics</h4>
616 <p><strong>Total Videos:</strong> ${videos.length}</p>
617 <p><strong>Total Duration:</strong> ${hours}h ${minutes}m</p>
618 <p><strong>Unique Channels:</strong> ${channels.size}</p>
619 <p><strong>Watched:</strong> ${watchedCount} (${Math.round(watchedCount / videos.length * 100)}%)</p>
620 <p><strong>Unwatched:</strong> ${videos.length - watchedCount}</p>
621 `;
622
623 statsDisplay.style.display = 'block';
624 console.log('Stats displayed');
625 }
626
627 // Start the extension
628 TM_runBody(init);
629
630})();