diff --git a/CHANGELOG.md b/CHANGELOG.md index e135a555..a964bfb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,17 @@ # Changelog -## v2.2.4 +## v2.3.0 +- Shuffling when excluding shorts is now a lot quicker if you have shuffled from the channel before, as the extension will remember which videos are shorts and skip them automatically. +- Added an additional message to the shuffle button if shuffling takes a bit longer due to ignoring shorts. +- Fixed a rare data inconsistency bug occurring with specific database values. + + +## v2.2.4 + - Fixed an alignment issue of the shuffle button on channel pages that was introduced with the latest update to the YouTube UI. - Fixed some issues with the display of the shuffle button if the shuffle icon is not loaded in time. - ## v2.2.3 diff --git a/README.md b/README.md index 5c5ffba5..1ff9a3e4 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Did you find any bugs with the version you tested? Please let me know by [openin - The bundled extension files will be placed in the `dist/` directories. - You can load the extension in your browser by following the instructions below. -#### Chrome/Chromium +#### Chromium - Open the Extension Management page by navigating to `chrome://extensions`. - Make sure that you have enabled developer mode. diff --git a/package.json b/package.json index 613e8f34..326bdefb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "random-youtube-video", - "version": "2.2.4", + "version": "2.3.0", "description": "Play a random video uploaded on the current YouTube channel.", "scripts": { "dev": "concurrently \"npm run dev:chromium\" \"npm run dev:firefox\"", diff --git a/src/background.js b/src/background.js index 3a55d217..936f5ec3 100644 --- a/src/background.js +++ b/src/background.js @@ -12,13 +12,13 @@ chrome.runtime.onStartup.addListener(async function () { console.log(`${((utilizedStorage / maxLocalStorage) * 100).toFixed(2)}% of local storage is used. (${utilizedStorage}/${maxLocalStorage} bytes)`); if (maxLocalStorage * 0.9 < utilizedStorage) { - console.log("Local storage is over 90% utilized. Removing playlists that have not been accessed the longest..."); + console.log("Local storage is over 90% utilized. Removing playlists that have not been accessed the longest to free up some space..."); // Get all playlists from local storage const localStorageContents = await chrome.storage.local.get(); // We only need the keys that hold playlists, which is signified by the existence of the "videos" sub-key - const allPlaylists = Object.fromEntries(Object.entries(localStorageContents).filter(([k, v]) => v["videos"])); + const allPlaylists = Object.fromEntries(Object.entries(localStorageContents).filter(([key, value]) => value["videos"])); // Sort the playlists by lastAccessedLocally value const sortedPlaylists = Object.entries(allPlaylists).sort((a, b) => { @@ -324,7 +324,7 @@ async function openVideoInTabWithId(tabId, videoUrl) { // ---------- Local storage ---------- async function getFromLocalStorage(key) { return await chrome.storage.local.get([key]).then((result) => { - if (result[key]) { + if (result[key] !== undefined) { return result[key]; } return null; diff --git a/src/content.js b/src/content.js index 11a0bec0..6a65586b 100644 --- a/src/content.js +++ b/src/content.js @@ -141,6 +141,9 @@ async function shuffleVideos() { var hasBeenShuffled = false; setDOMTextWithDelay(shuffleButtonTextElement, "\xa0Shuffling...", 1000, () => { return (shuffleButtonTextElement.innerText === "\xa0Shuffle" && !hasBeenShuffled); }); setDOMTextWithDelay(shuffleButtonTextElement, "\xa0Still on it...", 5000, () => { return (shuffleButtonTextElement.innerText === "\xa0Shuffling..." && !hasBeenShuffled); }); + if (configSync.shuffleIgnoreShortsOption) { + setDOMTextWithDelay(shuffleButtonTextElement, "\xa0Sorting shorts...", 8000, () => { return (shuffleButtonTextElement.innerText === "\xa0Still on it..." && !hasBeenShuffled); }); + } await chooseRandomVideo(channelId, false, shuffleButtonTextElement); hasBeenShuffled = true; diff --git a/src/shuffleVideo.js b/src/shuffleVideo.js index 8afade71..0cd3201a 100644 --- a/src/shuffleVideo.js +++ b/src/shuffleVideo.js @@ -23,16 +23,17 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE try { // While chooseRandomVideo is running, we need to keep the service worker alive // Otherwise, it will get stopped after 30 seconds and we will get an error if fetching the videos takes longer + // So every 25 seconds, we send a message to the service worker to keep it alive var keepServiceWorkerAlive = setInterval(() => { chrome.runtime.sendMessage({ command: "connectionTest" }); }, 25000); /* c8 ignore stop */ // Each user has a set amount of quota they can use per day. - // If they exceed it, they need to provide a custom API key, or wait until the quota resets the next day. + // If they exceed it, they need to provide a custom API key, or wait until the quota resets the next day let userQuotaRemainingToday = await getUserQuotaRemainingToday(); - // If we somehow update the playlist info and want to send it to the database in the end, this variable indicates it + // If we update the playlist info in any way and want to send it to the database in the end, this variable indicates it let shouldUpdateDatabase = false; // User preferences @@ -89,7 +90,8 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE (!databaseSharing && ((playlistInfo["lastAccessedLocally"] ?? new Date(0).toISOString()) < addHours(new Date(), -48).toISOString()))) { console.log(`Local uploads playlist for this channel may be outdated.${databaseSharing ? " Updating from the database..." : ""}`); - playlistInfo = databaseSharing ? await tryGetPlaylistFromDB(uploadsPlaylistId) : {}; + // Try to get an updated version of the playlist, but keep the information about locally known videos and shorts + playlistInfo = databaseSharing ? await tryGetPlaylistFromDB(uploadsPlaylistId, playlistInfo) : {}; // The playlist does not exist in the database (==it was deleted since the user last fetched it). Get it from the API. // With the current functionality and db rules, this shouldn't happen, except if the user has opted out of database sharing. @@ -110,10 +112,12 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE // Update the remaining user quota in the configSync await setSyncStorageValue("userQuotaRemainingToday", Math.max(0, userQuotaRemainingToday)); - // To prevent potential TypeErrors later on, assign an empty newVideos object if it doesn't exist - if (!playlistInfo["newVideos"]) { - playlistInfo["newVideos"] = {}; - } + // Validate that all required keys exist in the playlistInfo object + validatePlaylistInfo(playlistInfo); + + // Join the new videos with the old ones to be able to use them when shuffling + // Do not delete the newVideos key as it may be needed when updating the database + playlistInfo["videos"]["unknownType"] = Object.assign({}, playlistInfo["videos"]["unknownType"] ?? {}, playlistInfo["newVideos"] ?? {}); let chosenVideos, encounteredDeletedVideos; ({ chosenVideos, playlistInfo, shouldUpdateDatabase, encounteredDeletedVideos } = await chooseRandomVideosFromPlaylist(playlistInfo, channelId, shouldUpdateDatabase)); @@ -127,14 +131,14 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE // If any videos need to be deleted, this should be the union of videos, newvideos, minus the videos to delete if (encounteredDeletedVideos) { console.log("Some videos need to be deleted from the database. All current videos will be uploaded to the database..."); - videosToDatabase = Object.assign({}, playlistInfo["videos"], playlistInfo["newVideos"] ?? {}); + videosToDatabase = getAllVideosFromLocalPlaylist(playlistInfo); } else { // Otherwise, we want to only upload new videos. If there are no "newVideos", we upload all videos, as this is the first time we are uploading the playlist console.log("Uploading new video IDs to the database..."); if (getLength(playlistInfo["newVideos"] ?? {}) > 0) { videosToDatabase = playlistInfo["newVideos"]; } else { - videosToDatabase = playlistInfo["videos"]; + videosToDatabase = getAllVideosFromLocalPlaylist(playlistInfo); } } @@ -147,9 +151,6 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE // Update the playlist locally console.log("Saving playlist to local storage..."); - // We can now join the new videos with the old ones - playlistInfo["videos"] = Object.assign({}, playlistInfo["videos"], playlistInfo["newVideos"] ?? {}); - // Only save the wanted keys const playlistInfoForLocalStorage = { // Remember the last time the playlist was accessed locally (==now) @@ -175,13 +176,14 @@ export async function chooseRandomVideo(channelId, firedFromPopup, progressTextE // --------------- Private --------------- // ---------- Database ---------- // Try to get the playlist from the database. If it does not exist, return an empty dictionary. -async function tryGetPlaylistFromDB(playlistId) { +async function tryGetPlaylistFromDB(playlistId, localPlaylistInfo = null) { const msg = { command: "getPlaylistFromDB", data: playlistId }; - let playlistInfo = await chrome.runtime.sendMessage(msg); + // Some of the tests break if we do not create a deepCopy here, as the local and database object somehow get linked + let playlistInfo = JSON.parse(JSON.stringify(await chrome.runtime.sendMessage(msg))); /* c8 ignore start - These are legacy conversions we don't want to test */ // In case the playlist is still in the old Array format (before v1.0.0) in the database, convert it to the new format @@ -203,20 +205,43 @@ async function tryGetPlaylistFromDB(playlistId) { } /* c8 ignore stop */ - if (!playlistInfo) { - return {}; - } - - if (!playlistInfo["videos"]) { - // Due to some mistake, there is no video data in the database - // Overwrite the playlist with an empty one - console.log("The playlist was found in the database, but it is empty. Removing..."); - await uploadPlaylistToDatabase({}, {}, playlistId, true); + if (!playlistInfo || !playlistInfo["videos"]) { return {}; } playlistInfo["lastFetchedFromDB"] = new Date().toISOString(); + const videosCopy = JSON.parse(JSON.stringify(playlistInfo["videos"])); + if (!localPlaylistInfo) { + // Since we just fetched the playlist, we do not have any info locally, so all videos are "unknownType" + playlistInfo["videos"] = {}; + playlistInfo["videos"]["unknownType"] = videosCopy; + playlistInfo["videos"]["knownVideos"] = {}; + playlistInfo["videos"]["knownShorts"] = {}; + } else { + const allVideosInLocalPlaylist = getAllVideosFromLocalPlaylist(localPlaylistInfo); + const allVideosInLocalPlaylistAsSet = new Set(Object.keys(allVideosInLocalPlaylist)); + const allVideosInDatabaseAsSet = new Set(Object.keys(playlistInfo["videos"])); + + // Add videos that are new from the database to the local playlist + const videosOnlyInDatabase = Object.keys(playlistInfo["videos"]).filter((videoId) => !allVideosInLocalPlaylistAsSet.has(videoId)); + + playlistInfo["videos"] = {}; + playlistInfo["videos"]["unknownType"] = Object.assign({}, localPlaylistInfo["videos"]["unknownType"], Object.fromEntries(videosOnlyInDatabase.map((videoId) => [videoId, videosCopy[videoId]]))); + playlistInfo["videos"]["knownVideos"] = localPlaylistInfo["videos"]["knownVideos"] ?? {}; + playlistInfo["videos"]["knownShorts"] = localPlaylistInfo["videos"]["knownShorts"] ?? {}; + + // Remove videos from the local playlist object that no longer exist in the database + const videoTypes = ["knownVideos", "knownShorts", "unknownType"]; + for (const type of videoTypes) { + for (const videoId in localPlaylistInfo["videos"][type]) { + if (!allVideosInDatabaseAsSet.has(videoId)) { + delete playlistInfo["videos"][type][videoId]; + } + } + } + } + return playlistInfo; } @@ -275,6 +300,11 @@ async function getPlaylistFromAPI(playlistId, useAPIKeyAtIndex, userQuotaRemaini } let playlistInfo = {}; + playlistInfo["videos"] = {}; + playlistInfo["videos"]["unknownType"] = {}; + playlistInfo["videos"]["knownVideos"] = {}; + playlistInfo["videos"]["knownShorts"] = {}; + let pageToken = ""; let apiResponse = null; @@ -308,7 +338,7 @@ async function getPlaylistFromAPI(playlistId, useAPIKeyAtIndex, userQuotaRemaini } // For each video, add an entry in the form of videoId: uploadTime - playlistInfo["videos"] = Object.fromEntries(apiResponse["items"].map((video) => [video["contentDetails"]["videoId"], video["contentDetails"]["videoPublishedAt"].substring(0, 10)])); + playlistInfo["videos"]["unknownType"] = Object.fromEntries(apiResponse["items"].map((video) => [video["contentDetails"]["videoId"], video["contentDetails"]["videoPublishedAt"].substring(0, 10)])); // We also want to get the uploadTime of the most recent video playlistInfo["lastVideoPublishedAt"] = apiResponse["items"][0]["contentDetails"]["videoPublishedAt"]; @@ -323,11 +353,13 @@ async function getPlaylistFromAPI(playlistId, useAPIKeyAtIndex, userQuotaRemaini progressTextElement.innerText = `\xa0Fetching: ${Math.round(resultsFetchedCount / totalResults * 100)}%`; // For each video, add an entry in the form of videoId: uploadTime - playlistInfo["videos"] = Object.assign(playlistInfo["videos"], Object.fromEntries(apiResponse["items"].map((video) => [video["contentDetails"]["videoId"], video["contentDetails"]["videoPublishedAt"].substring(0, 10)]))); + playlistInfo["videos"]["unknownType"] = Object.assign(playlistInfo["videos"]["unknownType"], Object.fromEntries(apiResponse["items"].map((video) => [video["contentDetails"]["videoId"], video["contentDetails"]["videoPublishedAt"].substring(0, 10)]))); pageToken = apiResponse["nextPageToken"] ? apiResponse["nextPageToken"] : null; } + playlistInfo["lastFetchedFromDB"] = new Date().toISOString(); + return { playlistInfo, userQuotaRemainingToday }; } @@ -358,7 +390,7 @@ async function updatePlaylistFromAPI(playlistInfo, playlistId, useAPIKeyAtIndex, const totalNumVideosOnChannel = apiResponse["pageInfo"]["totalResults"]; // If the channel has already reached the API cap, we don't know how many new videos there are, so we put an estimate to show the user something // The difference could be negative if there are more videos saved in the database than exist in the playlist, e.g videos were deleted - const numLocallyKnownVideos = getLength(playlistInfo["videos"]); + const numLocallyKnownVideos = getLength(getAllVideosFromLocalPlaylist(playlistInfo)); const totalExpectedNewResults = totalNumVideosOnChannel > 19999 ? 1000 : Math.max(totalNumVideosOnChannel - numLocallyKnownVideos, 0); // If there are more results we need to fetch than the user has quota remaining (+leeway) and the user is not using a custom API key, we need to throw an error @@ -568,7 +600,7 @@ async function testVideoExistence(videoId) { videoExists = true; } } catch (error) { - console.log(`Video doesn't exist: ${videoId}`); + console.log(`An error was encountered while checking for video existence, so it is assumed the video does not exist: ${videoId}`); videoExists = false; } @@ -662,13 +694,20 @@ async function chooseRandomVideosFromPlaylist(playlistInfo, channelId, shouldUpd } // Sort all videos by date - let allVideos = Object.assign({}, playlistInfo["videos"], playlistInfo["newVideos"] ?? {}); + let allUnknownType = Object.assign({}, playlistInfo["videos"]["unknownType"], playlistInfo["newVideos"] ?? {}); + let allKnownVideos = playlistInfo["videos"]["knownVideos"]; + let allKnownShorts = playlistInfo["videos"]["knownShorts"]; + + let allVideos = Object.assign({}, allUnknownType, allKnownVideos); + // Only if we are not ignoring shorts, we want to include them in the shuffle + if (!configSync.shuffleIgnoreShortsOption) { + allVideos = Object.assign({}, allVideos, allKnownShorts); + } let videosByDate = Object.keys(allVideos).sort((a, b) => { return new Date(allVideos[b]) - new Date(allVideos[a]); }); - // Error handling for videosToShuffle being undefined/empty is done in applyShuffleFilter() let videosToShuffle = applyShuffleFilter(allVideos, videosByDate, activeShuffleFilterOption, activeOptionValue); let chosenVideos = []; @@ -697,8 +736,7 @@ async function chooseRandomVideosFromPlaylist(playlistInfo, channelId, shouldUpd shouldUpdateDatabase = true; do { // Remove the video from the local playlist object - // It will always be in the "videos" object, as we have just fetched the "newVideos" from the YouTube API - delete playlistInfo["videos"][randomVideo]; + delete playlistInfo["videos"][getVideoType(randomVideo, playlistInfo)][randomVideo]; // Remove the deleted video from the videosToShuffle array and choose a new random video videosToShuffle.splice(videosToShuffle.indexOf(randomVideo), 1); @@ -730,19 +768,33 @@ async function chooseRandomVideosFromPlaylist(playlistInfo, channelId, shouldUpd // If the user does not want to shuffle from shorts, and we do not yet know the type of the chosen video, we check if it is a short if (configSync.shuffleIgnoreShortsOption) { - const videoIsShort = await isShort(randomVideo); + if (playlistInfo["videos"]["unknownType"][randomVideo] !== undefined) { + const videoIsShort = await isShort(randomVideo); - if (videoIsShort) { - console.log('A chosen video was a short, but shorts are ignored. Choosing a new random video.'); + if (videoIsShort) { + console.log('A chosen video was a short, but shorts are ignored. Choosing a new random video.'); - // Remove the video from videosToShuffle to not choose it again - // Do not remove it from the playlistInfo object, as we do not want to delete it from the database - videosToShuffle.splice(videosToShuffle.indexOf(randomVideo), 1); + // Move the video to the knownShorts subdictionary + playlistInfo["videos"]["knownShorts"][randomVideo] = playlistInfo["videos"]["unknownType"][randomVideo]; + delete playlistInfo["videos"]["unknownType"][randomVideo]; + + // Remove the video from videosToShuffle to not choose it again + // Do not remove it from the playlistInfo object, as we do not want to delete it from the database + videosToShuffle.splice(videosToShuffle.indexOf(randomVideo), 1); - // We need to decrement i, as we did not choose a video in this iteration - i--; + // We need to decrement i, as we did not choose a video in this iteration + i--; + } else { + // Move the video to the knownVideos subdictionary + playlistInfo["videos"]["knownVideos"][randomVideo] = playlistInfo["videos"]["unknownType"][randomVideo]; + delete playlistInfo["videos"]["unknownType"][randomVideo]; + + // The video is not a short, so add it to the list of chosen videos and remove it from the pool of videos to choose from + chosenVideos.push(randomVideo); + videosToShuffle.splice(videosToShuffle.indexOf(randomVideo), 1); + } } else { - // The video is not a short, so add it to the list of chosen videos and remove it from the pool of videos to choose from + // Otherwise, the video must be a knownVideo, as we do not include knownShorts in allVideos chosenVideos.push(randomVideo); videosToShuffle.splice(videosToShuffle.indexOf(randomVideo), 1); } @@ -769,6 +821,26 @@ async function chooseRandomVideosFromPlaylist(playlistInfo, channelId, shouldUpd return { chosenVideos, playlistInfo, shouldUpdateDatabase, encounteredDeletedVideos }; } +function getVideoType(videoId, playlistInfo) { + if (playlistInfo["videos"]["unknownType"][videoId]) { + return "unknownType"; + } else if (playlistInfo["videos"]["knownVideos"][videoId]) { + return "knownVideos"; + } else if (playlistInfo["videos"]["knownShorts"][videoId]) { + return "knownShorts"; + } + /* c8 ignore start - This will only happen if we forget to implement something here, so we do not need to test it */ + throw new RandomYoutubeVideoError( + { + code: "RYV-11", + message: `The video that was tested does not exist in the local playlist ${videoId}, so it's type could not be found.`, + solveHint: "Please contact the developer, as this should not happen.", + showTrace: false + } + ); + /* c8 ignore stop */ +} + // Applies a filter to the playlist object, based on the setting set in the popup function applyShuffleFilter(allVideos, videosByDate, activeShuffleFilterOption, activeOptionValue) { let videosToShuffle; @@ -820,7 +892,7 @@ function applyShuffleFilter(allVideos, videosByDate, activeShuffleFilterOption, { code: "RYV-8C", message: `There are no videos that were released after the specified video ID (${activeOptionValue}), or the newest video has not yet been added to the database.`, - solveHint: "The extension will update playlists every 48 hours, so please wait for an update, change the video ID or use a different shuffle filter option.", + solveHint: "The extension updates playlists every 48 hours, so please wait for an update, change the video ID used in the filer or use a different filter option.", showTrace: false } ); @@ -832,7 +904,7 @@ function applyShuffleFilter(allVideos, videosByDate, activeShuffleFilterOption, throw new RandomYoutubeVideoError( { code: "RYV-8D", - message: `The percentage you specified (${activeOptionValue}) should be between 1 and 100. Normally, you should not be able to set such a value.`, + message: `The percentage you specified (${activeOptionValue}%) should be between 1 and 100. Normally, you should not be able to set such a value.`, solveHint: "Please fix the percentage in the popup.", showTrace: false } @@ -925,14 +997,41 @@ async function aprilFoolsJoke() { } } +// ---------- Helper functions ---------- +// Join all videos into one object, useful for database interaction and filtering +function getAllVideosFromLocalPlaylist(playlistInfo) { + return Object.assign({}, playlistInfo["videos"]["knownVideos"], playlistInfo["videos"]["knownShorts"], playlistInfo["videos"]["unknownType"]); +} + +/* c8 ignore start - We do not test this function as it will only trigger if we make a mistake programming somewhere, which will get caught by the test suite */ +function validatePlaylistInfo(playlistInfo) { + // The playlistInfo object must contain lastVideoPublishedAt, lastFetchedFromDB and videos + // The videos subkey must contain knownVideos, knownShorts and unknownType + if (!playlistInfo["lastVideoPublishedAt"] || !playlistInfo["lastFetchedFromDB"] + || !playlistInfo["videos"] || !playlistInfo["videos"]["knownVideos"] || !playlistInfo["videos"]["knownShorts"] || !playlistInfo["videos"]["unknownType"]) { + throw new RandomYoutubeVideoError( + { + code: "RYV-10", + message: `The playlistInfo object is missing one or more required keys (Got: ${Object.keys(playlistInfo)}, videos key: ${Object.keys(playlistInfo["videos"]) ?? "No keys"}).`, + solveHint: "Please try again and inform the developer if the error is not resolved.", + showTrace: false + } + ); + } + if (!playlistInfo["newVideos"]) { + playlistInfo["newVideos"] = {}; + } +} +/* c8 ignore stop */ + // ---------- Local storage ---------- // Tries to fetch the playlist from local storage. If it is not present, returns an empty dictionary async function tryGetPlaylistFromLocalStorage(playlistId) { return await chrome.storage.local.get([playlistId]).then(async (result) => { - if (result[playlistId]) { + if (result[playlistId] !== undefined) { /* c8 ignore start */ // To fix a bug introduced in v2.2.1 - if (!result[playlistId]["videos"]) { + if (result[playlistId]["videos"] === undefined) { // Remove from localStorage await chrome.storage.local.remove([playlistId]); return {} diff --git a/static/manifest.json b/static/manifest.json index e7b24a6e..ef7b6621 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,7 +1,7 @@ { "name": "Random YouTube Video", "description": "Play a random video uploaded on the current YouTube channel.", - "version": "2.2.4", + "version": "2.3.0", "manifest_version": 3, "content_scripts": [ { diff --git a/test/playlistPermutations.js b/test/playlistPermutations.js index ce6d7e06..fb2c448a 100644 --- a/test/playlistPermutations.js +++ b/test/playlistPermutations.js @@ -231,6 +231,11 @@ const playlistModifiers = [ 'DBContainsVideosNotInLocalPlaylist', 'DBContainsNoVideosNotInLocalPlaylist', 'DBContainsDeletedVideos' + ], + // localPlaylistVideoKnowledge: If the local playlist already has sorted videos into knownShorts and knownVideos, or if all videos are of unknownType + [ + 'LocalPlaylistContainsKnownShortsAndVideos', + 'LocalPlaylistContainsOnlyUnknownVideos' ] ]; @@ -301,7 +306,8 @@ let playlistId, localVideos, localDeletedVideos, dbVideos, - dbDeletedVideos; + dbDeletedVideos, + localPlaylistVideoKnowledge; for (let i = 0; i < playlistModifiers[0].length; i++) { for (let j = 0; j < playlistModifiers[1].length; j++) { @@ -309,160 +315,174 @@ for (let i = 0; i < playlistModifiers[0].length; i++) { for (let l = 0; l < playlistModifiers[3].length; l++) { for (let m = 0; m < playlistModifiers[4].length; m++) { for (let n = 0; n < playlistModifiers[5].length; n++) { - // Skip permutations that are not possible - // If the local playlist recently fetched from the DB, it does not matter when the DB entry was last updated or if it contains any videos not in the local playlist, and we know the local playlist was recently accessed - if (playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently" && (playlistModifiers[1][j] !== "DBEntryIsUpToDate" - || playlistModifiers[2][k] !== "LocalPlaylistRecentlyAccessed" || playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist")) { - continue; - } - // If the local playlist does not exist, it cannot contain deleted videos, only shorts, or have been updated from the DB recently - if ((playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") && (playlistModifiers[3][l] === "LocalPlaylistContainsDeletedVideos" - || playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts" || playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently")) { - continue; - } - // If the DB entry does not exist, it cannot contain videos not in the local playlist - if ((playlistModifiers[1][j] === "DBEntryDoesNotExist") && (playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist")) { - continue; - } - // If the DB entry is up-to-date or the local playlist is up-to-date, it does not matter if there are new videos uploaded - if ((playlistModifiers[1][j] === "DBEntryIsUpToDate" || playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently") - && (playlistModifiers[4][m] !== "NoNewVideoUploaded")) { - continue; - } - // We only need one permutation in total that has only shorts saved locally, which is - // UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsOnlyShorts_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist - // This is because the other permutations are covered by the other tests - if (playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts") { - // Discard all permutations that are not the one described above - if (playlistModifiers[0][i] !== "LocalPlaylistFetchedDBRecently" || playlistModifiers[1][j] !== "DBEntryIsUpToDate" - || playlistModifiers[2][k] !== "LocalPlaylistRecentlyAccessed" || playlistModifiers[4][m] !== "NoNewVideoUploaded" - || playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist") { + for (let o = 0; o < playlistModifiers[6].length; o++) { + // Skip permutations that are not possible + // If the local playlist recently fetched from the DB, it does not matter when the DB entry was last updated or if it contains any videos not in the local playlist, and we know the local playlist was recently accessed + if (playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently" && (playlistModifiers[1][j] !== "DBEntryIsUpToDate" + || playlistModifiers[2][k] !== "LocalPlaylistRecentlyAccessed" || playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist")) { continue; } - } + // If the local playlist does not exist, it cannot contain deleted videos, only shorts, have been updated from the DB recently, or have sorted videos into types + if ((playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") && (playlistModifiers[3][l] === "LocalPlaylistContainsDeletedVideos" + || playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts" || playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently" + || playlistModifiers[6][o] === "LocalPlaylistContainsKnownShortsAndVideos")) { + continue; + } + // If the DB entry does not exist, it cannot contain videos not in the local playlist + if ((playlistModifiers[1][j] === "DBEntryDoesNotExist") && (playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist")) { + continue; + } + // If the DB entry is up-to-date or the local playlist is up-to-date, it does not matter if there are new videos uploaded + if ((playlistModifiers[1][j] === "DBEntryIsUpToDate" || playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently") + && (playlistModifiers[4][m] !== "NoNewVideoUploaded")) { + continue; + } + // We only need one permutation in total that has only shorts saved locally, which is + // UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsOnlyShorts_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos + // This is because the other permutations are covered by the other tests + if (playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts") { + // Discard all permutations that are not the one described above + if (playlistModifiers[0][i] !== "LocalPlaylistFetchedDBRecently" || playlistModifiers[1][j] !== "DBEntryIsUpToDate" + || playlistModifiers[2][k] !== "LocalPlaylistRecentlyAccessed" || playlistModifiers[4][m] !== "NoNewVideoUploaded" + || playlistModifiers[5][n] !== "DBContainsNoVideosNotInLocalPlaylist" || playlistModifiers[6][o] !== "LocalPlaylistContainsOnlyUnknownVideos") { + continue; + } + } - // The playlist ID always exists - playlistId = (`UU_${playlistModifiers[0][i]}_${playlistModifiers[1][j]}_${playlistModifiers[2][k]}_${playlistModifiers[3][l]}_${playlistModifiers[4][m]}_${playlistModifiers[5][n]}`); - channelId = playlistId.replace("UU", "UC"); - - // When was the playlist last accessed locally - if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { - lastAccessedLocally = null; - } else if (playlistModifiers[2][k] === "LocalPlaylistRecentlyAccessed") { - lastAccessedLocally = zeroDaysAgo; - } else if (playlistModifiers[2][k] === "LocalPlaylistNotRecentlyAccessed") { - lastAccessedLocally = fourteenDaysAgo; - } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[2][k]}`); - } + // The playlist ID always exists + playlistId = (`UU_${playlistModifiers[0][i]}_${playlistModifiers[1][j]}_${playlistModifiers[2][k]}_${playlistModifiers[3][l]}_${playlistModifiers[4][m]}_${playlistModifiers[5][n]}_${playlistModifiers[6][o]}`); + channelId = playlistId.replace("UU", "UC"); + + // When was the playlist last accessed locally + if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { + lastAccessedLocally = null; + } else if (playlistModifiers[2][k] === "LocalPlaylistRecentlyAccessed") { + lastAccessedLocally = zeroDaysAgo; + } else if (playlistModifiers[2][k] === "LocalPlaylistNotRecentlyAccessed") { + lastAccessedLocally = fourteenDaysAgo; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[2][k]}`); + } - // When was the playlist last updated in the database, if it exists - if (playlistModifiers[1][j] === "DBEntryDoesNotExist") { - lastUpdatedDBAt = null; - } else if (playlistModifiers[1][j] === "DBEntryIsUpToDate") { - lastUpdatedDBAt = zeroDaysAgo; - } else if (playlistModifiers[1][j] === "DBEntryIsNotUpToDate") { - lastUpdatedDBAt = fourteenDaysAgo; - } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[1][j]}`); - } + // When was the playlist last updated in the database, if it exists + if (playlistModifiers[1][j] === "DBEntryDoesNotExist") { + lastUpdatedDBAt = null; + } else if (playlistModifiers[1][j] === "DBEntryIsUpToDate") { + lastUpdatedDBAt = zeroDaysAgo; + } else if (playlistModifiers[1][j] === "DBEntryIsNotUpToDate") { + lastUpdatedDBAt = fourteenDaysAgo; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[1][j]}`); + } - // How long ago until we last fetched the playlist from the database - if (playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently") { - lastFetchedFromDB = zeroDaysAgo; - } else if (playlistModifiers[0][i] === "LocalPlaylistDidNotFetchDBRecently") { - lastFetchedFromDB = fourteenDaysAgo; - } else if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { - lastFetchedFromDB = null; - } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[0][i]}`); - } + // How long ago until we last fetched the playlist from the database + if (playlistModifiers[0][i] === "LocalPlaylistFetchedDBRecently") { + lastFetchedFromDB = zeroDaysAgo; + } else if (playlistModifiers[0][i] === "LocalPlaylistDidNotFetchDBRecently") { + lastFetchedFromDB = fourteenDaysAgo; + } else if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { + lastFetchedFromDB = null; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[0][i]}`); + } - // When was the last locally known video published - localLastVideoPublishedAt = threeDaysAgo.slice(0, 19) + 'Z'; - - if (playlistModifiers[3][l] === "LocalPlaylistContainsDeletedVideos") { - localVideos = deepCopy(defaultLocalVideos); - localDeletedVideos = deepCopy(defaultLocalDeletedVideos); - } else if (playlistModifiers[3][l] === "LocalPlaylistContainsNoDeletedVideos") { - localVideos = deepCopy(defaultLocalVideos); - localDeletedVideos = null; - } else if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { - localVideos = null; - localDeletedVideos = null; - } else if (playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts") { - localVideos = deepCopy(defaultLocalShorts); - localDeletedVideos = null; - } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[3][l]}`); - } + // When was the last locally known video published + localLastVideoPublishedAt = threeDaysAgo.slice(0, 19) + 'Z'; + + if (playlistModifiers[3][l] === "LocalPlaylistContainsDeletedVideos") { + localVideos = deepCopy(defaultLocalVideos); + localDeletedVideos = deepCopy(defaultLocalDeletedVideos); + } else if (playlistModifiers[3][l] === "LocalPlaylistContainsNoDeletedVideos") { + localVideos = deepCopy(defaultLocalVideos); + localDeletedVideos = null; + } else if (playlistModifiers[2][k] === "LocalPlaylistDoesNotExist") { + localVideos = null; + localDeletedVideos = null; + } else if (playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts") { + localVideos = deepCopy(defaultLocalShorts); + localDeletedVideos = null; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[3][l]}`); + } - // Does the db contain videos unknown to the local playlist - if (playlistModifiers[5][n] === "DBContainsVideosNotInLocalPlaylist") { - dbVideos = deepCopy({ ...defaultLocalVideos, ...defaultDBVideos }); - dbDeletedVideos = null; - dbLastVideoPublishedAt = twoDaysAgo.slice(0, 19) + 'Z'; - } else if (playlistModifiers[5][n] === "DBContainsNoVideosNotInLocalPlaylist") { - dbVideos = playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts" - ? deepCopy(defaultLocalShorts) - : deepCopy(defaultLocalVideos); - dbDeletedVideos = null; - dbLastVideoPublishedAt = localLastVideoPublishedAt; - } else if (playlistModifiers[5][n] === "DBContainsDeletedVideos") { - dbVideos = deepCopy(defaultLocalVideos); - dbDeletedVideos = deepCopy(defaultDBDeletedVideos); - dbLastVideoPublishedAt = localLastVideoPublishedAt; - } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[5][n]}`); - } + // Does the db contain videos unknown to the local playlist + if (playlistModifiers[5][n] === "DBContainsVideosNotInLocalPlaylist") { + dbVideos = deepCopy({ ...defaultLocalVideos, ...defaultDBVideos }); + dbDeletedVideos = null; + dbLastVideoPublishedAt = twoDaysAgo.slice(0, 19) + 'Z'; + } else if (playlistModifiers[5][n] === "DBContainsNoVideosNotInLocalPlaylist") { + dbVideos = playlistModifiers[3][l] === "LocalPlaylistContainsOnlyShorts" + ? deepCopy(defaultLocalShorts) + : deepCopy(defaultLocalVideos); + dbDeletedVideos = null; + dbLastVideoPublishedAt = localLastVideoPublishedAt; + } else if (playlistModifiers[5][n] === "DBContainsDeletedVideos") { + dbVideos = deepCopy(defaultLocalVideos); + dbDeletedVideos = deepCopy(defaultDBDeletedVideos); + dbLastVideoPublishedAt = localLastVideoPublishedAt; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[5][n]}`); + } - // Was a new video uploaded since the last time we fetched data from the YouTube API - // newLastVideoPublishedAt is the new date that should be in the database and locally after the update - if (playlistModifiers[1][j] !== "DBEntryIsUpToDate") { - if (playlistModifiers[4][m] === "OneNewVideoUploaded") { - newUploadedVideos = deepCopy(oneNewYTAPIVideo); - newLastVideoPublishedAt = zeroDaysAgo.slice(0, 19) + 'Z'; - } else if (playlistModifiers[4][m] === "MultipleNewVideosUploaded") { - newUploadedVideos = deepCopy(multipleNewYTAPIVideos); - newLastVideoPublishedAt = zeroDaysAgo.slice(0, 19) + 'Z'; - } else if (playlistModifiers[4][m] === "NoNewVideoUploaded") { + // Was a new video uploaded since the last time we fetched data from the YouTube API + // newLastVideoPublishedAt is the new date that should be in the database and locally after the update + if (playlistModifiers[1][j] !== "DBEntryIsUpToDate") { + if (playlistModifiers[4][m] === "OneNewVideoUploaded") { + newUploadedVideos = deepCopy(oneNewYTAPIVideo); + newLastVideoPublishedAt = zeroDaysAgo.slice(0, 19) + 'Z'; + } else if (playlistModifiers[4][m] === "MultipleNewVideosUploaded") { + newUploadedVideos = deepCopy(multipleNewYTAPIVideos); + newLastVideoPublishedAt = zeroDaysAgo.slice(0, 19) + 'Z'; + } else if (playlistModifiers[4][m] === "NoNewVideoUploaded") { + newUploadedVideos = null; + newLastVideoPublishedAt = dbLastVideoPublishedAt; + } else { + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[4][m]}`); + } + } else { newUploadedVideos = null; newLastVideoPublishedAt = dbLastVideoPublishedAt; + } + + // Does the local playlist already have sorted videos into knownShorts and knownVideos, or are all videos of unknownType + if (playlistModifiers[6][o] === "LocalPlaylistContainsKnownShortsAndVideos") { + localPlaylistVideoKnowledge = "knownShortsAndVideos"; + } else if (playlistModifiers[6][o] === "LocalPlaylistContainsOnlyUnknownVideos") { + localPlaylistVideoKnowledge = "unknownType"; } else { - throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[4][m]}`); + throw new Error(`Invalid playlist modifier combination: ${playlistModifiers[6][o]}`); } - } else { - newUploadedVideos = null; - newLastVideoPublishedAt = dbLastVideoPublishedAt; - } - playlistPermutations.push({ - // Also add the modifiers to the object - playlistModifiers: { - lastFetchedFromDB: playlistModifiers[0][i], - lastUpdatedDBAt: playlistModifiers[1][j], - lastAccessedLocally: playlistModifiers[2][k], - containsDeletedVideos: playlistModifiers[3][l], - newUploadedVideos: playlistModifiers[4][m], - dbContainsNewVideos: playlistModifiers[5][n] - }, - playlistId, - channelId, - // Local - lastAccessedLocally, - lastFetchedFromDB, - localVideos, - localDeletedVideos, - localLastVideoPublishedAt, - // DB - dbVideos, - dbDeletedVideos, - lastUpdatedDBAt, - dbLastVideoPublishedAt, - // "YT API" (actually DB) - newUploadedVideos, - newLastVideoPublishedAt - }); + playlistPermutations.push({ + // Also add the modifiers to the object + playlistModifiers: { + lastFetchedFromDB: playlistModifiers[0][i], + lastUpdatedDBAt: playlistModifiers[1][j], + lastAccessedLocally: playlistModifiers[2][k], + containsDeletedVideos: playlistModifiers[3][l], + newUploadedVideos: playlistModifiers[4][m], + dbContainsNewVideos: playlistModifiers[5][n], + localPlaylistVideoKnowledge: playlistModifiers[6][o] + }, + playlistId, + channelId, + // Local + lastAccessedLocally, + lastFetchedFromDB, + localVideos, + localDeletedVideos, + localLastVideoPublishedAt, + localPlaylistVideoKnowledge, + // DB + dbVideos, + dbDeletedVideos, + lastUpdatedDBAt, + dbLastVideoPublishedAt, + // "YT API" (actually DB) + newUploadedVideos, + newLastVideoPublishedAt + }); + } } } } @@ -471,23 +491,24 @@ for (let i = 0; i < playlistModifiers[0].length; i++) { } // ----- Locally stored playlists ----- -export const localPlaylistPermutations = playlistPermutations.reduce((acc, playlist) => { +export const localPlaylistPermutations = playlistPermutations.reduce((localPlaylists, playlist) => { if (playlist.playlistModifiers.lastAccessedLocally !== "LocalPlaylistDoesNotExist") { const playlistCopy = deepCopy(playlist); - const { playlistId, lastAccessedLocally, lastFetchedFromDB, localLastVideoPublishedAt, localVideos, localDeletedVideos } = playlistCopy; - acc[playlistId] = { lastAccessedLocally, lastFetchedFromDB, lastVideoPublishedAt: localLastVideoPublishedAt, videos: deepCopy({ ...localVideos, ...localDeletedVideos }) }; + const { playlistId, lastAccessedLocally, lastFetchedFromDB, localLastVideoPublishedAt, localVideos, localDeletedVideos, localPlaylistVideoKnowledge } = playlistCopy; + const videosWithTypes = makeLocalPlaylistFromVideos(deepCopy(localVideos ?? {}), deepCopy(localDeletedVideos ?? {}), localPlaylistVideoKnowledge); + localPlaylists[playlistId] = { lastAccessedLocally, lastFetchedFromDB, lastVideoPublishedAt: localLastVideoPublishedAt, videos: videosWithTypes }; } - return acc; + return localPlaylists; }, {}); // ----- Database ----- -export const databasePermutations = playlistPermutations.reduce((acc, playlist) => { +export const databasePermutations = playlistPermutations.reduce((databasePlaylists, playlist) => { if (playlist.playlistModifiers.lastUpdatedDBAt !== "DBEntryDoesNotExist") { const playlistCopy = deepCopy(playlist); const { playlistId, lastUpdatedDBAt, dbLastVideoPublishedAt, dbVideos, dbDeletedVideos } = playlistCopy; - acc[playlistId] = { lastUpdatedDBAt, lastVideoPublishedAt: dbLastVideoPublishedAt, videos: deepCopy({ ...dbVideos, ...dbDeletedVideos }) }; + databasePlaylists[playlistId] = { lastUpdatedDBAt, lastVideoPublishedAt: dbLastVideoPublishedAt, videos: deepCopy({ ...dbVideos, ...dbDeletedVideos }) }; } - return acc; + return databasePlaylists; }, {}); // ----- Utility functions ----- @@ -496,6 +517,29 @@ function daysAgo(x) { return new Date(Date.now() - x * 24 * 60 * 60 * 1000).toISOString(); } +function makeLocalPlaylistFromVideos(localVideos, localDeletedVideos, localPlaylistVideoKnowledge) { + const localPlaylist = { + knownShorts: {}, + knownVideos: {}, + unknownType: {} + }; + + if (localPlaylistVideoKnowledge == "knownShortsAndVideos") { + // Assign depending on the type, signified by the prefix of the video id + // Do this for both the localVideos and localDeletedVideos + localPlaylist.knownShorts = Object.fromEntries(Object.entries(localVideos).filter(([key, value]) => key.startsWith("LOC_S_"))); + localPlaylist.knownShorts = Object.assign(localPlaylist.knownShorts ?? {}, Object.fromEntries(Object.entries(localDeletedVideos).filter(([key, value]) => key.startsWith("DEL_LOC_S_")))); + localPlaylist.knownVideos = Object.fromEntries(Object.entries(localVideos).filter(([key, value]) => key.startsWith("LOC_V_"))); + localPlaylist.knownVideos = Object.assign(localPlaylist.knownVideos ?? {}, Object.fromEntries(Object.entries(localDeletedVideos).filter(([key, value]) => key.startsWith("DEL_LOC_V_")))); + } else if (localPlaylistVideoKnowledge == "unknownType") { + localPlaylist.unknownType = Object.assign({}, localVideos, localDeletedVideos); + } else { + throw new Error(`Invalid localPlaylistVideoKnowledge: ${localPlaylistVideoKnowledge}`); + } + + return localPlaylist; +} + // Create a deep copy of an object export function deepCopy(obj) { return JSON.parse(JSON.stringify(obj)); @@ -517,5 +561,4 @@ export function needsYTAPIInteraction(permutation, configSync = configSyncDefaul } else { return (permutation.playlistModifiers.lastAccessedLocally === 'LocalPlaylistDoesNotExist' || permutation.playlistModifiers.lastAccessedLocally === 'LocalPlaylistNotRecentlyAccessed'); } - } \ No newline at end of file diff --git a/test/shuffleVideo.test.js b/test/shuffleVideo.test.js index a7779395..200ec428 100644 --- a/test/shuffleVideo.test.js +++ b/test/shuffleVideo.test.js @@ -7,10 +7,14 @@ import { chooseRandomVideo } from '../src/shuffleVideo.js'; import { configSync, setSyncStorageValue } from '../src/chromeStorage.js'; import { deepCopy, configSyncPermutations, playlistPermutations, needsDBInteraction, needsYTAPIInteraction } from './playlistPermutations.js'; +// ---------- Utility functions ---------- // Utility to get the contents of localStorage at a certain key async function getKeyFromLocalStorage(key) { return await chrome.storage.local.get([key]).then((result) => { - return result[key]; + if (result[key] !== undefined) { + return result[key]; + } + return null; }); } @@ -64,6 +68,11 @@ function checkPlaylistsUploadedToDB(messages, input) { }); } +function getAllVideosAsOneObject(playlistInfo) { + return Object.assign({}, playlistInfo.videos.unknownType, playlistInfo.videos.knownShorts, playlistInfo.videos.knownVideos); +} + +// ---------- Tests ---------- describe('shuffleVideo', function () { beforeEach(function () { @@ -144,22 +153,24 @@ describe('shuffleVideo', function () { it('should throw an error if the channel has no uploads', async function () { // Take a playlist with deleted videos - const testedPlaylist = await getKeyFromLocalStorage('UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist'); + const playlistId = "UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos"; + const channelId = playlistId.replace("UU", "UC"); + const testedPlaylist = await getKeyFromLocalStorage(playlistId); - const newVideos = Object.keys(testedPlaylist.videos).reduce((acc, videoId) => { + const newVideos = Object.keys(testedPlaylist.videos).reduce((newPlaylist, videoId) => { if (videoId.includes('DEL')) { - acc[videoId] = testedPlaylist.videos[videoId]; + newPlaylist[videoId] = testedPlaylist.videos[videoId]; } - return acc; + return newPlaylist; }, {}); - testedPlaylist.videos = newVideos; + testedPlaylist.videos.unknownType = newVideos; await chrome.storage.local.set({ [testedPlaylist.playlistId]: testedPlaylist }); setUpMockResponses(videoExistenceMockResponses); try { - await chooseRandomVideo("UC_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist", false, domElement); + await chooseRandomVideo(channelId, false, domElement); } catch (error) { expect(error).to.be.a(RandomYoutubeVideoError); expect(error.code).to.be("RYV-6B"); @@ -469,25 +480,25 @@ describe('shuffleVideo', function () { }); }); - context('various user settings', async function () { + context('user settings', async function () { // Choose a number of playlists for which to test different user setting combinations const playlists = [ // Playlist that does not exist locally, DB is outdated - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistDoesNotExist_LocalPlaylistContainsNoDeletedVideos_MultipleNewVideosUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistDoesNotExist_LocalPlaylistContainsNoDeletedVideos_MultipleNewVideosUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos')), // Playlist that does not exist locally, DB is up-to-date - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsUpToDate_LocalPlaylistDoesNotExist_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsUpToDate_LocalPlaylistDoesNotExist_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos')), // Locally up-to-date playlist with deleted videos - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsKnownShortsAndVideos')), // Locally up-to-date playlist without deleted videos - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos')), // Playlist that has to be updated from the database, but not from the YT API - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsKnownShortsAndVideos')), // Playlist that has to be updated from the YT API as well, YT API has no new videos - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos')), // Playlist that has to be updated from the YT API as well, YT API has new videos - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_MultipleNewVideosUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsNotUpToDate_LocalPlaylistNotRecentlyAccessed_LocalPlaylistContainsNoDeletedVideos_MultipleNewVideosUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsKnownShortsAndVideos')), // Playlist that only has shorts saved locally - deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsOnlyShorts_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist')), + deepCopy(playlistPermutations.find((playlist) => playlist.playlistId === 'UU_LocalPlaylistFetchedDBRecently_DBEntryIsUpToDate_LocalPlaylistRecentlyAccessed_LocalPlaylistContainsOnlyShorts_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos')), ]; setupChannelSettings(configSyncPermutations.channelSettingsPermutations, playlists); @@ -938,10 +949,10 @@ describe('shuffleVideo', function () { expect(testedPlaylistLocally.lastFetchedFromDB).to.be(input.lastFetchedFromDB); expect(testedPlaylistLocally.lastVideoPublishedAt).to.be(input.localLastVideoPublishedAt); expect(testedPlaylistLocally.videos).to.be.an('object'); - expect(testedPlaylistLocally.videos).to.eql({ ...input.localVideos, ...input.localDeletedVideos }); + expect(getAllVideosAsOneObject(testedPlaylistLocally)).to.eql({ ...input.localVideos, ...input.localDeletedVideos }); } else { // For non-existent playlists, the local storage should be empty - expect(testedPlaylistLocally).to.be(undefined); + expect(testedPlaylistLocally).to.be(null); } }); @@ -984,7 +995,7 @@ describe('shuffleVideo', function () { context('database interaction', function () { if (!needsDBInteraction(input)) { - it('should only interact with the database to remove deleted videos', async function () { + it('should only interact with the database to remove deleted videos if the local playlist is up-to-date', async function () { await chooseRandomVideo(input.channelId, false, domElement); const messages = chrome.runtime.sendMessage.args; @@ -1025,7 +1036,7 @@ describe('shuffleVideo', function () { expect(commands).to.contain('getCurrentTabId'); const numDeletedVideosBefore = Object.keys(input.dbDeletedVideos).filter(videoId => videoId.includes('DEL')).length; - const numDeletedVideosAfter = Object.keys(playlistInfoAfter.videos).filter(videoId => videoId.includes('DEL')).length; + const numDeletedVideosAfter = Object.keys(getAllVideosAsOneObject(playlistInfoAfter)).filter(videoId => videoId.includes('DEL')).length; // Callcount: // 6 if we need to fetch from the YT API, with update or overwrite depending on if a video was deleted @@ -1085,7 +1096,6 @@ describe('shuffleVideo', function () { } const numDeletedVideosAfter = Object.keys(playlistInfoAfter.videos).filter(videoId => videoId.includes('DEL')).length; - expect(numDeletedVideosAfter).to.be(0); }); } else if (input.playlistModifiers.lastUpdatedDBAt === 'DBEntryIsUpToDate') { @@ -1124,6 +1134,41 @@ describe('shuffleVideo', function () { checkPlaylistsUploadedToDB(updateMessages, input); }); } + // Test special case where the database has an entry but no video data + if (input.playlistId === 'UU_LocalPlaylistDidNotFetchDBRecently_DBEntryIsUpToDate_LocalPlaylistDoesNotExist_LocalPlaylistContainsNoDeletedVideos_NoNewVideoUploaded_DBContainsNoVideosNotInLocalPlaylist_LocalPlaylistContainsOnlyUnknownVideos') { + it('should correctly handle the case that the database entry exists but has no videos', async function () { + // Remove the video data from the database entry + let newPlaylistInfoInDB = deepCopy(await chrome.runtime.sendMessage({ command: 'getPlaylistFromDB', data: input.playlistId })); + delete newPlaylistInfoInDB["videos"]; + await chrome.runtime.sendMessage({ command: 'overwritePlaylistInfoInDB', data: { key: input.playlistId, val: newPlaylistInfoInDB } }); + + await chooseRandomVideo(input.channelId, false, domElement); + + const messages = chrome.runtime.sendMessage.args; + const commands = messages.map(arg => arg[0].command); + + expect(chrome.runtime.sendMessage.callCount).to.be(8); + + // First two are from the test setup + expect(commands).to.contain('overwritePlaylistInfoInDB'); + const getPlaylistFromDBCount = commands.filter(command => command === 'getPlaylistFromDB').length; + expect(getPlaylistFromDBCount).to.equal(2); + expect(commands).to.contain('connectionTest'); + expect(commands).to.contain('getAPIKey'); + expect(commands).to.contain('updatePlaylistInfoInDB'); + const updateMessages = messages.filter(arg => arg[0].command === 'updatePlaylistInfoInDB'); + checkPlaylistsUploadedToDB(updateMessages, input); + expect(commands).to.contain('getAllYouTubeTabs'); + expect(commands).to.contain('getCurrentTabId'); + + // Get the entry from the database + const playlistInfoInDB = await chrome.runtime.sendMessage({ command: 'getPlaylistFromDB', data: input.playlistId }); + expect(playlistInfoInDB).to.not.be(null); + expect(Object.keys(playlistInfoInDB.videos).length).to.equal(10); + // These are the videos that should be in the playlist, but due to some mistake were missing from the DB + expect(playlistInfoInDB.videos).to.eql({ ...input.dbVideos }); + }); + } }); context('locally stored playlist', function () { @@ -1134,7 +1179,6 @@ describe('shuffleVideo', function () { const playlistInfoBefore = await getKeyFromLocalStorage(input.playlistId); await chooseRandomVideo(input.channelId, false, domElement); const playlistInfoAfter = await getKeyFromLocalStorage(input.playlistId); - const videosAfter = Object.keys(playlistInfoAfter.videos); if (input.playlistModifiers.lastAccessedLocally !== 'LocalPlaylistDoesNotExist') { expect(playlistInfoAfter.lastAccessedLocally).to.be.greaterThan(playlistInfoBefore.lastAccessedLocally); @@ -1142,7 +1186,7 @@ describe('shuffleVideo', function () { if (!needsDBInteraction(input)) { expect(playlistInfoAfter.lastFetchedFromDB).to.be(playlistInfoBefore.lastFetchedFromDB); expect(playlistInfoAfter.lastVideoPublishedAt).to.be(playlistInfoBefore.lastVideoPublishedAt); - expect(playlistInfoBefore.videos).to.have.keys(videosAfter); + expect(playlistInfoBefore.videos).to.have.keys(Object.keys(playlistInfoAfter.videos)); // If the database contains videos not in the local playlist } else if (input.playlistModifiers.dbContainsNewVideos === 'DBContainsVideosNotInLocalPlaylist' || input.playlistModifiers.dbContainsNewVideos === 'DBContainsDeletedVideos') { @@ -1154,16 +1198,16 @@ describe('shuffleVideo', function () { } if (input.localDeletedVideos) { // By fetching the videos from the database, any locally deleted videos should have been removed from the local playlist - expect(playlistInfoAfter.videos).to.not.have.keys(Object.keys(input.localDeletedVideos)); + expect(getAllVideosAsOneObject(playlistInfoAfter)).to.not.have.keys(Object.keys(input.localDeletedVideos)); } - expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos }).to.have.keys(videosAfter); + expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos }).to.have.keys(Object.keys(getAllVideosAsOneObject(playlistInfoAfter))); // The database and local playlist are in sync already } else { expect(playlistInfoAfter.lastFetchedFromDB).to.be.greaterThan(playlistInfoBefore.lastFetchedFromDB); expect(playlistInfoAfter.lastVideoPublishedAt).to.be(playlistInfoBefore.lastVideoPublishedAt); // By fetching the videos from the database, any deleted videos should have been removed from the local playlist // We also know that here, the db did not contain any deleted videos - expect(playlistInfoAfter.videos).to.have.keys(Object.keys({ ...input.localVideos, ...input.dbVideos })); + expect(getAllVideosAsOneObject(playlistInfoAfter)).to.have.keys(Object.keys({ ...input.localVideos, ...input.dbVideos })); } } else { // If the playlist did not exist locally before, it should now @@ -1172,7 +1216,7 @@ describe('shuffleVideo', function () { expect(playlistInfoAfter.lastAccessedLocally).to.be.greaterThan(timeBefore); expect(playlistInfoAfter.lastFetchedFromDB).to.be.greaterThan(timeBefore); expect(playlistInfoAfter.lastVideoPublishedAt).to.be(input.dbLastVideoPublishedAt); - expect({ ...input.dbVideos, ...input.dbDeletedVideos }).to.have.keys(videosAfter) + expect({ ...input.dbVideos, ...input.dbDeletedVideos }).to.have.keys(Object.keys(getAllVideosAsOneObject(playlistInfoAfter))) } }); // For playlists that need to interact with the YouTube API @@ -1182,7 +1226,6 @@ describe('shuffleVideo', function () { const playlistInfoBefore = await getKeyFromLocalStorage(input.playlistId); await chooseRandomVideo(input.channelId, false, domElement); const playlistInfoAfter = await getKeyFromLocalStorage(input.playlistId); - const videosAfter = Object.keys(playlistInfoAfter.videos); if (!needsDBInteraction(input)) { throw new Error('This test should not be run for playlists that do not need to interact with the database. If they are, this means we are now using different configSync objects.'); @@ -1195,19 +1238,23 @@ describe('shuffleVideo', function () { if (input.playlistModifiers.dbContainsNewVideos === 'DBContainsNoVideosNotInLocalPlaylist' && input.playlistModifiers.newUploadedVideos === 'NoNewVideoUploaded') { expect(playlistInfoAfter.lastVideoPublishedAt.substring(0, 10)).to.be(playlistInfoBefore.lastVideoPublishedAt.substring(0, 10)); if (input.playlistModifiers.containsDeletedVideos === 'LocalPlaylistContainsDeletedVideos') { - expect(playlistInfoAfter.videos).to.have.keys(Object.keys({ ...input.localVideos, ...input.dbVideos })); - } else { + expect(getAllVideosAsOneObject(playlistInfoAfter)).to.have.keys(Object.keys({ ...input.localVideos, ...input.dbVideos })); + } else if (input.playlistModifiers.lastUpdatedDBAt !== 'DBEntryDoesNotExist') { expect(playlistInfoAfter.videos).to.eql(playlistInfoBefore.videos); + } else { + // If the DB entry does not exist, we overwrite possible local knowledge about known videos and shorts + expect(playlistInfoAfter.videos.knownVideos).to.eql({}); + expect(playlistInfoAfter.videos.knownShorts).to.eql({}); } // If there are deleted videos, but no new videos } else if (input.playlistModifiers.dbContainsNewVideos === 'DBContainsDeletedVideos' && input.playlistModifiers.newUploadedVideos === 'NoNewVideoUploaded') { expect(playlistInfoAfter.lastVideoPublishedAt.substring(0, 10)).to.be(playlistInfoBefore.lastVideoPublishedAt.substring(0, 10)); - expect(playlistInfoAfter.videos).to.have.keys(Object.keys(input.localVideos)); - expect({ ...playlistInfoAfter.videos, ...input.dbDeletedVideos }).to.have.keys(Object.keys(playlistInfoAfter.videos)); + expect(getAllVideosAsOneObject(playlistInfoAfter)).to.have.keys(Object.keys(input.localVideos)); + expect({ ...getAllVideosAsOneObject(playlistInfoAfter), ...input.dbDeletedVideos }).to.have.keys(Object.keys(getAllVideosAsOneObject(playlistInfoAfter))); // If there were new videos, either in the DB or uploaded } else { expect(playlistInfoAfter.lastVideoPublishedAt).to.be.greaterThan(playlistInfoBefore.lastVideoPublishedAt); - expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos, ...input.newUploadedVideos }).to.have.keys(videosAfter); + expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos, ...input.newUploadedVideos }).to.have.keys(Object.keys(getAllVideosAsOneObject(playlistInfoAfter))); } } else { // If the playlist did not exist locally before, it should now @@ -1216,7 +1263,7 @@ describe('shuffleVideo', function () { expect(playlistInfoAfter.lastAccessedLocally).to.be.greaterThan(timeBefore); expect(playlistInfoAfter.lastFetchedFromDB).to.be.greaterThan(timeBefore); expect(playlistInfoAfter.lastVideoPublishedAt.substring(0, 10)).to.be(input.newLastVideoPublishedAt.substring(0, 10)); - expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos, ...input.newUploadedVideos }).to.have.keys(videosAfter) + expect({ ...input.localVideos, ...input.dbVideos, ...input.dbDeletedVideos, ...input.newUploadedVideos }).to.have.keys(Object.keys(getAllVideosAsOneObject(playlistInfoAfter))) } }); diff --git a/test/testSetup.js b/test/testSetup.js index 849981fc..8cbc863c 100644 --- a/test/testSetup.js +++ b/test/testSetup.js @@ -64,7 +64,7 @@ chrome.runtime.sendMessage.callsFake((request) => { // Only for the tests case "setKeyInDB": mockedDatabase[request.data.key] = request.data.val; - return "Key was removed from database."; + return "Key was set in the database (mocked for tests)."; case "getAPIKey": return getAPIKey(false, request.data.useAPIKeyAtIndex);