Skip to content

Instantly share code, notes, and snippets.

@jasontucker
Last active May 27, 2026 07:33
Show Gist options
  • Select an option

  • Save jasontucker/9a6289d72d0a409975ead5fb2acc05ec to your computer and use it in GitHub Desktop.

Select an option

Save jasontucker/9a6289d72d0a409975ead5fb2acc05ec to your computer and use it in GitHub Desktop.
// --- 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