diff --git a/src/constants.js b/src/constants.js index bd80da2ffdc17..f1d68b592466a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -44,7 +44,7 @@ const DBActions = { PLAYLISTS: { UPSERT_VIDEO: 'db-action-playlists-upsert-video-by-playlist-name', - UPSERT_VIDEO_IDS: 'db-action-playlists-upsert-video-ids-by-playlist-id', + UPSERT_VIDEOS: 'db-action-playlists-upsert-videos-by-playlist-name', DELETE_VIDEO_ID: 'db-action-playlists-delete-video-by-playlist-name', DELETE_VIDEO_IDS: 'db-action-playlists-delete-video-ids', DELETE_ALL_VIDEOS: 'db-action-playlists-delete-all-videos' diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js index 71e1ca3889682..070ebda8e8e2a 100644 --- a/src/datastores/handlers/base.js +++ b/src/datastores/handlers/base.js @@ -64,8 +64,8 @@ class History { return db.history.updateAsync({ videoId }, { $set: { watchProgress } }, { upsert: true }) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { - return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId } }, { upsert: true }) + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { + return db.history.updateAsync({ videoId }, { $set: { lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } }, { upsert: true }) } static delete(videoId) { @@ -112,18 +112,22 @@ class Playlists { return db.playlists.findAsync({}) } - static upsertVideoByPlaylistName(playlistName, videoData) { + static upsert(playlist) { + return db.playlists.updateAsync({ _id: playlist._id }, { $set: playlist }, { upsert: true }) + } + + static upsertVideoByPlaylistId(_id, videoData) { return db.playlists.updateAsync( - { playlistName }, + { _id }, { $push: { videos: videoData } }, { upsert: true } ) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { + static upsertVideosByPlaylistId(_id, videos) { return db.playlists.updateAsync( { _id }, - { $push: { videos: { $each: videoIds } } }, + { $push: { videos: { $each: videos } } }, { upsert: true } ) } @@ -132,25 +136,25 @@ class Playlists { return db.playlists.removeAsync({ _id, protected: { $ne: true } }) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { + static deleteVideoIdByPlaylistId(_id, playlistItemId) { return db.playlists.updateAsync( - { playlistName }, - { $pull: { videos: { videoId } } }, + { _id }, + { $pull: { videos: { playlistItemId } } }, { upsert: true } ) } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + static deleteVideoIdsByPlaylistId(_id, videoIds) { return db.playlists.updateAsync( - { playlistName }, + { _id }, { $pull: { videos: { $in: videoIds } } }, { upsert: true } ) } - static deleteAllVideosByPlaylistName(playlistName) { + static deleteAllVideosByPlaylistId(_id) { return db.playlists.updateAsync( - { playlistName }, + { _id }, { $set: { videos: [] } }, { upsert: true } ) @@ -161,7 +165,7 @@ class Playlists { } static deleteAll() { - return db.playlists.removeAsync({ protected: { $ne: true } }) + return db.playlists.removeAsync({}, { multi: true }) } static persist() { @@ -174,7 +178,7 @@ function compactAllDatastores() { Settings.persist(), History.persist(), Profiles.persist(), - Playlists.persist() + Playlists.persist(), ]) } @@ -184,7 +188,7 @@ const baseHandlers = { profiles: Profiles, playlists: Playlists, - compactAllDatastores + compactAllDatastores, } export default baseHandlers diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js index ddb90ff82434e..d84ccac13c78d 100644 --- a/src/datastores/handlers/electron.js +++ b/src/datastores/handlers/electron.js @@ -42,12 +42,12 @@ class History { ) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { return ipcRenderer.invoke( IpcChannels.DB_HISTORY, { action: DBActions.HISTORY.UPDATE_PLAYLIST, - data: { videoId, lastViewedPlaylistId } + data: { videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId } } ) } @@ -126,22 +126,29 @@ class Playlists { ) } - static upsertVideoByPlaylistName(playlistName, videoData) { + static upsert(playlist) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.UPSERT, data: playlist } + ) + } + + static upsertVideoByPlaylistId(_id, videoData) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.UPSERT_VIDEO, - data: { playlistName, videoData } + data: { _id, videoData } } ) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { + static upsertVideosByPlaylistId(_id, videos) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { - action: DBActions.PLAYLISTS.UPSERT_VIDEO_IDS, - data: { _id, videoIds } + action: DBActions.PLAYLISTS.UPSERT_VIDEOS, + data: { _id, videos } } ) } @@ -153,32 +160,32 @@ class Playlists { ) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { + static deleteVideoIdByPlaylistId(_id, playlistItemId) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_VIDEO_ID, - data: { playlistName, videoId } + data: { _id, playlistItemId } } ) } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + static deleteVideoIdsByPlaylistId(_id, videoIds) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_VIDEO_IDS, - data: { playlistName, videoIds } + data: { _id, videoIds } } ) } - static deleteAllVideosByPlaylistName(playlistName) { + static deleteAllVideosByPlaylistId(_id) { return ipcRenderer.invoke( IpcChannels.DB_PLAYLISTS, { action: DBActions.PLAYLISTS.DELETE_ALL_VIDEOS, - data: playlistName + data: _id } ) } diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js index a81eb305d1dd9..103d93d441a2b 100644 --- a/src/datastores/handlers/web.js +++ b/src/datastores/handlers/web.js @@ -33,8 +33,8 @@ class History { return baseHandlers.history.updateWatchProgress(videoId, watchProgress) } - static updateLastViewedPlaylist(videoId, lastViewedPlaylistId) { - return baseHandlers.history.updateLastViewedPlaylist(videoId, lastViewedPlaylistId) + static updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) { + return baseHandlers.history.updateLastViewedPlaylist(videoId, lastViewedPlaylistId, lastViewedPlaylistType, lastViewedPlaylistItemId) } static delete(videoId) { @@ -81,28 +81,32 @@ class Playlists { return baseHandlers.playlists.find() } - static upsertVideoByPlaylistName(playlistName, videoData) { - return baseHandlers.playlists.upsertVideoByPlaylistName(playlistName, videoData) + static upsert(playlist) { + return baseHandlers.playlists.upsert(playlist) } - static upsertVideoIdsByPlaylistId(_id, videoIds) { - return baseHandlers.playlists.upsertVideoIdsByPlaylistId(_id, videoIds) + static upsertVideoByPlaylistId(_id, videoData) { + return baseHandlers.playlists.upsertVideoByPlaylistId(_id, videoData) + } + + static upsertVideosByPlaylistId(_id, videoData) { + return baseHandlers.playlists.upsertVideosByPlaylistId(_id, videoData) } static delete(_id) { return baseHandlers.playlists.delete(_id) } - static deleteVideoIdByPlaylistName(playlistName, videoId) { - return baseHandlers.playlists.deleteVideoIdByPlaylistName(playlistName, videoId) + static deleteVideoIdByPlaylistId(_id, playlistItemId) { + return baseHandlers.playlists.deleteVideoIdByPlaylistId(_id, playlistItemId) } - static deleteVideoIdsByPlaylistName(playlistName, videoIds) { - return baseHandlers.playlists.deleteVideoIdsByPlaylistName(playlistName, videoIds) + static deleteVideoIdsByPlaylistId(_id, videoIds) { + return baseHandlers.playlists.deleteVideoIdsByPlaylistId(_id, videoIds) } - static deleteAllVideosByPlaylistName(playlistName) { - return baseHandlers.playlists.deleteAllVideosByPlaylistName(playlistName) + static deleteAllVideosByPlaylistId(_id) { + return baseHandlers.playlists.deleteAllVideosByPlaylistId(_id) } static deleteMultiple(ids) { diff --git a/src/main/index.js b/src/main/index.js index 95bc4aebdf27c..0ca9917a3c86d 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -847,7 +847,7 @@ function runApp() { return null case DBActions.HISTORY.UPDATE_PLAYLIST: - await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId) + await baseHandlers.history.updateLastViewedPlaylist(data.videoId, data.lastViewedPlaylistId, data.lastViewedPlaylistType, data.lastViewedPlaylistItemId) syncOtherWindows( IpcChannels.SYNC_HISTORY, event, @@ -948,15 +948,27 @@ function runApp() { switch (action) { case DBActions.GENERAL.CREATE: await baseHandlers.playlists.create(data) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.CREATE, data } + ) return null case DBActions.GENERAL.FIND: return await baseHandlers.playlists.find() + case DBActions.GENERAL.UPSERT: + await baseHandlers.playlists.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + case DBActions.PLAYLISTS.UPSERT_VIDEO: - await baseHandlers.playlists.upsertVideoByPlaylistName(data.playlistName, data.videoData) + await baseHandlers.playlists.upsertVideoByPlaylistId(data._id, data.videoData) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, @@ -964,20 +976,26 @@ function runApp() { ) return null - case DBActions.PLAYLISTS.UPSERT_VIDEO_IDS: - await baseHandlers.playlists.upsertVideoIdsByPlaylistId(data._id, data.videoIds) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + case DBActions.PLAYLISTS.UPSERT_VIDEOS: + await baseHandlers.playlists.upsertVideosByPlaylistId(data._id, data.videos) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.PLAYLISTS.UPSERT_VIDEOS, data } + ) return null case DBActions.GENERAL.DELETE: await baseHandlers.playlists.delete(data) - // TODO: Syncing (implement only when it starts being used) - // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) return null case DBActions.PLAYLISTS.DELETE_VIDEO_ID: - await baseHandlers.playlists.deleteVideoIdByPlaylistName(data.playlistName, data.videoId) + await baseHandlers.playlists.deleteVideoIdByPlaylistId(data._id, data.playlistItemId) syncOtherWindows( IpcChannels.SYNC_PLAYLISTS, event, @@ -986,13 +1004,13 @@ function runApp() { return null case DBActions.PLAYLISTS.DELETE_VIDEO_IDS: - await baseHandlers.playlists.deleteVideoIdsByPlaylistName(data.playlistName, data.videoIds) + await baseHandlers.playlists.deleteVideoIdsByPlaylistId(data._id, data.videoIds) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null case DBActions.PLAYLISTS.DELETE_ALL_VIDEOS: - await baseHandlers.playlists.deleteAllVideosByPlaylistName(data) + await baseHandlers.playlists.deleteAllVideosByPlaylistId(data) // TODO: Syncing (implement only when it starts being used) // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) return null diff --git a/src/renderer/App.js b/src/renderer/App.js index 25ef07ca4e8b5..e152ea8fb2d27 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -9,6 +9,8 @@ import FtPrompt from './components/ft-prompt/ft-prompt.vue' import FtButton from './components/ft-button/ft-button.vue' import FtToast from './components/ft-toast/ft-toast.vue' import FtProgressBar from './components/ft-progress-bar/ft-progress-bar.vue' +import FtPlaylistAddVideoPrompt from './components/ft-playlist-add-video-prompt/ft-playlist-add-video-prompt.vue' +import FtCreatePlaylistPrompt from './components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue' import { marked } from 'marked' import { IpcChannels } from '../constants' import packageDetails from '../../package.json' @@ -28,7 +30,9 @@ export default defineComponent({ FtPrompt, FtButton, FtToast, - FtProgressBar + FtProgressBar, + FtPlaylistAddVideoPrompt, + FtCreatePlaylistPrompt, }, data: function () { return { @@ -66,6 +70,12 @@ export default defineComponent({ checkForBlogPosts: function () { return this.$store.getters.getCheckForBlogPosts }, + showAddToPlaylistPrompt: function () { + return this.$store.getters.getShowAddToPlaylistPrompt + }, + showCreatePlaylistPrompt: function () { + return this.$store.getters.getShowCreatePlaylistPrompt + }, windowTitle: function () { const routeTitle = this.$route.meta.title if (routeTitle !== 'Channel' && routeTitle !== 'Watch' && routeTitle !== 'Hashtag') { diff --git a/src/renderer/App.vue b/src/renderer/App.vue index e0394fae9711b..cb563b887b91f 100644 --- a/src/renderer/App.vue +++ b/src/renderer/App.vue @@ -74,6 +74,12 @@ :option-values="externalLinkOpeningPromptValues" @click="handleExternalLinkOpeningPromptAnswer" /> + + { + playlists.forEach((playlistData) => { // We would technically already be done by the time the data is parsed, // however we want to limit the possibility of malicious data being sent // to the app, so we'll only grab the data we need here. @@ -911,58 +930,71 @@ export default defineComponent({ const playlistObject = {} Object.keys(playlistData).forEach((key) => { - if (!requiredKeys.includes(key) && !optionalKeys.includes(key)) { + if ([requiredKeys, optionalKeys, ignoredKeys].every((ks) => !ks.includes(key))) { const message = `${this.$t('Settings.Data Settings.Unknown data key')}: ${key}` showToast(message) } else if (key === 'videos') { const videoArray = [] playlistData.videos.forEach((video) => { - let hasAllKeys = true - requiredVideoKeys.forEach((videoKey) => { - if (!Object.keys(video).includes(videoKey)) { - hasAllKeys = false - } - }) + const videoPropertyKeys = Object.keys(video) + const videoObjectHasAllRequiredKeys = requiredVideoKeys.every((k) => videoPropertyKeys.includes(k)) - if (hasAllKeys) { + if (videoObjectHasAllRequiredKeys) { videoArray.push(video) } }) playlistObject[key] = videoArray - } else { + } else if (!ignoredKeys.includes(key)) { + // Do nothing for keys to be ignored playlistObject[key] = playlistData[key] } }) - const objectKeys = Object.keys(playlistObject) + const playlistObjectKeys = Object.keys(playlistObject) + const playlistObjectHasAllRequiredKeys = requiredKeys.every((k) => playlistObjectKeys.includes(k)) - if ((objectKeys.length < requiredKeys.length) || playlistObject.videos.length === 0) { - const message = this.$t('Settings.Data Settings.Playlist insufficient data', { playlist: playlistData.playlistName }) - showToast(message) - } else { + if (playlistObjectHasAllRequiredKeys) { const existingPlaylist = this.allPlaylists.find((playlist) => { return playlist.playlistName === playlistObject.playlistName }) if (existingPlaylist !== undefined) { playlistObject.videos.forEach((video) => { - const videoExists = existingPlaylist.videos.some((x) => { - return x.videoId === video.videoId - }) + let videoExists = false + if (video.playlistItemId != null) { + // Find by `playlistItemId` if present + videoExists = existingPlaylist.videos.some((x) => { + // Allow duplicate (by videoId) videos to be added + return x.videoId === video.videoId && x.playlistItemId === video.playlistItemId + }) + } else { + // Older playlist exports have no `playlistItemId` but have `timeAdded` + // Which might be duplicate for copied playlists with duplicate `videoId` + videoExists = existingPlaylist.videos.some((x) => { + // Allow duplicate (by videoId) videos to be added + return x.videoId === video.videoId && x.timeAdded === video.timeAdded + }) + } if (!videoExists) { + // Keep original `timeAdded` value const payload = { - playlistName: existingPlaylist.playlistName, - videoData: video + _id: existingPlaylist._id, + videoData: video, } this.addVideo(payload) } }) + // Update playlist's `lastUpdatedAt` + this.updatePlaylist({ _id: existingPlaylist._id }) } else { this.addPlaylist(playlistObject) } + } else { + const message = this.$t('Settings.Data Settings.Playlist insufficient data', { playlist: playlistData.playlistName }) + showToast(message) } }) @@ -986,6 +1018,55 @@ export default defineComponent({ await this.promptAndWriteToFile(options, JSON.stringify(this.allPlaylists), 'All playlists has been successfully exported') }, + exportPlaylistsForOlderVersionsSometimes: function () { + if (this.shouldExportPlaylistForOlderVersions) { + this.exportPlaylistsForOlderVersions() + } else { + this.exportPlaylists() + } + }, + + exportPlaylistsForOlderVersions: async function () { + const dateStr = getTodayDateStrLocalTimezone() + const exportFileName = 'freetube-playlists-as-single-favorites-playlist-' + dateStr + '.db' + + const options = { + defaultPath: exportFileName, + filters: [ + { + name: 'Database File', + extensions: ['db'] + } + ] + } + + const favoritesPlaylistData = { + playlistName: 'Favorites', + protected: true, + videos: [], + } + + this.allPlaylists.forEach((playlist) => { + playlist.videos.forEach((video) => { + const videoAlreadyAdded = favoritesPlaylistData.videos.some((v) => { + return v.videoId === video.videoId + }) + if (videoAlreadyAdded) { return } + + favoritesPlaylistData.videos.push( + Object.assign({ + // The "required" keys during import (but actually unused) in older versions + isLive: false, + paid: false, + published: '', + }, video) + ) + }) + }) + + await this.promptAndWriteToFile(options, JSON.stringify([favoritesPlaylistData]), 'All playlists has been successfully exported') + }, + convertOldFreeTubeFormatToNew(oldData) { const convertedData = [] for (const channel of oldData) { @@ -1151,7 +1232,8 @@ export default defineComponent({ 'updateShowProgressBar', 'updateHistory', 'addPlaylist', - 'addVideo' + 'addVideo', + 'updatePlaylist', ]), ...mapMutations([ diff --git a/src/renderer/components/data-settings/data-settings.vue b/src/renderer/components/data-settings/data-settings.vue index cdc55727fd752..456c6d3585c57 100644 --- a/src/renderer/components/data-settings/data-settings.vue +++ b/src/renderer/components/data-settings/data-settings.vue @@ -49,7 +49,17 @@ /> + + + { + return playlist.playlistName === this.playlistName + }) + if (nameExists !== -1) { + showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["There is already a playlist with this name. Please pick a different name."]')) + return + } + + const playlistObject = { + playlistName: this.playlistName, + protected: false, + description: '', + videos: [], + } + + try { + this.addPlaylist(playlistObject) + showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["Playlist {playlistName} has been successfully created."]', { + playlistName: this.playlistName, + })) + } catch (e) { + showToast(this.$t('User Playlists.CreatePlaylistPrompt.Toast["There was an issue with creating the playlist."]')) + console.error(e) + } finally { + this.hideCreatePlaylistPrompt() + } + }, + + ...mapActions([ + 'addPlaylist', + 'hideCreatePlaylistPrompt', + ]) + } +}) diff --git a/src/renderer/components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue b/src/renderer/components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue new file mode 100644 index 0000000000000..f541d08892a36 --- /dev/null +++ b/src/renderer/components/ft-create-playlist-prompt/ft-create-playlist-prompt.vue @@ -0,0 +1,34 @@ + + +