diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..ba55e79b9 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,77 @@ +// IPC Channels +const IpcChannels = { + ENABLE_PROXY: 'enable-proxy', + DISABLE_PROXY: 'disable-proxy', + OPEN_EXTERNAL_LINK: 'open-external-link', + GET_SYSTEM_LOCALE: 'get-system-locale', + GET_USER_DATA_PATH: 'get-user-data-path', + GET_USER_DATA_PATH_SYNC: 'get-user-data-path-sync', + SHOW_OPEN_DIALOG: 'show-open-dialog', + SHOW_SAVE_DIALOG: 'show-save-dialog', + STOP_POWER_SAVE_BLOCKER: 'stop-power-save-blocker', + START_POWER_SAVE_BLOCKER: 'start-power-save-blocker', + CREATE_NEW_WINDOW: 'create-new-window', + OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', + + DB_SETTINGS: 'db-settings', + DB_HISTORY: 'db-history', + DB_PROFILES: 'db-profiles', + DB_PLAYLISTS: 'db-playlists', + + SYNC_SETTINGS: 'sync-settings', + SYNC_HISTORY: 'sync-history', + SYNC_PROFILES: 'sync-profiles', + SYNC_PLAYLISTS: 'sync-playlists' +} + +const DBActions = { + GENERAL: { + CREATE: 'db-action-create', + FIND: 'db-action-find', + UPSERT: 'db-action-upsert', + DELETE: 'db-action-delete', + DELETE_MULTIPLE: 'db-action-delete-multiple', + DELETE_ALL: 'db-action-delete-all', + PERSIST: 'db-action-persist' + }, + + HISTORY: { + UPDATE_WATCH_PROGRESS: 'db-action-history-update-watch-progress' + }, + + PLAYLISTS: { + UPSERT_VIDEO: 'db-action-playlists-upsert-video-by-playlist-name', + UPSERT_VIDEO_IDS: 'db-action-playlists-upsert-video-ids-by-playlist-id', + 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' + } +} + +const SyncEvents = { + GENERAL: { + CREATE: 'sync-create', + UPSERT: 'sync-upsert', + DELETE: 'sync-delete', + DELETE_ALL: 'sync-delete-all' + }, + + HISTORY: { + UPDATE_WATCH_PROGRESS: 'sync-history-update-watch-progress' + }, + + PLAYLISTS: { + UPSERT_VIDEO: 'sync-playlists-upsert-video', + DELETE_VIDEO: 'sync-playlists-delete-video' + } +} + +// Utils +const MAIN_PROFILE_ID = 'allChannels' + +export { + IpcChannels, + DBActions, + SyncEvents, + MAIN_PROFILE_ID +} diff --git a/src/datastores/handlers/base.js b/src/datastores/handlers/base.js new file mode 100644 index 000000000..90b9d183d --- /dev/null +++ b/src/datastores/handlers/base.js @@ -0,0 +1,153 @@ +import db from '../index' + +class Settings { + static find() { + return db.settings.find({ _id: { $ne: 'bounds' } }) + } + + static upsert(_id, value) { + return db.settings.update({ _id }, { _id, value }, { upsert: true }) + } + + // ******************** // + // Unique Electron main process handlers + static _findAppReadyRelatedSettings() { + return db.settings.find({ + $or: [ + { _id: 'disableSmoothScrolling' }, + { _id: 'useProxy' }, + { _id: 'proxyProtocol' }, + { _id: 'proxyHostname' }, + { _id: 'proxyPort' } + ] + }) + } + + static _findBounds() { + return db.settings.findOne({ _id: 'bounds' }) + } + + static _updateBounds(value) { + return db.settings.update({ _id: 'bounds' }, { _id: 'bounds', value }, { upsert: true }) + } + // ******************** // +} + +class History { + static find() { + return db.history.find({}).sort({ timeWatched: -1 }) + } + + static upsert(record) { + return db.history.update({ videoId: record.videoId }, record, { upsert: true }) + } + + static updateWatchProgress(videoId, watchProgress) { + return db.history.update({ videoId }, { $set: { watchProgress } }, { upsert: true }) + } + + static delete(videoId) { + return db.history.remove({ videoId }) + } + + static deleteAll() { + return db.history.remove({}, { multi: true }) + } + + static persist() { + db.history.persistence.compactDatafile() + } +} + +class Profiles { + static create(profile) { + return db.profiles.insert(profile) + } + + static find() { + return db.profiles.find({}) + } + + static upsert(profile) { + return db.profiles.update({ _id: profile._id }, profile, { upsert: true }) + } + + static delete(id) { + return db.profiles.remove({ _id: id }) + } + + static persist() { + db.profiles.persistence.compactDatafile() + } +} + +class Playlists { + static create(playlists) { + return db.playlists.insert(playlists) + } + + static find() { + return db.playlists.find({}) + } + + static upsertVideoByPlaylistName(playlistName, videoData) { + return db.playlists.update( + { playlistName }, + { $push: { videos: videoData } }, + { upsert: true } + ) + } + + static upsertVideoIdsByPlaylistId(_id, videoIds) { + return db.playlists.update( + { _id }, + { $push: { videos: { $each: videoIds } } }, + { upsert: true } + ) + } + + static delete(_id) { + return db.playlists.remove({ _id, protected: { $ne: true } }) + } + + static deleteVideoIdByPlaylistName(playlistName, videoId) { + return db.playlists.update( + { playlistName }, + { $pull: { videos: { videoId } } }, + { upsert: true } + ) + } + + static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + return db.playlists.update( + { playlistName }, + { $pull: { videos: { $in: videoIds } } }, + { upsert: true } + ) + } + + static deleteAllVideosByPlaylistName(playlistName) { + return db.playlists.update( + { playlistName }, + { $set: { videos: [] } }, + { upsert: true } + ) + } + + static deleteMultiple(ids) { + return db.playlists.remove({ _id: { $in: ids }, protected: { $ne: true } }) + } + + static deleteAll() { + return db.playlists.remove({ protected: { $ne: true } }) + } +} + +const baseHandlers = { + settings: Settings, + history: History, + profiles: Profiles, + playlists: Playlists +} + +export default baseHandlers diff --git a/src/datastores/handlers/electron.js b/src/datastores/handlers/electron.js new file mode 100644 index 000000000..a054d7b5f --- /dev/null +++ b/src/datastores/handlers/electron.js @@ -0,0 +1,198 @@ +import { ipcRenderer } from 'electron' +import { IpcChannels, DBActions } from '../../constants' + +class Settings { + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_SETTINGS, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsert(_id, value) { + return ipcRenderer.invoke( + IpcChannels.DB_SETTINGS, + { action: DBActions.GENERAL.UPSERT, data: { _id, value } } + ) + } +} + +class History { + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsert(record) { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.GENERAL.UPSERT, data: record } + ) + } + + static updateWatchProgress(videoId, watchProgress) { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { + action: DBActions.HISTORY.UPDATE_WATCH_PROGRESS, + data: { videoId, watchProgress } + } + ) + } + + static delete(videoId) { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.GENERAL.DELETE, data: videoId } + ) + } + + static deleteAll() { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.GENERAL.DELETE_ALL } + ) + } + + static persist() { + return ipcRenderer.invoke( + IpcChannels.DB_HISTORY, + { action: DBActions.GENERAL.PERSIST } + ) + } +} + +class Profiles { + static create(profile) { + return ipcRenderer.invoke( + IpcChannels.DB_PROFILES, + { action: DBActions.GENERAL.CREATE, data: profile } + ) + } + + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_PROFILES, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsert(profile) { + return ipcRenderer.invoke( + IpcChannels.DB_PROFILES, + { action: DBActions.GENERAL.UPSERT, data: profile } + ) + } + + static delete(id) { + return ipcRenderer.invoke( + IpcChannels.DB_PROFILES, + { action: DBActions.GENERAL.DELETE, data: id } + ) + } + + static persist() { + return ipcRenderer.invoke( + IpcChannels.DB_PROFILES, + { action: DBActions.GENERAL.PERSIST } + ) + } +} + +class Playlists { + static create(playlists) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.CREATE, data: playlists } + ) + } + + static find() { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.FIND } + ) + } + + static upsertVideoByPlaylistName(playlistName, videoData) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { + action: DBActions.PLAYLISTS.UPSERT_VIDEO, + data: { playlistName, videoData } + } + ) + } + + static upsertVideoIdsByPlaylistId(_id, videoIds) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { + action: DBActions.PLAYLISTS.UPSERT_VIDEO_IDS, + data: { _id, videoIds } + } + ) + } + + static delete(_id) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.DELETE, data: _id } + ) + } + + static deleteVideoIdByPlaylistName(playlistName, videoId) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { + action: DBActions.PLAYLISTS.DELETE_VIDEO_ID, + data: { playlistName, videoId } + } + ) + } + + static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { + action: DBActions.PLAYLISTS.DELETE_VIDEO_IDS, + data: { playlistName, videoIds } + } + ) + } + + static deleteAllVideosByPlaylistName(playlistName) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { + action: DBActions.PLAYLISTS.DELETE_ALL_VIDEOS, + data: playlistName + } + ) + } + + static deleteMultiple(ids) { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.DELETE_MULTIPLE, data: ids } + ) + } + + static deleteAll() { + return ipcRenderer.invoke( + IpcChannels.DB_PLAYLISTS, + { action: DBActions.GENERAL.DELETE_ALL } + ) + } +} + +const handlers = { + settings: Settings, + history: History, + profiles: Profiles, + playlists: Playlists +} + +export default handlers diff --git a/src/datastores/handlers/index.js b/src/datastores/handlers/index.js new file mode 100644 index 000000000..0b1dc938b --- /dev/null +++ b/src/datastores/handlers/index.js @@ -0,0 +1,19 @@ +let handlers +const usingElectron = window?.process?.type === 'renderer' +if (usingElectron) { + handlers = require('./electron').default +} else { + handlers = require('./web').default +} + +const DBSettingHandlers = handlers.settings +const DBHistoryHandlers = handlers.history +const DBProfileHandlers = handlers.profiles +const DBPlaylistHandlers = handlers.playlists + +export { + DBSettingHandlers, + DBHistoryHandlers, + DBProfileHandlers, + DBPlaylistHandlers +} diff --git a/src/datastores/handlers/web.js b/src/datastores/handlers/web.js new file mode 100644 index 000000000..bf7ee41fe --- /dev/null +++ b/src/datastores/handlers/web.js @@ -0,0 +1,120 @@ +import baseHandlers from './base' + +// TODO: Syncing +// Syncing on the web would involve a different implementation +// to the electron one (obviously) +// One idea would be to use a watcher-like mechanism on +// localStorage or IndexedDB to inform other tabs on the changes +// that have occurred in other tabs +// +// NOTE: NeDB uses `localForage` on the browser +// https://www.npmjs.com/package/localforage + +class Settings { + static find() { + return baseHandlers.settings.find() + } + + static upsert(_id, value) { + return baseHandlers.settings.upsert(_id, value) + } +} + +class History { + static find() { + return baseHandlers.history.find() + } + + static upsert(record) { + return baseHandlers.history.upsert(record) + } + + static updateWatchProgress(videoId, watchProgress) { + return baseHandlers.history.updateWatchProgress(videoId, watchProgress) + } + + static delete(videoId) { + return baseHandlers.history.delete(videoId) + } + + static deleteAll() { + return baseHandlers.history.deleteAll() + } + + static persist() { + baseHandlers.history.persist() + } +} + +class Profiles { + static create(profile) { + return baseHandlers.profiles.create(profile) + } + + static find() { + return baseHandlers.profiles.find() + } + + static upsert(profile) { + return baseHandlers.profiles.upsert(profile) + } + + static delete(id) { + return baseHandlers.profiles.delete(id) + } + + static persist() { + baseHandlers.profiles.persist() + } +} + +class Playlists { + static create(playlists) { + return baseHandlers.playlists.create(playlists) + } + + static find() { + return baseHandlers.playlists.find() + } + + static upsertVideoByPlaylistName(playlistName, videoData) { + return baseHandlers.playlists.upsertVideoByPlaylistName(playlistName, videoData) + } + + static upsertVideoIdsByPlaylistId(_id, videoIds) { + return baseHandlers.playlists.upsertVideoIdsByPlaylistId(_id, videoIds) + } + + static delete(_id) { + return baseHandlers.playlists.delete(_id) + } + + static deleteVideoIdByPlaylistName(playlistName, videoId) { + return baseHandlers.playlists.deleteVideoIdByPlaylistName(playlistName, videoId) + } + + static deleteVideoIdsByPlaylistName(playlistName, videoIds) { + return baseHandlers.playlists.deleteVideoIdsByPlaylistName(playlistName, videoIds) + } + + static deleteAllVideosByPlaylistName(playlistName) { + return baseHandlers.playlists.deleteAllVideosByPlaylistName(playlistName) + } + + static deleteMultiple(ids) { + return baseHandlers.playlists.deleteMultiple(ids) + } + + static deleteAll() { + return baseHandlers.playlists.deleteAll() + } +} + +const handlers = { + settings: Settings, + history: History, + profiles: Profiles, + playlists: Playlists +} + +export default handlers diff --git a/src/datastores/index.js b/src/datastores/index.js new file mode 100644 index 000000000..713a0ea30 --- /dev/null +++ b/src/datastores/index.js @@ -0,0 +1,21 @@ +import Datastore from 'nedb-promises' + +let dbPath = null + +const isElectronMain = !!process?.versions?.electron +if (isElectronMain) { + const { app } = require('electron') + const { join } = require('path') + const userDataPath = app.getPath('userData') // This is based on the user's OS + dbPath = (dbName) => join(userDataPath, `${dbName}.db`) +} else { + dbPath = (dbName) => `${dbName}.db` +} + +const db = {} +db.settings = Datastore.create({ filename: dbPath('settings'), autoload: true }) +db.profiles = Datastore.create({ filename: dbPath('profiles'), autoload: true }) +db.playlists = Datastore.create({ filename: dbPath('playlists'), autoload: true }) +db.history = Datastore.create({ filename: dbPath('history'), autoload: true }) + +export default db diff --git a/src/main/index.js b/src/main/index.js index 66c3ade0f..c594cfbd6 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,10 +2,12 @@ import { app, BrowserWindow, dialog, Menu, ipcMain, powerSaveBlocker, screen, session, shell } from 'electron' -import Datastore from 'nedb-promises' import path from 'path' import cp from 'child_process' +import { IpcChannels, DBActions, SyncEvents } from '../constants' +import baseHandlers from '../datastores/handlers/base' + if (process.argv.includes('--version')) { console.log(`v${app.getVersion()}`) app.exit() @@ -29,13 +31,6 @@ function runApp() { ] }) - const localDataStorage = app.getPath('userData') // Grabs the userdata directory based on the user's OS - - const settingsDb = Datastore.create({ - filename: localDataStorage + '/settings.db', - autoload: true - }) - // disable electron warning process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true' const isDev = process.env.NODE_ENV === 'development' @@ -93,15 +88,7 @@ function runApp() { app.on('ready', async (_, __) => { let docArray try { - docArray = await settingsDb.find({ - $or: [ - { _id: 'disableSmoothScrolling' }, - { _id: 'useProxy' }, - { _id: 'proxyProtocol' }, - { _id: 'proxyHostname' }, - { _id: 'proxyPort' } - ] - }) + docArray = await baseHandlers.settings._findAppReadyRelatedSettings() } catch (err) { console.error(err) app.exit() @@ -241,7 +228,7 @@ function runApp() { height: 800 }) - const boundsDoc = await settingsDb.findOne({ _id: 'bounds' }) + const boundsDoc = await baseHandlers.settings._findBounds() if (typeof boundsDoc?.value === 'object') { const { maximized, ...bounds } = boundsDoc.value const allDisplaysSummaryWidth = screen @@ -296,11 +283,7 @@ function runApp() { maximized: newWindow.isMaximized() } - await settingsDb.update( - { _id: 'bounds' }, - { _id: 'bounds', value }, - { upsert: true } - ) + await baseHandlers.settings._updateBounds(value) }) newWindow.once('closed', () => { @@ -354,70 +337,292 @@ function runApp() { app.quit() }) - ipcMain.on('enableProxy', (_, url) => { + ipcMain.on(IpcChannels.ENABLE_PROXY, (_, url) => { console.log(url) session.defaultSession.setProxy({ proxyRules: url }) }) - ipcMain.on('disableProxy', () => { + ipcMain.on(IpcChannels.DISABLE_PROXY, () => { session.defaultSession.setProxy({}) }) - ipcMain.on('openExternalLink', (_, url) => { + ipcMain.on(IpcChannels.OPEN_EXTERNAL_LINK, (_, url) => { if (typeof url === 'string') shell.openExternal(url) }) - ipcMain.handle('getSystemLocale', () => { + ipcMain.handle(IpcChannels.GET_SYSTEM_LOCALE, () => { return app.getLocale() }) - ipcMain.handle('getUserDataPath', () => { + ipcMain.handle(IpcChannels.GET_USER_DATA_PATH, () => { return app.getPath('userData') }) - ipcMain.on('getUserDataPathSync', (event) => { + ipcMain.on(IpcChannels.GET_USER_DATA_PATH_SYNC, (event) => { event.returnValue = app.getPath('userData') }) - ipcMain.handle('showOpenDialog', async (_, options) => { + ipcMain.handle(IpcChannels.SHOW_OPEN_DIALOG, async (_, options) => { return await dialog.showOpenDialog(options) }) - ipcMain.handle('showSaveDialog', async (_, options) => { + ipcMain.handle(IpcChannels.SHOW_SAVE_DIALOG, async (_, options) => { return await dialog.showSaveDialog(options) }) - ipcMain.on('stopPowerSaveBlocker', (_, id) => { + ipcMain.on(IpcChannels.STOP_POWER_SAVE_BLOCKER, (_, id) => { powerSaveBlocker.stop(id) }) - ipcMain.handle('startPowerSaveBlocker', (_, type) => { - return powerSaveBlocker.start(type) + ipcMain.handle(IpcChannels.START_POWER_SAVE_BLOCKER, (_) => { + return powerSaveBlocker.start('prevent-display-sleep') }) - ipcMain.on('createNewWindow', () => { + ipcMain.on(IpcChannels.CREATE_NEW_WINDOW, () => { createWindow(false) }) - ipcMain.on('syncWindows', (event, payload) => { - const otherWindows = BrowserWindow.getAllWindows().filter( - (window) => { - return window.webContents.id !== event.sender.id - } - ) - - for (const window of otherWindows) { - window.webContents.send('syncWindows', payload) - } - }) - - ipcMain.on('openInExternalPlayer', (_, payload) => { + ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, (_, payload) => { const child = cp.spawn(payload.executable, payload.args, { detached: true, stdio: 'ignore' }) child.unref() }) + // ************************************************* // + // DB related IPC calls + // *********** // + + // Settings + ipcMain.handle(IpcChannels.DB_SETTINGS, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.FIND: + return await baseHandlers.settings.find() + + case DBActions.GENERAL.UPSERT: + await baseHandlers.settings.upsert(data._id, data.value) + syncOtherWindows( + IpcChannels.SYNC_SETTINGS, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid settings db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + + // *********** // + // History + ipcMain.handle(IpcChannels.DB_HISTORY, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.FIND: + return await baseHandlers.history.find() + + case DBActions.GENERAL.UPSERT: + await baseHandlers.history.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + + case DBActions.HISTORY.UPDATE_WATCH_PROGRESS: + await baseHandlers.history.updateWatchProgress(data.videoId, data.watchProgress) + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.HISTORY.UPDATE_WATCH_PROGRESS, data } + ) + return null + + case DBActions.GENERAL.DELETE: + await baseHandlers.history.delete(data) + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) + return null + + case DBActions.GENERAL.DELETE_ALL: + await baseHandlers.history.deleteAll() + syncOtherWindows( + IpcChannels.SYNC_HISTORY, + event, + { event: SyncEvents.GENERAL.DELETE_ALL } + ) + return null + + case DBActions.GENERAL.PERSIST: + baseHandlers.history.persist() + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid history db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + + // *********** // + // Profiles + ipcMain.handle(IpcChannels.DB_PROFILES, async (event, { action, data }) => { + try { + switch (action) { + case DBActions.GENERAL.CREATE: { + const newProfile = await baseHandlers.profiles.create(data) + syncOtherWindows( + IpcChannels.SYNC_PROFILES, + event, + { event: SyncEvents.GENERAL.CREATE, data: newProfile } + ) + return newProfile + } + + case DBActions.GENERAL.FIND: + return await baseHandlers.profiles.find() + + case DBActions.GENERAL.UPSERT: + await baseHandlers.profiles.upsert(data) + syncOtherWindows( + IpcChannels.SYNC_PROFILES, + event, + { event: SyncEvents.GENERAL.UPSERT, data } + ) + return null + + case DBActions.GENERAL.DELETE: + await baseHandlers.profiles.delete(data) + syncOtherWindows( + IpcChannels.SYNC_PROFILES, + event, + { event: SyncEvents.GENERAL.DELETE, data } + ) + return null + + case DBActions.GENERAL.PERSIST: + baseHandlers.profiles.persist() + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid profile db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + + // *********** // + // Playlists + // ! NOTE: A lot of these actions are currently not used for anything + // As such, only the currently used actions have synchronization implemented + // The remaining should have it implemented only when playlists + // get fully implemented into the app + ipcMain.handle(IpcChannels.DB_PLAYLISTS, async (event, { action, data }) => { + try { + 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 }) + return null + + case DBActions.GENERAL.FIND: + return await baseHandlers.playlists.find() + + case DBActions.PLAYLISTS.UPSERT_VIDEO: + await baseHandlers.playlists.upsertVideoByPlaylistName(data.playlistName, data.videoData) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.PLAYLISTS.UPSERT_VIDEO, data } + ) + 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 }) + 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 }) + return null + + case DBActions.PLAYLISTS.DELETE_VIDEO_ID: + await baseHandlers.playlists.deleteVideoIdByPlaylistName(data.playlistName, data.videoId) + syncOtherWindows( + IpcChannels.SYNC_PLAYLISTS, + event, + { event: SyncEvents.PLAYLISTS.DELETE_VIDEO, data } + ) + return null + + case DBActions.PLAYLISTS.DELETE_VIDEO_IDS: + await baseHandlers.playlists.deleteVideoIdsByPlaylistName(data.playlistName, 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) + // TODO: Syncing (implement only when it starts being used) + // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + return null + + case DBActions.GENERAL.DELETE_MULTIPLE: + await baseHandlers.playlists.deleteMultiple(data) + // TODO: Syncing (implement only when it starts being used) + // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + return null + + case DBActions.GENERAL.DELETE_ALL: + await baseHandlers.playlists.deleteAll() + // TODO: Syncing (implement only when it starts being used) + // syncOtherWindows(IpcChannels.SYNC_PLAYLISTS, event, { event: '_', data }) + return null + + default: + // eslint-disable-next-line no-throw-literal + throw 'invalid playlist db action' + } + } catch (err) { + if (typeof err === 'string') throw err + else throw err.toString() + } + }) + + // *********** // + + function syncOtherWindows(channel, event, payload) { + const otherWindows = BrowserWindow.getAllWindows().filter((window) => { + return window.webContents.id !== event.sender.id + }) + + for (const window of otherWindows) { + window.webContents.send(channel, payload) + } + } + + // ************************************************* // + app.once('window-all-closed', () => { // Clear cache and storage if it's the last window session.defaultSession.clearCache() diff --git a/src/renderer/App.js b/src/renderer/App.js index 74975d914..ec2ebd137 100644 --- a/src/renderer/App.js +++ b/src/renderer/App.js @@ -92,9 +92,6 @@ export default Vue.extend({ return null } }, - activeProfile: function () { - return this.$store.getters.getActiveProfile - }, defaultProfile: function () { return this.$store.getters.getDefaultProfile }, @@ -142,7 +139,7 @@ export default Vue.extend({ if (this.usingElectron) { console.log('User is using Electron') ipcRenderer = require('electron').ipcRenderer - this.setupListenerToSyncWindows() + this.setupListenersToSyncWindows() this.activateKeyboardShortcuts() this.openAllLinksExternally() this.enableOpenUrl() @@ -468,7 +465,7 @@ export default Vue.extend({ 'getExternalPlayerCmdArgumentsData', 'fetchInvidiousInstances', 'setRandomCurrentInvidiousInstance', - 'setupListenerToSyncWindows' + 'setupListenersToSyncWindows' ]) } }) diff --git a/src/renderer/components/data-settings/data-settings.js b/src/renderer/components/data-settings/data-settings.js index 5001c5815..7963873aa 100644 --- a/src/renderer/components/data-settings/data-settings.js +++ b/src/renderer/components/data-settings/data-settings.js @@ -5,6 +5,7 @@ import FtButton from '../ft-button/ft-button.vue' import FtToggleSwitch from '../ft-toggle-switch/ft-toggle-switch.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtPrompt from '../ft-prompt/ft-prompt.vue' +import { MAIN_PROFILE_ID } from '../../../constants' import fs from 'fs' import { opmlToJSON } from 'opml-to-json' @@ -165,7 +166,7 @@ export default Vue.extend({ message: message }) } else { - if (profileObject.name === 'All Channels' || profileObject._id === 'allChannels') { + if (profileObject.name === 'All Channels' || profileObject._id === MAIN_PROFILE_ID) { primaryProfile.subscriptions = primaryProfile.subscriptions.concat(profileObject.subscriptions) primaryProfile.subscriptions = primaryProfile.subscriptions.filter((sub, index) => { const profileIndex = primaryProfile.subscriptions.findIndex((x) => { diff --git a/src/renderer/components/ft-profile-edit/ft-profile-edit.js b/src/renderer/components/ft-profile-edit/ft-profile-edit.js index 3918120b6..bc740fae6 100644 --- a/src/renderer/components/ft-profile-edit/ft-profile-edit.js +++ b/src/renderer/components/ft-profile-edit/ft-profile-edit.js @@ -5,6 +5,7 @@ import FtPrompt from '../../components/ft-prompt/ft-prompt.vue' import FtFlexBox from '../../components/ft-flex-box/ft-flex-box.vue' import FtInput from '../../components/ft-input/ft-input.vue' import FtButton from '../../components/ft-button/ft-button.vue' +import { MAIN_PROFILE_ID } from '../../../constants' export default Vue.extend({ name: 'FtProfileEdit', @@ -40,6 +41,9 @@ export default Vue.extend({ } }, computed: { + isMainProfile: function () { + return this.profileId === MAIN_PROFILE_ID + }, colorValues: function () { return this.$store.getters.getColorValues }, @@ -70,7 +74,7 @@ export default Vue.extend({ this.profileTextColor = await this.calculateColorLuminance(val) } }, - mounted: async function () { + created: function () { this.profileId = this.$route.params.id this.profileName = this.profile.name this.profileBgColor = this.profile.bgColor @@ -109,9 +113,8 @@ export default Vue.extend({ console.log(profile) - this.updateProfile(profile) - if (this.isNew) { + this.createProfile(profile) this.showToast({ message: this.$t('Profile.Profile has been created') }) @@ -119,6 +122,7 @@ export default Vue.extend({ path: '/settings/profile/' }) } else { + this.updateProfile(profile) this.showToast({ message: this.$t('Profile.Profile has been updated') }) @@ -134,20 +138,22 @@ export default Vue.extend({ }, deleteProfile: function () { + if (this.activeProfile._id === this.profileId) { + this.updateActiveProfile(MAIN_PROFILE_ID) + } + this.removeProfile(this.profileId) + const message = this.$t('Profile.Removed $ from your profiles').replace('$', this.profileName) - this.showToast({ - message: message - }) + this.showToast({ message }) + if (this.defaultProfile === this.profileId) { - this.updateDefaultProfile('allChannels') + this.updateDefaultProfile(MAIN_PROFILE_ID) this.showToast({ message: this.$t('Profile.Your default profile has been changed to your primary profile') }) } - if (this.profileList[this.activeProfile]._id === this.profileId) { - this.updateActiveProfile(0) - } + this.$router.push({ path: '/settings/profile/' }) @@ -155,6 +161,7 @@ export default Vue.extend({ ...mapActions([ 'showToast', + 'createProfile', 'updateProfile', 'removeProfile', 'updateDefaultProfile', diff --git a/src/renderer/components/ft-profile-edit/ft-profile-edit.vue b/src/renderer/components/ft-profile-edit/ft-profile-edit.vue index e6c072708..700d54c3d 100644 --- a/src/renderer/components/ft-profile-edit/ft-profile-edit.vue +++ b/src/renderer/components/ft-profile-edit/ft-profile-edit.vue @@ -77,7 +77,7 @@ @click="setDefaultProfile" /> { - return x._id === profile._id - }) + if (this.activeProfile._id !== profile._id) { + const targetProfile = this.profileList.find((x) => { + return x._id === profile._id + }) - if (index === -1) { - return + if (targetProfile) { + this.updateActiveProfile(targetProfile._id) + + const message = this.$t('Profile.$ is now the active profile').replace('$', profile.name) + this.showToast({ message }) + } } - this.updateActiveProfile(index) - const message = this.$t('Profile.$ is now the active profile').replace('$', profile.name) - this.showToast({ - message: message - }) - $('#profileList').focusout() + + $('#profileList').trigger('focusout') }, ...mapActions([ diff --git a/src/renderer/components/ft-profile-selector/ft-profile-selector.vue b/src/renderer/components/ft-profile-selector/ft-profile-selector.vue index e21608776..4e8ccde1f 100644 --- a/src/renderer/components/ft-profile-selector/ft-profile-selector.vue +++ b/src/renderer/components/ft-profile-selector/ft-profile-selector.vue @@ -2,13 +2,13 @@
- {{ profileInitials[activeProfile] }} + {{ activeProfileInitial }}
{ - if (profile._id === 'allChannels') { + if (profile._id === MAIN_PROFILE_ID) { const newProfile = { - _id: 'allChannels', + _id: MAIN_PROFILE_ID, name: profile.name, bgColor: profile.bgColor, textColor: profile.textColor, diff --git a/src/renderer/components/proxy-settings/proxy-settings.js b/src/renderer/components/proxy-settings/proxy-settings.js index 7635b486f..1f536e17a 100644 --- a/src/renderer/components/proxy-settings/proxy-settings.js +++ b/src/renderer/components/proxy-settings/proxy-settings.js @@ -14,6 +14,8 @@ import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import { ipcRenderer } from 'electron' import debounce from 'lodash.debounce' +import { IpcChannels } from '../../../constants' + export default Vue.extend({ name: 'ProxySettings', components: { @@ -111,11 +113,11 @@ export default Vue.extend({ }, enableProxy: function () { - ipcRenderer.send('enableProxy', this.proxyUrl) + ipcRenderer.send(IpcChannels.ENABLE_PROXY, this.proxyUrl) }, disableProxy: function () { - ipcRenderer.send('disableProxy') + ipcRenderer.send(IpcChannels.DISABLE_PROXY) }, testProxy: function () { diff --git a/src/renderer/components/side-nav/side-nav.js b/src/renderer/components/side-nav/side-nav.js index 329f474fd..60b812953 100644 --- a/src/renderer/components/side-nav/side-nav.js +++ b/src/renderer/components/side-nav/side-nav.js @@ -25,7 +25,7 @@ export default Vue.extend({ return this.$store.getters.getActiveProfile }, activeSubscriptions: function () { - const profile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile])) + const profile = JSON.parse(JSON.stringify(this.activeProfile)) return profile.subscriptions.sort((a, b) => { const nameA = a.name.toLowerCase() const nameB = b.name.toLowerCase() diff --git a/src/renderer/components/top-nav/top-nav.js b/src/renderer/components/top-nav/top-nav.js index e806fd5be..d3c2f4dd3 100644 --- a/src/renderer/components/top-nav/top-nav.js +++ b/src/renderer/components/top-nav/top-nav.js @@ -7,6 +7,8 @@ import $ from 'jquery' import debounce from 'lodash.debounce' import ytSuggest from 'youtube-suggest' +import { IpcChannels } from '../../../constants' + export default Vue.extend({ name: 'TopNav', components: { @@ -303,7 +305,7 @@ export default Vue.extend({ createNewWindow: function () { if (this.usingElectron) { const { ipcRenderer } = require('electron') - ipcRenderer.send('createNewWindow') + ipcRenderer.send(IpcChannels.CREATE_NEW_WINDOW) } else { // Web placeholder } diff --git a/src/renderer/components/watch-video-info/watch-video-info.js b/src/renderer/components/watch-video-info/watch-video-info.js index 088bd006b..ea0bdcb76 100644 --- a/src/renderer/components/watch-video-info/watch-video-info.js +++ b/src/renderer/components/watch-video-info/watch-video-info.js @@ -6,6 +6,7 @@ import FtListDropdown from '../ft-list-dropdown/ft-list-dropdown.vue' import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtIconButton from '../ft-icon-button/ft-icon-button.vue' import FtShareButton from '../ft-share-button/ft-share-button.vue' +import { MAIN_PROFILE_ID } from '../../../constants' export default Vue.extend({ name: 'WatchVideoInfo', @@ -228,7 +229,7 @@ export default Vue.extend({ }, isSubscribed: function () { - const subIndex = this.profileList[this.activeProfile].subscriptions.findIndex((channel) => { + const subIndex = this.activeProfile.subscriptions.findIndex((channel) => { return channel.id === this.channelId }) @@ -322,7 +323,7 @@ export default Vue.extend({ return } - const currentProfile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile])) + const currentProfile = JSON.parse(JSON.stringify(this.activeProfile)) const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) if (this.isSubscribed) { @@ -335,13 +336,13 @@ export default Vue.extend({ message: this.$t('Channel.Channel has been removed from your subscriptions') }) - if (this.activeProfile === 0) { + if (this.activeProfile._id === MAIN_PROFILE_ID) { // Check if a subscription exists in a different profile. // Remove from there as well. let duplicateSubscriptions = 0 this.profileList.forEach((profile) => { - if (profile._id === 'allChannels') { + if (profile._id === MAIN_PROFILE_ID) { return } const parsedProfile = JSON.parse(JSON.stringify(profile)) @@ -380,7 +381,7 @@ export default Vue.extend({ message: this.$t('Channel.Added channel to your subscriptions') }) - if (this.activeProfile !== 0) { + if (this.activeProfile._id !== MAIN_PROFILE_ID) { const index = primaryProfile.subscriptions.findIndex((channel) => { return channel.id === this.channelId }) diff --git a/src/renderer/store/datastores.js b/src/renderer/store/datastores.js deleted file mode 100644 index 5a236538d..000000000 --- a/src/renderer/store/datastores.js +++ /dev/null @@ -1,47 +0,0 @@ -import Datastore from 'nedb-promises' - -// Initialize all datastores and export their references -// Current dbs: -// `settings.db` -// `profiles.db` -// `playlists.db` -// `history.db` - -let buildFileName = null - -// Check if using Electron -const usingElectron = window?.process?.type === 'renderer' -if (usingElectron) { - const { ipcRenderer } = require('electron') - const userDataPath = ipcRenderer.sendSync('getUserDataPathSync') - buildFileName = (dbName) => userDataPath + '/' + dbName + '.db' -} else { - buildFileName = (dbName) => dbName + '.db' -} - -const settingsDb = Datastore.create({ - filename: buildFileName('settings'), - autoload: true -}) - -const playlistsDb = Datastore.create({ - filename: buildFileName('playlists'), - autoload: true -}) - -const profilesDb = Datastore.create({ - filename: buildFileName('profiles'), - autoload: true -}) - -const historyDb = Datastore.create({ - filename: buildFileName('history'), - autoload: true -}) - -export { - settingsDb, - profilesDb, - playlistsDb, - historyDb -} diff --git a/src/renderer/store/modules/history.js b/src/renderer/store/modules/history.js index ee5ab1f8e..cb36636c3 100644 --- a/src/renderer/store/modules/history.js +++ b/src/renderer/store/modules/history.js @@ -1,4 +1,4 @@ -import { historyDb } from '../datastores' +import { DBHistoryHandlers } from '../../../datastores/handlers/index' const state = { historyCache: [] @@ -12,80 +12,52 @@ const getters = { const actions = { async grabHistory({ commit }) { - const results = await historyDb.find({}).sort({ timeWatched: -1 }) - commit('setHistoryCache', results) + try { + const results = await DBHistoryHandlers.find() + commit('setHistoryCache', results) + } catch (errMessage) { + console.error(errMessage) + } }, - async updateHistory({ commit, dispatch, state }, entry) { - await historyDb.update( - { videoId: entry.videoId }, - entry, - { upsert: true } - ) - - const entryIndex = state.historyCache.findIndex((currentEntry) => { - return entry.videoId === currentEntry.videoId - }) - - entryIndex === -1 - ? commit('insertNewEntryToHistoryCache', entry) - : commit('hoistEntryToTopOfHistoryCache', { - currentIndex: entryIndex, - updatedEntry: entry - }) - - dispatch('propagateHistory') + async updateHistory({ commit }, record) { + try { + await DBHistoryHandlers.upsert(record) + commit('upsertToHistoryCache', record) + } catch (errMessage) { + console.error(errMessage) + } }, - async removeFromHistory({ commit, dispatch }, videoId) { - await historyDb.remove({ videoId: videoId }) - - const updatedCache = state.historyCache.filter((entry) => { - return entry.videoId !== videoId - }) - - commit('setHistoryCache', updatedCache) - - dispatch('propagateHistory') + async removeFromHistory({ commit }, videoId) { + try { + await DBHistoryHandlers.delete(videoId) + commit('removeFromHistoryCacheById', videoId) + } catch (errMessage) { + console.error(errMessage) + } }, - async removeAllHistory({ commit, dispatch }) { - await historyDb.remove({}, { multi: true }) - commit('setHistoryCache', []) - dispatch('propagateHistory') + async removeAllHistory({ commit }) { + try { + await DBHistoryHandlers.deleteAll() + commit('setHistoryCache', []) + } catch (errMessage) { + console.error(errMessage) + } }, - async updateWatchProgress({ commit, dispatch }, entry) { - await historyDb.update( - { videoId: entry.videoId }, - { $set: { watchProgress: entry.watchProgress } }, - { upsert: true } - ) - - const entryIndex = state.historyCache.findIndex((currentEntry) => { - return entry.videoId === currentEntry.videoId - }) - - commit('updateEntryWatchProgressInHistoryCache', { - index: entryIndex, - value: entry.watchProgress - }) - - dispatch('propagateHistory') - }, - - propagateHistory({ getters: { getUsingElectron: usingElectron } }) { - if (usingElectron) { - const { ipcRenderer } = require('electron') - ipcRenderer.send('syncWindows', { - type: 'history', - data: state.historyCache - }) + async updateWatchProgress({ commit }, { videoId, watchProgress }) { + try { + await DBHistoryHandlers.updateWatchProgress(videoId, watchProgress) + commit('updateRecordWatchProgressInHistoryCache', { videoId, watchProgress }) + } catch (errMessage) { + console.error(errMessage) } }, compactHistory(_) { - historyDb.persistence.compactDatafile() + DBHistoryHandlers.persist() } } @@ -94,17 +66,42 @@ const mutations = { state.historyCache = historyCache }, - insertNewEntryToHistoryCache(state, entry) { - state.historyCache.unshift(entry) - }, - hoistEntryToTopOfHistoryCache(state, { currentIndex, updatedEntry }) { state.historyCache.splice(currentIndex, 1) state.historyCache.unshift(updatedEntry) }, - updateEntryWatchProgressInHistoryCache(state, { index, value }) { - state.historyCache[index].watchProgress = value + upsertToHistoryCache(state, record) { + const i = state.historyCache.findIndex((currentRecord) => { + return record.videoId === currentRecord.videoId + }) + + if (i !== -1) { + // Already in cache + // Must be hoisted to top, remove it and then unshift it + state.historyCache.splice(i, 1) + } + + state.historyCache.unshift(record) + }, + + updateRecordWatchProgressInHistoryCache(state, { videoId, watchProgress }) { + const i = state.historyCache.findIndex((currentRecord) => { + return currentRecord.videoId === videoId + }) + + const targetRecord = Object.assign({}, state.historyCache[i]) + targetRecord.watchProgress = watchProgress + state.historyCache.splice(i, 1, targetRecord) + }, + + removeFromHistoryCacheById(state, videoId) { + for (let i = 0; i < state.historyCache.length; i++) { + if (state.historyCache[i].videoId === videoId) { + state.historyCache.splice(i, 1) + break + } + } } } diff --git a/src/renderer/store/modules/playlists.js b/src/renderer/store/modules/playlists.js index 2e587906d..8bd9de516 100644 --- a/src/renderer/store/modules/playlists.js +++ b/src/renderer/store/modules/playlists.js @@ -1,4 +1,4 @@ -import { playlistsDb } from '../datastores' +import { DBPlaylistHandlers } from '../../../datastores/handlers/index' const state = { playlists: [ @@ -25,89 +25,111 @@ const getters = { const actions = { async addPlaylist({ commit }, payload) { - await playlistsDb.insert(payload) - commit('addPlaylist', payload) + try { + await DBPlaylistHandlers.create(payload) + commit('addPlaylist', payload) + } catch (errMessage) { + console.error(errMessage) + } }, async addPlaylists({ commit }, payload) { - await playlistsDb.insert(payload) - commit('addPlaylists', payload) + try { + await DBPlaylistHandlers.create(payload) + commit('addPlaylists', payload) + } catch (errMessage) { + console.error(errMessage) + } }, async addVideo({ commit }, payload) { - await playlistsDb.update( - { playlistName: payload.playlistName }, - { $push: { videos: payload.videoData } }, - { upsert: true } - ) - commit('addVideo', payload) + try { + const { playlistName, videoData } = payload + await DBPlaylistHandlers.upsertVideoByPlaylistName(playlistName, videoData) + commit('addVideo', payload) + } catch (errMessage) { + console.error(errMessage) + } }, async addVideos({ commit }, payload) { - await playlistsDb.update( - { _id: payload.playlistId }, - { $push: { videos: { $each: payload.videosIds } } }, - { upsert: true } - ) - commit('addVideos', payload) + try { + const { playlistId, videoIds } = payload + await DBPlaylistHandlers.upsertVideoIdsByPlaylistId(playlistId, videoIds) + commit('addVideos', payload) + } catch (errMessage) { + console.error(errMessage) + } }, - async grabAllPlaylists({ commit, dispatch }) { - const payload = await playlistsDb.find({}) - if (payload.length === 0) { - commit('setAllPlaylists', state.playlists) - dispatch('addPlaylists', payload) - } else { - commit('setAllPlaylists', payload) + async grabAllPlaylists({ commit, dispatch, state }) { + try { + const payload = await DBPlaylistHandlers.find() + if (payload.length === 0) { + commit('setAllPlaylists', state.playlists) + dispatch('addPlaylists', payload) + } else { + commit('setAllPlaylists', payload) + } + } catch (errMessage) { + console.error(errMessage) } }, async removeAllPlaylists({ commit }) { - await playlistsDb.remove({ protected: { $ne: true } }) - commit('removeAllPlaylists') + try { + await DBPlaylistHandlers.deleteAll() + commit('removeAllPlaylists') + } catch (errMessage) { + console.error(errMessage) + } }, async removeAllVideos({ commit }, playlistName) { - await playlistsDb.update( - { playlistName: playlistName }, - { $set: { videos: [] } }, - { upsert: true } - ) - commit('removeAllVideos', playlistName) + try { + await DBPlaylistHandlers.deleteAllVideosByPlaylistName(playlistName) + commit('removeAllVideos', playlistName) + } catch (errMessage) { + console.error(errMessage) + } }, async removePlaylist({ commit }, playlistId) { - await playlistsDb.remove({ - _id: playlistId, - protected: { $ne: true } - }) - commit('removePlaylist', playlistId) + try { + await DBPlaylistHandlers.delete(playlistId) + commit('removePlaylist', playlistId) + } catch (errMessage) { + console.error(errMessage) + } }, async removePlaylists({ commit }, playlistIds) { - await playlistsDb.remove({ - _id: { $in: playlistIds }, - protected: { $ne: true } - }) - commit('removePlaylists', playlistIds) + try { + await DBPlaylistHandlers.deleteMultiple(playlistIds) + commit('removePlaylists', playlistIds) + } catch (errMessage) { + console.error(errMessage) + } }, async removeVideo({ commit }, payload) { - await playlistsDb.update( - { playlistName: payload.playlistName }, - { $pull: { videos: { videoId: payload.videoId } } }, - { upsert: true } - ) - commit('removeVideo', payload) + try { + const { playlistName, videoId } = payload + await DBPlaylistHandlers.deleteVideoIdByPlaylistName(playlistName, videoId) + commit('removeVideo', payload) + } catch (errMessage) { + console.error(errMessage) + } }, async removeVideos({ commit }, payload) { - await playlistsDb.update( - { _id: payload.playlistName }, - { $pull: { videos: { $in: payload.videoId } } }, - { upsert: true } - ) - commit('removeVideos', payload) + try { + const { playlistName, videoIds } = payload + await DBPlaylistHandlers.deleteVideoIdsByPlaylistName(playlistName, videoIds) + commit('removeVideos', payload) + } catch (errMessage) { + console.error(errMessage) + } } } diff --git a/src/renderer/store/modules/profiles.js b/src/renderer/store/modules/profiles.js index 8b467d8d7..ed69e53c7 100644 --- a/src/renderer/store/modules/profiles.js +++ b/src/renderer/store/modules/profiles.js @@ -1,14 +1,15 @@ -import { profilesDb } from '../datastores' +import { MAIN_PROFILE_ID } from '../../../constants' +import { DBProfileHandlers } from '../../../datastores/handlers/index' const state = { profileList: [{ - _id: 'allChannels', + _id: MAIN_PROFILE_ID, name: 'All Channels', bgColor: '#000000', textColor: '#FFFFFF', subscriptions: [] }], - activeProfile: 0 + activeProfile: MAIN_PROFILE_ID } const getters = { @@ -16,94 +17,111 @@ const getters = { return state.profileList }, - getActiveProfile: () => { - return state.activeProfile + getActiveProfile: (state) => { + const activeProfileId = state.activeProfile + return state.profileList.find((profile) => { + return profile._id === activeProfileId + }) + }, + + profileById: (state) => (id) => { + const profile = state.profileList.find(p => p._id === id) + return profile } } +function profileSort(a, b) { + if (a._id === MAIN_PROFILE_ID) return -1 + if (b._id === MAIN_PROFILE_ID) return 1 + if (a.name < b.name) return -1 + if (a.name > b.name) return 1 + return 0 +} + const actions = { async grabAllProfiles({ rootState, dispatch, commit }, defaultName = null) { - let profiles = await profilesDb.find({}) - if (profiles.length === 0) { - dispatch('createDefaultProfile', defaultName) + let profiles + try { + profiles = await DBProfileHandlers.find() + } catch (errMessage) { + console.error(errMessage) return } + + if (!Array.isArray(profiles)) return + + if (profiles.length === 0) { + // Create a default profile and persist it + const randomColor = await dispatch('getRandomColor') + const textColor = await dispatch('calculateColorLuminance', randomColor) + const defaultProfile = { + _id: MAIN_PROFILE_ID, + name: defaultName, + bgColor: randomColor, + textColor: textColor, + subscriptions: [] + } + + try { + await DBProfileHandlers.create(defaultProfile) + commit('setProfileList', [defaultProfile]) + } catch (errMessage) { + console.error(errMessage) + } + + return + } + // We want the primary profile to always be first // So sort with that then sort alphabetically by profile name - profiles = profiles.sort((a, b) => { - if (a._id === 'allChannels') { - return -1 - } - - if (b._id === 'allChannels') { - return 1 - } - - return b.name - a.name - }) + profiles = profiles.sort(profileSort) if (state.profileList.length < profiles.length) { - const profileIndex = profiles.findIndex((profile) => { + const profile = profiles.find((profile) => { return profile._id === rootState.settings.defaultProfile }) - if (profileIndex !== -1) { - commit('setActiveProfile', profileIndex) + if (profile) { + commit('setActiveProfile', profile._id) } } commit('setProfileList', profiles) }, - async grabProfileInfo(_, profileId) { - console.log(profileId) - return await profilesDb.findOne({ _id: profileId }) - }, - - async createDefaultProfile({ dispatch }, defaultName) { - const randomColor = await dispatch('getRandomColor') - const textColor = await dispatch('calculateColorLuminance', randomColor) - const defaultProfile = { - _id: 'allChannels', - name: defaultName, - bgColor: randomColor, - textColor: textColor, - subscriptions: [] + async createProfile({ commit }, profile) { + try { + const newProfile = await DBProfileHandlers.create(profile) + commit('addProfileToList', newProfile) + } catch (errMessage) { + console.error(errMessage) } - - await profilesDb.update( - { _id: 'allChannels' }, - defaultProfile, - { upsert: true } - ) - dispatch('grabAllProfiles') }, - async updateProfile({ dispatch }, profile) { - await profilesDb.update( - { _id: profile._id }, - profile, - { upsert: true } - ) - dispatch('grabAllProfiles') + async updateProfile({ commit }, profile) { + try { + await DBProfileHandlers.upsert(profile) + commit('upsertProfileToList', profile) + } catch (errMessage) { + console.error(errMessage) + } }, - async insertProfile({ dispatch }, profile) { - await profilesDb.insert(profile) - dispatch('grabAllProfiles') - }, - - async removeProfile({ dispatch }, profileId) { - await profilesDb.remove({ _id: profileId }) - dispatch('grabAllProfiles') + async removeProfile({ commit }, profileId) { + try { + await DBProfileHandlers.delete(profileId) + commit('removeProfileFromList', profileId) + } catch (errMessage) { + console.error(errMessage) + } }, compactProfiles(_) { - profilesDb.persistence.compactDatafile() + DBProfileHandlers.persist() }, - updateActiveProfile({ commit }, index) { - commit('setActiveProfile', index) + updateActiveProfile({ commit }, id) { + commit('setActiveProfile', id) } } @@ -111,8 +129,36 @@ const mutations = { setProfileList(state, profileList) { state.profileList = profileList }, + setActiveProfile(state, activeProfile) { state.activeProfile = activeProfile + }, + + addProfileToList(state, profile) { + state.profileList.push(profile) + state.profileList.sort(profileSort) + }, + + upsertProfileToList(state, updatedProfile) { + const i = state.profileList.findIndex((p) => { + return p._id === updatedProfile._id + }) + + if (i === -1) { + state.profileList.push(updatedProfile) + } else { + state.profileList.splice(i, 1, updatedProfile) + } + + state.profileList.sort(profileSort) + }, + + removeProfileFromList(state, profileId) { + const i = state.profileList.findIndex((profile) => { + return profile._id === profileId + }) + + state.profileList.splice(i, 1) } } diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index ab26b1ad6..6b9140a33 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -1,5 +1,6 @@ -import { settingsDb } from '../datastores' import i18n from '../../i18n/index' +import { MAIN_PROFILE_ID, IpcChannels, SyncEvents } from '../../../constants' +import { DBSettingHandlers } from '../../../datastores/handlers/index' /* * Due to the complexity of the settings module in FreeTube, a more @@ -170,7 +171,7 @@ const state = { defaultCaptionSettings: '{}', defaultInterval: 5, defaultPlayback: 1, - defaultProfile: 'allChannels', + defaultProfile: MAIN_PROFILE_ID, defaultQuality: '720', defaultSkipInterval: 5, defaultTheatreMode: false, @@ -312,29 +313,29 @@ Object.assign(customGetters, { const customActions = { grabUserSettings: async ({ commit, dispatch, getters }) => { - const userSettings = await settingsDb.find({ - _id: { $ne: 'bounds' } - }) + try { + const userSettings = await DBSettingHandlers.find() + for (const setting of userSettings) { + const { _id, value } = setting + if (getters.settingHasSideEffects(_id)) { + dispatch(defaultSideEffectsTriggerId(_id), value) + } - for (const setting of userSettings) { - const { _id, value } = setting - if (getters.settingHasSideEffects(_id)) { - dispatch(defaultSideEffectsTriggerId(_id), value) + commit(defaultMutationId(_id), value) } - - commit(defaultMutationId(_id), value) + } catch (errMessage) { + console.error(errMessage) } }, // Should be a root action, but we'll tolerate - setupListenerToSyncWindows: ({ commit, dispatch, getters }) => { + setupListenersToSyncWindows: ({ commit, dispatch, getters }) => { // Already known to be Electron, no need to check const { ipcRenderer } = require('electron') - ipcRenderer.on('syncWindows', (_, payload) => { - const { type, data } = payload - switch (type) { - case 'setting': - // `data` is a single setting => { _id, value } + + ipcRenderer.on(IpcChannels.SYNC_SETTINGS, (_, { event, data }) => { + switch (event) { + case SyncEvents.GENERAL.UPSERT: if (getters.settingHasSideEffects(data._id)) { dispatch(defaultSideEffectsTriggerId(data._id), data.value) } @@ -342,18 +343,65 @@ const customActions = { commit(defaultMutationId(data._id), data.value) break - case 'history': - // `data` is the whole history => Array of history entries - commit('setHistoryCache', data) + default: + console.error('settings: invalid sync event received') + } + }) + + ipcRenderer.on(IpcChannels.SYNC_HISTORY, (_, { event, data }) => { + switch (event) { + case SyncEvents.GENERAL.UPSERT: + commit('upsertToHistoryCache', data) break - case 'playlist': - // TODO: Not implemented + case SyncEvents.HISTORY.UPDATE_WATCH_PROGRESS: + commit('updateRecordWatchProgressInHistoryCache', data) break - case 'profile': - // TODO: Not implemented + case SyncEvents.GENERAL.DELETE: + commit('removeFromHistoryCacheById', data) break + + case SyncEvents.GENERAL.DELETE_ALL: + commit('setHistoryCache', []) + break + + default: + console.error('history: invalid sync event received') + } + }) + + ipcRenderer.on(IpcChannels.SYNC_PROFILES, (_, { event, data }) => { + switch (event) { + case SyncEvents.GENERAL.CREATE: + commit('addProfileToList', data) + break + + case SyncEvents.GENERAL.UPSERT: + commit('upsertProfileToList', data) + break + + case SyncEvents.GENERAL.DELETE: + commit('removeProfileFromList', data) + break + + default: + console.error('profiles: invalid sync event received') + } + }) + + ipcRenderer.on(IpcChannels.SYNC_PLAYLISTS, (_, { event, data }) => { + switch (event) { + case SyncEvents.PLAYLISTS.UPSERT_VIDEO: + commit('addVideo', data) + break + + case SyncEvents.PLAYLISTS.DELETE_VIDEO: + commit('removeVideo', data) + break + + default: + console.error('playlists: invalid sync event received') } }) } @@ -398,30 +446,16 @@ for (const settingId of Object.keys(state)) { } actions[updaterId] = async ({ commit, dispatch, getters }, value) => { - await settingsDb.update( - { _id: settingId }, - { _id: settingId, value: value }, - { upsert: true } - ) + try { + await DBSettingHandlers.upsert(settingId, value) - const { - getUsingElectron: usingElectron, - settingHasSideEffects - } = getters + if (getters.settingHasSideEffects(settingId)) { + dispatch(triggerId, value) + } - if (settingHasSideEffects(settingId)) { - dispatch(triggerId, value) - } - commit(mutationId, value) - - if (usingElectron) { - const { ipcRenderer } = require('electron') - - // Propagate settings to all other existing windows - ipcRenderer.send('syncWindows', { - type: 'setting', - data: { _id: settingId, value: value } - }) + commit(mutationId, value) + } catch (errMessage) { + console.error(errMessage) } } } diff --git a/src/renderer/store/modules/subscriptions.js b/src/renderer/store/modules/subscriptions.js index 6bbf944cc..486a2e10a 100644 --- a/src/renderer/store/modules/subscriptions.js +++ b/src/renderer/store/modules/subscriptions.js @@ -1,7 +1,9 @@ +import { MAIN_PROFILE_ID } from '../../../constants' + const state = { allSubscriptionsList: [], profileSubscriptions: { - activeProfile: 0, + activeProfile: MAIN_PROFILE_ID, videoList: [] } } diff --git a/src/renderer/store/modules/utils.js b/src/renderer/store/modules/utils.js index 2be086a47..df01e80ec 100644 --- a/src/renderer/store/modules/utils.js +++ b/src/renderer/store/modules/utils.js @@ -1,6 +1,9 @@ import IsEqual from 'lodash.isequal' import FtToastEvents from '../../components/ft-toast/ft-toast-events' import fs from 'fs' + +import { IpcChannels } from '../../../constants' + const state = { isSideNavOpen: false, sessionSearchHistory: [], @@ -166,7 +169,7 @@ const actions = { const usingElectron = rootState.settings.usingElectron if (usingElectron) { const ipcRenderer = require('electron').ipcRenderer - ipcRenderer.send('openExternalLink', url) + ipcRenderer.send(IpcChannels.OPEN_EXTERNAL_LINK, url) } else { // Web placeholder } @@ -179,25 +182,25 @@ const actions = { } } - return (await invokeIRC(context, 'getSystemLocale', webCbk)) || 'en-US' + return (await invokeIRC(context, IpcChannels.GET_SYSTEM_LOCALE, webCbk)) || 'en-US' }, async showOpenDialog (context, options) { // TODO: implement showOpenDialog web compatible callback const webCbk = () => null - return await invokeIRC(context, 'showOpenDialog', webCbk, options) + return await invokeIRC(context, IpcChannels.SHOW_OPEN_DIALOG, webCbk, options) }, async showSaveDialog (context, options) { // TODO: implement showSaveDialog web compatible callback const webCbk = () => null - return await invokeIRC(context, 'showSaveDialog', webCbk, options) + return await invokeIRC(context, IpcChannels.SHOW_SAVE_DIALOG, webCbk, options) }, async getUserDataPath (context) { // TODO: implement getUserDataPath web compatible callback const webCbk = () => null - return await invokeIRC(context, 'getUserDataPath', webCbk) + return await invokeIRC(context, IpcChannels.GET_USER_DATA_PATH, webCbk) }, updateShowProgressBar ({ commit }, value) { @@ -853,10 +856,7 @@ const actions = { console.log(executable, args) const { ipcRenderer } = require('electron') - ipcRenderer.send('openInExternalPlayer', { - executable, - args - }) + ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, { executable, args }) } } diff --git a/src/renderer/views/Channel/Channel.js b/src/renderer/views/Channel/Channel.js index 9ae2f556f..58131c7f7 100644 --- a/src/renderer/views/Channel/Channel.js +++ b/src/renderer/views/Channel/Channel.js @@ -11,6 +11,7 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import ytch from 'yt-channel-info' import autolinker from 'autolinker' +import { MAIN_PROFILE_ID } from '../../../constants' export default Vue.extend({ name: 'Search', @@ -90,7 +91,7 @@ export default Vue.extend({ }, isSubscribed: function () { - const subIndex = this.profileList[this.activeProfile].subscriptions.findIndex((channel) => { + const subIndex = this.activeProfile.subscriptions.findIndex((channel) => { return channel.id === this.id }) @@ -508,7 +509,7 @@ export default Vue.extend({ }, handleSubscription: function () { - const currentProfile = JSON.parse(JSON.stringify(this.profileList[this.activeProfile])) + const currentProfile = JSON.parse(JSON.stringify(this.activeProfile)) const primaryProfile = JSON.parse(JSON.stringify(this.profileList[0])) if (this.isSubscribed) { @@ -521,13 +522,13 @@ export default Vue.extend({ message: this.$t('Channel.Channel has been removed from your subscriptions') }) - if (this.activeProfile === 0) { + if (this.activeProfile === MAIN_PROFILE_ID) { // Check if a subscription exists in a different profile. // Remove from there as well. let duplicateSubscriptions = 0 this.profileList.forEach((profile) => { - if (profile._id === 'allChannels') { + if (profile._id === MAIN_PROFILE_ID) { return } const parsedProfile = JSON.parse(JSON.stringify(profile)) @@ -566,7 +567,7 @@ export default Vue.extend({ message: this.$t('Channel.Added channel to your subscriptions') }) - if (this.activeProfile !== 0) { + if (this.activeProfile !== MAIN_PROFILE_ID) { const index = primaryProfile.subscriptions.findIndex((channel) => { return channel.id === this.id }) diff --git a/src/renderer/views/ProfileEdit/ProfileEdit.js b/src/renderer/views/ProfileEdit/ProfileEdit.js index 7b6bccb0e..1ffb531b2 100644 --- a/src/renderer/views/ProfileEdit/ProfileEdit.js +++ b/src/renderer/views/ProfileEdit/ProfileEdit.js @@ -1,9 +1,10 @@ import Vue from 'vue' -import { mapActions } from 'vuex' +import { mapActions, mapGetters } from 'vuex' import FtLoader from '../../components/ft-loader/ft-loader.vue' import FtProfileEdit from '../../components/ft-profile-edit/ft-profile-edit.vue' import FtProfileChannelList from '../../components/ft-profile-channel-list/ft-profile-channel-list.vue' import FtProfileFilterChannelsList from '../../components/ft-profile-filter-channels-list/ft-profile-filter-channels-list.vue' +import { MAIN_PROFILE_ID } from '../../../constants' export default Vue.extend({ name: 'ProfileEdit', @@ -15,40 +16,43 @@ export default Vue.extend({ }, data: function () { return { - isLoading: false, + isLoading: true, isNew: false, profileId: '', profile: {} } }, computed: { + ...mapGetters([ + 'profileById' + ]), + profileList: function () { return this.$store.getters.getProfileList }, + isMainProfile: function () { - return this.profileId === 'allChannels' + return this.profileId === MAIN_PROFILE_ID } }, watch: { profileList: { handler: function () { - this.grabProfileInfo(this.profileId).then((profile) => { - if (profile === null) { - this.showToast({ - message: this.$t('Profile.Profile could not be found') - }) - this.$router.push({ - path: '/settings/profile/' - }) - } - this.profile = profile - }) + const profile = this.profileById(this.profileId) + if (!profile) { + this.showToast({ + message: this.$t('Profile.Profile could not be found') + }) + this.$router.push({ + path: '/settings/profile/' + }) + } + this.profile = profile }, deep: true } }, mounted: async function () { - this.isLoading = true const profileType = this.$route.name this.deletePromptLabel = `${this.$t('Profile.Are you sure you want to delete this profile?')} ${this.$t('Profile["All subscriptions will also be deleted."]')}` @@ -63,29 +67,27 @@ export default Vue.extend({ textColor: textColor, subscriptions: [] } - this.isLoading = false } else { this.isNew = false this.profileId = this.$route.params.id - this.grabProfileInfo(this.profileId).then((profile) => { - if (profile === null) { - this.showToast({ - message: this.$t('Profile.Profile could not be found') - }) - this.$router.push({ - path: '/settings/profile/' - }) - } - this.profile = profile - this.isLoading = false - }) + const profile = this.profileById(this.profileId) + if (!profile) { + this.showToast({ + message: this.$t('Profile.Profile could not be found') + }) + this.$router.push({ + path: '/settings/profile/' + }) + } + this.profile = profile } + + this.isLoading = false }, methods: { ...mapActions([ 'showToast', - 'grabProfileInfo', 'getRandomColor', 'calculateColorLuminance' ]) diff --git a/src/renderer/views/Subscriptions/Subscriptions.js b/src/renderer/views/Subscriptions/Subscriptions.js index 619107cc3..660fd0e8d 100644 --- a/src/renderer/views/Subscriptions/Subscriptions.js +++ b/src/renderer/views/Subscriptions/Subscriptions.js @@ -9,6 +9,7 @@ import FtElementList from '../../components/ft-element-list/ft-element-list.vue' import ytch from 'yt-channel-info' import Parser from 'rss-parser' +import { MAIN_PROFILE_ID } from '../../../constants' export default Vue.extend({ name: 'Subscriptions', @@ -81,11 +82,11 @@ export default Vue.extend({ }, activeSubscriptionList: function () { - return this.profileList[this.activeProfile].subscriptions + return this.activeProfile.subscriptions } }, watch: { - activeProfile: async function (val) { + activeProfile: async function (_) { this.getProfileSubscriptions() } }, @@ -97,7 +98,7 @@ export default Vue.extend({ } if (this.profileSubscriptions.videoList.length !== 0) { - if (this.profileSubscriptions.activeProfile === this.activeProfile) { + if (this.profileSubscriptions.activeProfile === this.activeProfile._id) { const subscriptionList = JSON.parse(JSON.stringify(this.profileSubscriptions)) if (this.hideWatchedSubs) { this.videoList = await Promise.all(subscriptionList.videoList.filter((video) => { @@ -172,7 +173,7 @@ export default Vue.extend({ })) const profileSubscriptions = { - activeProfile: this.activeProfile, + activeProfile: this.activeProfile._id, videoList: videoList } @@ -191,7 +192,7 @@ export default Vue.extend({ this.isLoading = false this.updateShowProgressBar(false) - if (this.activeProfile === 0) { + if (this.activeProfile === MAIN_PROFILE_ID) { this.updateAllSubscriptionsList(profileSubscriptions.videoList) } }