Last active
May 27, 2026 07:33
-
-
Save jasontucker/9a6289d72d0a409975ead5fb2acc05ec to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // --- GLOBAL CONFIGURATION --- | |
| var CACHE_DURATION_DAYS = 7; | |
| var VIDEO_DURATION_THRESHOLD = 120; // 2 minutes | |
| // NOTE: UPDATE THESE WITH YOUR ACTUAL IDs | |
| var PLAYLIST_ID = 'ADD YOUR ID HERE'; | |
| var SPREADSHEET_ID = 'THE SPREADSHEET ID'; | |
| var CHANNEL_IDS = [ | |
| 'UCEJM4kRM01cY-2NeJ4-3nbw', 'UCZkGmgbBziqGCYI3x9hVQPg', 'UC5OdB-sGz9atv-tJINwWOTg', | |
| 'UCtf6aq_atnqF6shEam6m1sQ', 'UCsphzld6v6ghCl_RyvihNgA', 'UC4ijq8Cg-8zQKx8OH12dUSw', | |
| 'UCQdxE3tUpHOKQ5zK-_fOBjg' | |
| ]; | |
| // ================================================================= | |
| // === 1. MAIN PLAYLIST UPDATE FUNCTION (for Scheduled Run 1) === | |
| // ================================================================= | |
| function updatePlaylist() { | |
| // Ensure the sheet structure is ready | |
| initializeSheet(SPREADSHEET_ID); | |
| const eightDaysAgo = new Date(); | |
| eightDaysAgo.setDate(eightDaysAgo.getDate() - 8); | |
| try { | |
| let allVideos = []; | |
| for (let i = 0; i < CHANNEL_IDS.length; i++) { | |
| // FIX #9: Now fetches all pages per channel, not just the first 10 results | |
| const videos = getLatestVideos(CHANNEL_IDS[i], eightDaysAgo); | |
| allVideos = allVideos.concat(videos); | |
| } | |
| const allVideoIds = allVideos.map(video => video.id.videoId); | |
| const videoDetails = getVideoDetailsInBatches(allVideoIds); | |
| // FIX #4: Load watched videos as a Set for O(1) lookups instead of O(n) array .includes() | |
| const watchedVideos = getWatchedVideos(SPREADSHEET_ID); | |
| for (let i = 0; i < allVideos.length; i++) { | |
| const video = allVideos[i]; | |
| const videoId = video.id.videoId; | |
| const details = videoDetails[videoId]; | |
| const fullVideoData = { | |
| id: videoId, | |
| channelName: video.snippet.channelTitle, | |
| title: video.snippet.title, | |
| publishedAt: video.snippet.publishedAt, | |
| durationInSeconds: details ? details.durationInSeconds : 0 | |
| }; | |
| // FIX #1: Skip the API call to videoInPlaylist() if already in the watched Set — | |
| // no need to hit the API if the spreadsheet already has it logged. | |
| if (fullVideoData.durationInSeconds > VIDEO_DURATION_THRESHOLD && | |
| !watchedVideos.has(videoId)) { | |
| if (!videoInPlaylist(videoId, PLAYLIST_ID)) { | |
| addToPlaylist(videoId, PLAYLIST_ID); | |
| // FIX #3: Only write to sheet after confirmed add, preventing duplicate rows | |
| // on overlapping runs | |
| addToWatchedList(fullVideoData, SPREADSHEET_ID); | |
| // FIX #4: Update the in-memory Set so subsequent iterations in this same | |
| // run also see the newly added video | |
| watchedVideos.add(videoId); | |
| Logger.log('Added video ' + videoId + ' to playlist and watched list'); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('An error occurred while updating the playlist:', e); | |
| } | |
| } | |
| // ================================================================= | |
| // === 2. CLEANUP FUNCTIONS (for Scheduled Run 2) === | |
| // ================================================================= | |
| function runPlaylistCleanup() { | |
| cleanPlaylist(PLAYLIST_ID, SPREADSHEET_ID); | |
| } | |
| function cleanPlaylist(playlistId, spreadsheetId) { | |
| Logger.log('Starting scheduled playlist cleanup...'); | |
| const watchedVideoIds = getWatchedVideos(spreadsheetId); | |
| if (watchedVideoIds.size === 0) { | |
| Logger.log('No videos found in the Watched Videos sheet. Cleanup complete.'); | |
| return; | |
| } | |
| const playlistItems = getAllPlaylistItems(playlistId); | |
| playlistItems.forEach(function(item) { | |
| // FIX #5: Use contentDetails.videoId instead of snippet.resourceId.videoId | |
| // since we switched getAllPlaylistItems() to only request 'contentDetails' | |
| const videoId = item.contentDetails.videoId; | |
| // FIX #4: .has() instead of .includes() to match the Set return from getWatchedVideos() | |
| if (watchedVideoIds.has(videoId)) { | |
| try { | |
| YouTube.PlaylistItems.remove(item.id); | |
| Logger.log('Removed playlist item ' + item.id + ' (Video: ' + videoId + ')'); | |
| } catch (e) { | |
| console.error('Error removing item ' + item.id + ': ' + e); | |
| } | |
| } | |
| }); | |
| Logger.log('Playlist cleanup finished.'); | |
| } | |
| // FIX #5: Changed 'snippet,contentDetails' → 'contentDetails' only. | |
| // We only need item.id (always returned) and the video ID. | |
| // This saves quota and reduces response payload size. | |
| function getAllPlaylistItems(playlistId) { | |
| const allItems = []; | |
| let pageToken = ''; | |
| do { | |
| const response = YouTube.PlaylistItems.list('contentDetails', { | |
| playlistId: playlistId, | |
| maxResults: 50, | |
| pageToken: pageToken | |
| }); | |
| allItems.push(...response.items); | |
| pageToken = response.nextPageToken; | |
| } while (pageToken); | |
| return allItems; | |
| } | |
| // ================================================================= | |
| // === 3. HELPER FUNCTIONS === | |
| // ================================================================= | |
| function exponentialBackoff(func, maxRetries = 5) { | |
| for (let i = 0; i < maxRetries; i++) { | |
| try { | |
| return func(); | |
| } catch (e) { | |
| if (i === maxRetries - 1) throw e; | |
| const waitTime = Math.pow(2, i) * 1000 + Math.random() * 1000; | |
| Utilities.sleep(waitTime); | |
| } | |
| } | |
| } | |
| function initializeSheet(spreadsheetId) { | |
| const ss = SpreadsheetApp.openById(spreadsheetId); | |
| let sheet = ss.getSheetByName('Watched Videos'); | |
| if (!sheet) { | |
| sheet = ss.insertSheet('Watched Videos'); | |
| } | |
| const headerRange = sheet.getRange('A1:D1'); | |
| if (headerRange.getValues()[0][0] !== 'Video ID') { | |
| headerRange.setValues([['Video ID', 'Channel Name', 'Video Title', 'Publication Date/Time']]).setFontWeight('bold'); | |
| } | |
| } | |
| // FIX #9: Removed the unused pageToken parameter from the signature and replaced | |
| // with internal pagination, so callers always get ALL videos (not just the first 10) | |
| // from each channel within the date window. | |
| function getLatestVideos(channelId, publishedAfter) { | |
| return exponentialBackoff(() => { | |
| const allItems = []; | |
| let pageToken = ''; | |
| do { | |
| const options = { | |
| channelId: channelId, | |
| type: 'video', | |
| order: 'date', | |
| publishedAfter: publishedAfter.toISOString(), | |
| maxResults: 50 | |
| }; | |
| if (pageToken) options.pageToken = pageToken; | |
| try { | |
| const results = YouTube.Search.list('id,snippet', options); | |
| allItems.push(...results.items); | |
| pageToken = results.nextPageToken; | |
| } catch (e) { | |
| console.error('Error fetching latest videos for channel ' + channelId + ':', e); | |
| throw e; | |
| } | |
| } while (pageToken); | |
| return allItems; | |
| }); | |
| } | |
| function getVideoDetailsInBatches(videoIds) { | |
| const cache = CacheService.getScriptCache(); | |
| const videoDetails = {}; | |
| const uncachedIds = []; | |
| videoIds.forEach(function(videoId) { | |
| const cachedData = cache.get(videoId); | |
| if (cachedData) { | |
| videoDetails[videoId] = JSON.parse(cachedData); | |
| } else { | |
| uncachedIds.push(videoId); | |
| } | |
| }); | |
| for (let i = 0; i < uncachedIds.length; i += 50) { | |
| const batch = uncachedIds.slice(i, i + 50); | |
| exponentialBackoff(() => { | |
| try { | |
| const response = YouTube.Videos.list('contentDetails', {id: batch.join(',')}); | |
| response.items.forEach(function(item) { | |
| const durationInSeconds = parseDuration(item.contentDetails.duration); | |
| videoDetails[item.id] = {durationInSeconds: durationInSeconds}; | |
| // FIX #8: Use CACHE_DURATION_SECONDS constant instead of hardcoded 21600 | |
| cache.put(item.id, JSON.stringify({durationInSeconds: durationInSeconds}), CACHE_DURATION_SECONDS); | |
| }); | |
| } catch (e) { | |
| console.error('Error fetching video details:', e); | |
| throw e; | |
| } | |
| }); | |
| } | |
| return videoDetails; | |
| } | |
| function parseDuration(duration) { | |
| const match = duration.match(/PT(\d+H)?(\d+M)?(\d+S)?/); | |
| if (!match) return 0; | |
| const hours = parseInt(match[1]) ? parseInt(match[1].replace('H','')) : 0; | |
| const minutes = parseInt(match[2]) ? parseInt(match[2].replace('M','')) : 0; | |
| const seconds = parseInt(match[3]) ? parseInt(match[3].replace('S','')) : 0; | |
| return hours * 3600 + minutes * 60 + seconds; | |
| } | |
| // FIX #4: Returns a Set instead of an Array for O(1) .has() lookups. | |
| // All callers updated to use .has() instead of .includes(). | |
| function getWatchedVideos(spreadsheetId) { | |
| const sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('Watched Videos'); | |
| if (!sheet) return new Set(); | |
| return new Set(sheet.getRange('A2:A').getValues().flat().filter(String)); | |
| } | |
| function videoInPlaylist(videoId, playlistId) { | |
| return exponentialBackoff(() => { | |
| const cache = CacheService.getScriptCache(); | |
| // FIX #2: Include playlistId in cache key to prevent collisions if multiple | |
| // playlist scripts ever run in the same Apps Script project | |
| const cacheKey = 'playlist_' + playlistId + '_' + videoId; | |
| const cachedResult = cache.get(cacheKey); | |
| if (cachedResult !== null) { | |
| return cachedResult === 'true'; | |
| } | |
| try { | |
| const playlistItems = YouTube.PlaylistItems.list('snippet', { | |
| playlistId: playlistId, | |
| videoId: videoId, | |
| maxResults: 1 | |
| }); | |
| const result = playlistItems.items && playlistItems.items.length > 0; | |
| // FIX #8: Use CACHE_DURATION_SECONDS constant instead of hardcoded 21600 | |
| cache.put(cacheKey, result.toString(), CACHE_DURATION_SECONDS); | |
| return result; | |
| } catch (e) { | |
| console.error('Error checking video in playlist:', e); | |
| throw e; | |
| } | |
| }); | |
| } | |
| function addToPlaylist(videoId, playlistId) { | |
| return exponentialBackoff(() => { | |
| try { | |
| YouTube.PlaylistItems.insert({ | |
| snippet: { | |
| playlistId: playlistId, | |
| resourceId: { | |
| kind: 'youtube#video', | |
| videoId: videoId | |
| } | |
| } | |
| }, 'snippet'); | |
| } catch (e) { | |
| console.error('Error adding video to playlist:', e); | |
| throw e; | |
| } | |
| }); | |
| } | |
| function addToWatchedList(videoData, spreadsheetId) { | |
| const sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName('Watched Videos'); | |
| const row = [ | |
| videoData.id, | |
| videoData.channelName, | |
| videoData.title, | |
| videoData.publishedAt | |
| ]; | |
| sheet.appendRow(row); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment