2022-12-29 02:19:48 +01:00
import fs from 'fs/promises'
2022-06-21 03:43:45 +02:00
import path from 'path'
2022-02-02 04:11:38 +01:00
import i18n from '../../i18n/index'
2023-08-03 14:48:23 +02:00
import { set as vueSet } from 'vue'
Store Revamp / Full database synchronization across windows (#1833)
* History: Refactor history module
* Profiles: Refactor profiles module
* IPC: Move channel ids to their own file and make them constants
* IPC: Replace single sync channel for one channel per sync type
* Everywhere: Replace default profile id magic strings with constant ref
* Profiles: Refactor `activeProfile` property from store
This commit makes it so that `activeProfile`'s getter returns
the entire profile, while the related update function only needs
the profile id (instead of the previously used array index)
to change the currently active profile.
This change was made due to inconsistency regarding the active profile
when creating new profiles.
If a new profile coincidentally landed in the current active profile's
array index after sorting, the app would mistakenly change to it
without any action from the user apart from the profile's creation.
Turning the profile id into the selector instead solves this issue.
* Revert "Store: Implement history synchronization between windows"
This reverts commit 99b61e617873412eb393d8f4dfccd8f8c172021f.
This is necessary for an upcoming improved implementation of the
history synchronization.
* History: Remove unused mutation
* Everywhere: Create abstract database handlers
The project now utilizes abstract handlers to fetch, modify
or otherwise manipulate data from the database.
This facilitates 3 aspects of the app, in addition of
making them future proof:
- Switching database libraries is now trivial
Since most of the app utilizes the abstract handlers, it's incredibly
easily to change to a different DB library.
Hypothetically, all that would need to be done is to simply replace the
the file containing the base handlers, while the rest of the app
would go unchanged.
- Syncing logic between Electron and web is now properly separated
There are now two distinct DB handling APIs: the Electron one and
the web one.
The app doesn't need to manually choose the API, because it's detected
which platform is being utilized on import.
- All Electron windows now share the same database instance
This provides a single source of truth, improving consistency
regarding data manipulation and windows synchronization.
As a sidenote, syncing implementation has been left as is
(web unimplemented; Electron only syncs settings, remaining
datastore syncing will be implemented in the upcoming commits).
* Electron/History: Implement history synchronization
* Profiles: Implement suplementary profile creation logic
* ft-profile-edit: Small fix on profile name missing display
* Electron/Profiles: Implement profile synchronization
* Electron/Playlists: Implement playlist synchronization
2021-12-15 19:42:24 +01:00
import { IpcChannels } from '../../../constants'
2022-12-29 02:19:48 +01:00
import { pathExists } from '../../helpers/filesystem'
2022-11-05 16:13:25 +01:00
import {
2023-03-01 01:39:33 +01:00
CHANNEL _HANDLE _REGEX ,
2022-11-05 16:13:25 +01:00
createWebURL ,
2023-01-01 03:52:49 +01:00
getVideoParamsFromUrl ,
2022-11-05 16:13:25 +01:00
openExternalLink ,
2022-12-02 08:29:01 +01:00
replaceFilenameForbiddenChars ,
2022-11-05 16:13:25 +01:00
searchFiltersMatch ,
2023-01-01 03:52:49 +01:00
showExternalPlayerUnsupportedActionToast ,
2022-11-05 16:13:25 +01:00
showSaveDialog ,
showToast
} from '../../helpers/utils'
Store Revamp / Full database synchronization across windows (#1833)
* History: Refactor history module
* Profiles: Refactor profiles module
* IPC: Move channel ids to their own file and make them constants
* IPC: Replace single sync channel for one channel per sync type
* Everywhere: Replace default profile id magic strings with constant ref
* Profiles: Refactor `activeProfile` property from store
This commit makes it so that `activeProfile`'s getter returns
the entire profile, while the related update function only needs
the profile id (instead of the previously used array index)
to change the currently active profile.
This change was made due to inconsistency regarding the active profile
when creating new profiles.
If a new profile coincidentally landed in the current active profile's
array index after sorting, the app would mistakenly change to it
without any action from the user apart from the profile's creation.
Turning the profile id into the selector instead solves this issue.
* Revert "Store: Implement history synchronization between windows"
This reverts commit 99b61e617873412eb393d8f4dfccd8f8c172021f.
This is necessary for an upcoming improved implementation of the
history synchronization.
* History: Remove unused mutation
* Everywhere: Create abstract database handlers
The project now utilizes abstract handlers to fetch, modify
or otherwise manipulate data from the database.
This facilitates 3 aspects of the app, in addition of
making them future proof:
- Switching database libraries is now trivial
Since most of the app utilizes the abstract handlers, it's incredibly
easily to change to a different DB library.
Hypothetically, all that would need to be done is to simply replace the
the file containing the base handlers, while the rest of the app
would go unchanged.
- Syncing logic between Electron and web is now properly separated
There are now two distinct DB handling APIs: the Electron one and
the web one.
The app doesn't need to manually choose the API, because it's detected
which platform is being utilized on import.
- All Electron windows now share the same database instance
This provides a single source of truth, improving consistency
regarding data manipulation and windows synchronization.
As a sidenote, syncing implementation has been left as is
(web unimplemented; Electron only syncs settings, remaining
datastore syncing will be implemented in the upcoming commits).
* Electron/History: Implement history synchronization
* Profiles: Implement suplementary profile creation logic
* ft-profile-edit: Small fix on profile name missing display
* Electron/Profiles: Implement profile synchronization
* Electron/Playlists: Implement playlist synchronization
2021-12-15 19:42:24 +01:00
2020-02-16 19:30:00 +01:00
const state = {
isSideNavOpen : false ,
2023-11-17 03:08:10 +01:00
outlinesHidden : true ,
2020-02-16 19:30:00 +01:00
sessionSearchHistory : [ ] ,
2020-08-18 17:51:56 +02:00
popularCache : null ,
2021-08-21 23:08:38 +02:00
trendingCache : {
default : null ,
music : null ,
gaming : null ,
movies : null
} ,
2022-12-19 11:43:28 +01:00
cachedPlaylist : null ,
2023-07-03 18:27:49 +02:00
deArrowCache : { } ,
2020-08-31 23:35:22 +02:00
showProgressBar : false ,
2024-01-03 19:44:57 +01:00
showAddToPlaylistPrompt : false ,
showCreatePlaylistPrompt : false ,
2020-08-31 23:35:22 +02:00
progressBarPercentage : 0 ,
2024-01-03 19:44:57 +01:00
toBeAddedToPlaylistVideoList : [ ] ,
newPlaylistDefaultProperties : { } ,
newPlaylistVideoObject : [ ] ,
2020-10-22 20:56:49 +02:00
regionNames : [ ] ,
regionValues : [ ] ,
2020-09-20 20:22:39 +02:00
recentBlogPosts : [ ] ,
2020-02-16 19:30:00 +01:00
searchSettings : {
sortBy : 'relevance' ,
time : '' ,
type : 'all' ,
duration : ''
2020-05-23 23:29:42 +02:00
} ,
2021-06-13 17:31:43 +02:00
externalPlayerNames : [ ] ,
externalPlayerValues : [ ] ,
2024-04-17 23:54:46 +02:00
externalPlayerCmdArguments : { } ,
lastVideoRefreshTimestampByProfile : { } ,
lastShortRefreshTimestampByProfile : { } ,
lastLiveRefreshTimestampByProfile : { } ,
lastCommunityRefreshTimestampByProfile : { } ,
lastPopularRefreshTimestamp : '' ,
lastTrendingRefreshTimestamp : '' ,
2020-02-16 19:30:00 +01:00
}
const getters = {
2020-03-01 04:37:02 +01:00
getIsSideNavOpen ( ) {
return state . isSideNavOpen
} ,
2023-11-17 03:08:10 +01:00
getOutlinesHidden ( ) {
return state . outlinesHidden
} ,
2020-03-01 04:37:02 +01:00
getCurrentVolume ( ) {
return state . currentVolume
} ,
2020-02-16 19:30:00 +01:00
getSessionSearchHistory ( ) {
return state . sessionSearchHistory
} ,
2023-08-03 14:48:23 +02:00
getDeArrowCache : ( state ) => {
return state . deArrowCache
2023-07-03 18:27:49 +02:00
} ,
2020-08-13 16:26:20 +02:00
getPopularCache ( ) {
return state . popularCache
} ,
2020-08-22 22:37:09 +02:00
getTrendingCache ( ) {
return state . trendingCache
} ,
2022-12-19 11:43:28 +01:00
getCachedPlaylist ( ) {
return state . cachedPlaylist
} ,
2020-02-16 19:30:00 +01:00
getSearchSettings ( ) {
return state . searchSettings
2020-08-24 04:56:33 +02:00
} ,
2024-01-03 19:44:57 +01:00
getShowAddToPlaylistPrompt ( ) {
return state . showAddToPlaylistPrompt
} ,
getShowCreatePlaylistPrompt ( ) {
return state . showCreatePlaylistPrompt
} ,
getToBeAddedToPlaylistVideoList ( ) {
return state . toBeAddedToPlaylistVideoList
} ,
getNewPlaylistDefaultProperties ( ) {
return state . newPlaylistDefaultProperties
} ,
getNewPlaylistVideoObject ( ) {
return state . newPlaylistVideoObject
} ,
2020-08-31 23:35:22 +02:00
getShowProgressBar ( ) {
return state . showProgressBar
} ,
getProgressBarPercentage ( ) {
return state . progressBarPercentage
2020-09-20 20:22:39 +02:00
} ,
2020-10-22 20:56:49 +02:00
getRegionNames ( ) {
return state . regionNames
} ,
getRegionValues ( ) {
return state . regionValues
} ,
2020-09-20 20:22:39 +02:00
getRecentBlogPosts ( ) {
return state . recentBlogPosts
2021-06-13 17:31:43 +02:00
} ,
getExternalPlayerNames ( ) {
return state . externalPlayerNames
} ,
getExternalPlayerValues ( ) {
return state . externalPlayerValues
} ,
getExternalPlayerCmdArguments ( ) {
return state . externalPlayerCmdArguments
2024-04-17 23:54:46 +02:00
} ,
getLastTrendingRefreshTimestamp ( ) {
return state . lastTrendingRefreshTimestamp
} ,
getLastPopularRefreshTimestamp ( ) {
return state . lastPopularRefreshTimestamp
} ,
getLastCommunityRefreshTimestampByProfile : ( state ) => ( profileId ) => {
return state . lastCommunityRefreshTimestampByProfile [ profileId ]
} ,
getLastShortRefreshTimestampByProfile : ( state ) => ( profileId ) => {
return state . lastShortRefreshTimestampByProfile [ profileId ]
} ,
getLastLiveRefreshTimestampByProfile : ( state ) => ( profileId ) => {
return state . lastLiveRefreshTimestampByProfile [ profileId ]
} ,
getLastVideoRefreshTimestampByProfile : ( state ) => ( profileId ) => {
return state . lastVideoRefreshTimestampByProfile [ profileId ]
2020-02-16 19:30:00 +01:00
}
}
2020-05-23 23:29:42 +02:00
const actions = {
2023-11-17 03:08:10 +01:00
showOutlines ( { commit } ) {
commit ( 'setOutlinesHidden' , false )
} ,
hideOutlines ( { commit } ) {
commit ( 'setOutlinesHidden' , true )
} ,
2022-12-02 08:29:01 +01:00
async downloadMedia ( { rootState } , { url , title , extension , fallingBackPath } ) {
2022-10-18 10:15:28 +02:00
if ( ! process . env . IS _ELECTRON ) {
openExternalLink ( url )
return
2022-09-21 09:00:21 +02:00
}
2022-12-02 08:29:01 +01:00
const fileName = ` ${ replaceFilenameForbiddenChars ( title ) } . ${ extension } `
2022-10-13 13:51:15 +02:00
const errorMessage = i18n . t ( 'Downloading failed' , { videoTitle : title } )
2023-10-11 04:44:57 +02:00
const askFolderPath = rootState . settings . downloadAskPath
2022-02-02 04:11:38 +01:00
let folderPath = rootState . settings . downloadFolderPath
2023-10-11 04:44:57 +02:00
if ( askFolderPath ) {
2022-02-02 04:11:38 +01:00
const options = {
defaultPath : fileName ,
filters : [
{
2022-05-30 15:24:34 +02:00
name : extension . toUpperCase ( ) ,
2022-02-02 04:11:38 +01:00
extensions : [ extension ]
}
]
}
2022-10-25 16:44:18 +02:00
const response = await showSaveDialog ( options )
2022-02-02 04:11:38 +01:00
if ( response . canceled || response . filePath === '' ) {
// User canceled the save dialog
return
}
folderPath = response . filePath
2022-06-21 03:43:45 +02:00
} else {
2022-12-29 02:19:48 +01:00
if ( ! ( await pathExists ( folderPath ) ) ) {
2022-06-21 03:43:45 +02:00
try {
2022-12-29 02:19:48 +01:00
await fs . mkdir ( folderPath , { recursive : true } )
2022-06-21 03:43:45 +02:00
} catch ( err ) {
console . error ( err )
2022-10-14 07:59:49 +02:00
showToast ( err )
2022-06-21 03:43:45 +02:00
return
}
}
folderPath = path . join ( folderPath , fileName )
2022-01-30 18:49:16 +01:00
}
2022-10-14 07:59:49 +02:00
showToast ( i18n . t ( 'Starting download' , { videoTitle : title } ) )
2022-01-30 18:49:16 +01:00
2022-02-02 04:11:38 +01:00
const response = await fetch ( url ) . catch ( ( error ) => {
2022-09-23 03:04:10 +02:00
console . error ( error )
2022-10-14 07:59:49 +02:00
showToast ( errorMessage )
2022-02-02 04:11:38 +01:00
} )
2022-01-30 18:49:16 +01:00
2022-02-02 04:11:38 +01:00
const reader = response . body . getReader ( )
2022-01-30 18:49:16 +01:00
const chunks = [ ]
2022-02-02 04:11:38 +01:00
const handleError = ( err ) => {
2022-09-23 03:04:10 +02:00
console . error ( err )
2022-10-14 07:59:49 +02:00
showToast ( errorMessage )
2022-02-02 04:11:38 +01:00
}
2022-01-30 18:49:16 +01:00
2022-02-02 04:11:38 +01:00
const processText = async ( { done , value } ) => {
2022-01-30 18:49:16 +01:00
if ( done ) {
2022-02-02 04:11:38 +01:00
return
2022-01-30 18:49:16 +01:00
}
chunks . push ( value )
2022-02-02 04:11:38 +01:00
// Can be used in the future to determine download percentage
2022-02-19 23:17:58 +01:00
// const contentLength = response.headers.get('Content-Length')
// const receivedLength = value.length
// const percentage = receivedLength / contentLength
2022-02-02 04:11:38 +01:00
await reader . read ( ) . then ( processText ) . catch ( handleError )
2022-01-30 18:49:16 +01:00
}
2022-02-02 04:11:38 +01:00
await reader . read ( ) . then ( processText ) . catch ( handleError )
2022-01-30 18:49:16 +01:00
const blobFile = new Blob ( chunks )
const buffer = await blobFile . arrayBuffer ( )
2022-12-29 02:19:48 +01:00
try {
await fs . writeFile ( folderPath , new DataView ( buffer ) )
showToast ( i18n . t ( 'Downloading has completed' , { videoTitle : title } ) )
} catch ( err ) {
console . error ( err )
showToast ( errorMessage )
}
2022-01-30 18:49:16 +01:00
} ,
2022-05-30 15:24:34 +02:00
parseScreenshotCustomFileName : function ( { rootState } , payload ) {
return new Promise ( ( resolve , reject ) => {
const { pattern = rootState . settings . screenshotFilenamePattern , date , playerTime , videoId } = payload
const keywords = [
[ '%Y' , date . getFullYear ( ) ] , // year 4 digits
[ '%M' , ( date . getMonth ( ) + 1 ) . toString ( ) . padStart ( 2 , '0' ) ] , // month 2 digits
[ '%D' , date . getDate ( ) . toString ( ) . padStart ( 2 , '0' ) ] , // day 2 digits
[ '%H' , date . getHours ( ) . toString ( ) . padStart ( 2 , '0' ) ] , // hour 2 digits
[ '%N' , date . getMinutes ( ) . toString ( ) . padStart ( 2 , '0' ) ] , // minute 2 digits
[ '%S' , date . getSeconds ( ) . toString ( ) . padStart ( 2 , '0' ) ] , // second 2 digits
[ '%T' , date . getMilliseconds ( ) . toString ( ) . padStart ( 3 , '0' ) ] , // millisecond 3 digits
[ '%s' , parseInt ( playerTime ) ] , // video position second n digits
[ '%t' , ( playerTime % 1 ) . toString ( ) . slice ( 2 , 5 ) || '000' ] , // video position millisecond 3 digits
[ '%i' , videoId ] // video id
]
let parsedString = pattern
for ( const [ key , value ] of keywords ) {
parsedString = parsedString . replaceAll ( key , value )
}
2022-12-02 08:29:01 +01:00
if ( parsedString !== replaceFilenameForbiddenChars ( parsedString ) ) {
2024-04-07 16:58:15 +02:00
reject ( new Error ( i18n . t ( 'Settings.Player Settings.Screenshot.Error.Forbidden Characters' ) ) )
2022-05-30 15:24:34 +02:00
}
let filename
2022-12-02 08:29:01 +01:00
if ( parsedString . indexOf ( path . sep ) !== - 1 ) {
const lastIndex = parsedString . lastIndexOf ( path . sep )
2022-05-30 15:24:34 +02:00
filename = parsedString . substring ( lastIndex + 1 )
} else {
filename = parsedString
}
if ( ! filename ) {
2024-04-07 16:58:15 +02:00
reject ( new Error ( i18n . t ( 'Settings.Player Settings.Screenshot.Error.Empty File Name' ) ) )
2022-05-30 15:24:34 +02:00
}
resolve ( parsedString )
} )
} ,
2024-01-03 19:44:57 +01:00
showAddToPlaylistPromptForManyVideos ( { commit } , { videos : videoObjectArray , newPlaylistDefaultProperties } ) {
let videoDataValid = true
if ( ! Array . isArray ( videoObjectArray ) ) {
videoDataValid = false
}
let missingKeys = [ ]
if ( videoDataValid ) {
const requiredVideoKeys = [
'videoId' ,
'title' ,
'author' ,
'authorId' ,
'lengthSeconds' ,
// `timeAdded` should be generated when videos are added
// Not when a prompt is displayed
// 'timeAdded',
// `playlistItemId` should be generated anyway
// 'playlistItemId',
// `type` should be added in action anyway
// 'type',
]
// Using `every` to loop and `return false` to break
videoObjectArray . every ( ( video ) => {
const videoPropertyKeys = Object . keys ( video )
const missingKeysHere = requiredVideoKeys . filter ( x => ! videoPropertyKeys . includes ( x ) )
if ( missingKeysHere . length > 0 ) {
videoDataValid = false
missingKeys = missingKeysHere
return false
}
// Return true to continue loop
return true
} )
}
if ( ! videoDataValid ) {
// Print error and abort
const errorMsgText = 'Incorrect videos data passed when opening playlist prompt'
console . error ( errorMsgText )
console . error ( {
videoObjectArray ,
missingKeys ,
} )
throw new Error ( errorMsgText )
}
commit ( 'setShowAddToPlaylistPrompt' , true )
commit ( 'setToBeAddedToPlaylistVideoList' , videoObjectArray )
if ( newPlaylistDefaultProperties != null ) {
commit ( 'setNewPlaylistDefaultProperties' , newPlaylistDefaultProperties )
}
} ,
hideAddToPlaylistPrompt ( { commit } ) {
commit ( 'setShowAddToPlaylistPrompt' , false )
// The default value properties are only valid until prompt is closed
commit ( 'resetNewPlaylistDefaultProperties' )
} ,
showCreatePlaylistPrompt ( { commit } , data ) {
commit ( 'setShowCreatePlaylistPrompt' , true )
commit ( 'setNewPlaylistVideoObject' , data )
} ,
hideCreatePlaylistPrompt ( { commit } ) {
commit ( 'setShowCreatePlaylistPrompt' , false )
} ,
2020-08-31 23:35:22 +02:00
updateShowProgressBar ( { commit } , value ) {
commit ( 'setShowProgressBar' , value )
} ,
2022-10-22 10:31:34 +02:00
async getRegionData ( { commit } , { locale } ) {
2024-02-04 21:45:37 +01:00
const localePathExists = process . env . GEOLOCATION _NAMES . includes ( locale )
2022-10-22 10:31:34 +02:00
// Exclude __dirname from path if not in electron
const fileLocation = ` ${ process . env . IS _ELECTRON ? process . env . NODE _ENV === 'development' ? '.' : _ _dirname : '' } /static/geolocations/ `
2024-02-04 21:45:37 +01:00
2023-07-01 16:08:09 +02:00
const pathName = ` ${ fileLocation } ${ localePathExists ? locale : 'en-US' } .json `
const countries = process . env . IS _ELECTRON ? JSON . parse ( await fs . readFile ( pathName ) ) : await ( await fetch ( createWebURL ( pathName ) ) ) . json ( )
2020-10-22 20:56:49 +02:00
const regionNames = countries . map ( ( entry ) => { return entry . name } )
const regionValues = countries . map ( ( entry ) => { return entry . code } )
commit ( 'setRegionNames' , regionNames )
commit ( 'setRegionValues' , regionValues )
} ,
2023-03-01 01:39:33 +01:00
async getYoutubeUrlInfo ( { rootState , state } , urlStr ) {
2021-04-28 19:21:16 +02:00
// Returns
// - urlType [String] `video`, `playlist`
//
// If `urlType` is "video"
// - videoId [String]
// - timestamp [String]
//
// If `urlType` is "playlist"
// - playlistId [String]
// - query [Object]
//
// If `urlType` is "search"
// - searchQuery [String]
// - query [Object]
//
// If `urlType` is "hashtag"
// Nothing else
//
// If `urlType` is "channel"
// - channelId [String]
//
// If `urlType` is "unknown"
// Nothing else
//
// If `urlType` is "invalid_url"
// Nothing else
2023-03-01 01:39:33 +01:00
if ( CHANNEL _HANDLE _REGEX . test ( urlStr ) ) {
urlStr = ` https://www.youtube.com/ ${ urlStr } `
}
2023-01-01 03:52:49 +01:00
const { videoId , timestamp , playlistId } = getVideoParamsFromUrl ( urlStr )
2021-04-28 19:21:16 +02:00
if ( videoId ) {
return {
urlType : 'video' ,
videoId ,
2021-05-31 13:23:35 +02:00
playlistId ,
2021-04-28 19:21:16 +02:00
timestamp
}
}
let url
2021-01-15 17:34:44 +01:00
try {
2021-04-28 19:21:16 +02:00
url = new URL ( urlStr )
} catch {
return {
urlType : 'invalid_url'
}
}
let urlType = 'unknown'
const channelPattern =
2023-07-20 00:16:45 +02:00
/^\/(?:(?:channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(?<tab>join|featured|videos|shorts|live|streams|podcasts|releases|playlists|about|community|channels))?\/?$/
2021-04-28 19:21:16 +02:00
2023-05-13 13:27:41 +02:00
const hashtagPattern = /^\/hashtag\/(?<tag>[^#&/?]+)$/
2021-04-28 19:21:16 +02:00
const typePatterns = new Map ( [
2022-12-22 15:22:12 +01:00
[ 'playlist' , /^(\/playlist\/?|\/embed(\/?videoseries)?)$/ ] ,
2023-08-06 14:21:45 +02:00
[ 'search' , /^\/results|search\/?$/ ] ,
2023-05-13 13:27:41 +02:00
[ 'hashtag' , hashtagPattern ] ,
2021-04-28 19:21:16 +02:00
[ 'channel' , channelPattern ]
] )
for ( const [ type , pattern ] of typePatterns ) {
const matchFound = pattern . test ( url . pathname )
if ( matchFound ) {
urlType = type
break
}
2021-01-15 17:34:44 +01:00
}
2021-04-28 19:21:16 +02:00
switch ( urlType ) {
case 'playlist' : {
if ( ! url . searchParams . has ( 'list' ) ) {
throw new Error ( 'Playlist: "list" field not found' )
}
const playlistId = url . searchParams . get ( 'list' )
url . searchParams . delete ( 'list' )
const query = { }
for ( const [ param , value ] of url . searchParams ) {
query [ param ] = value
}
return {
urlType : 'playlist' ,
playlistId ,
query
2021-01-15 17:34:44 +01:00
}
}
2021-04-28 19:21:16 +02:00
case 'search' : {
2023-08-06 14:21:45 +02:00
let searchQuery = null
if ( url . searchParams . has ( 'search_query' ) ) {
// https://www.youtube.com/results?search_query={QUERY}
searchQuery = url . searchParams . get ( 'search_query' )
url . searchParams . delete ( 'search_query' )
}
if ( url . searchParams . has ( 'q' ) ) {
// https://redirect.invidious.io/search?q={QUERY}
searchQuery = url . searchParams . get ( 'q' )
url . searchParams . delete ( 'q' )
}
if ( searchQuery == null ) {
2021-04-28 19:21:16 +02:00
throw new Error ( 'Search: "search_query" field not found' )
}
2021-05-11 17:28:26 +02:00
const searchSettings = state . searchSettings
2021-04-28 19:21:16 +02:00
const query = {
2021-05-11 17:28:26 +02:00
sortBy : searchSettings . sortBy ,
time : searchSettings . time ,
type : searchSettings . type ,
duration : searchSettings . duration
2021-04-28 19:21:16 +02:00
}
for ( const [ param , value ] of url . searchParams ) {
query [ param ] = value
}
return {
urlType : 'search' ,
searchQuery ,
query
}
}
case 'hashtag' : {
2023-05-13 13:27:41 +02:00
const match = url . pathname . match ( hashtagPattern )
const hashtag = match . groups . tag
2021-04-28 19:21:16 +02:00
return {
2023-05-13 13:27:41 +02:00
urlType : 'hashtag' ,
hashtag
2021-04-28 19:21:16 +02:00
}
}
2021-11-02 12:45:50 +01:00
/ *
Using RegExp named capture groups from ES2018
To avoid access to specific captured value broken
2021-04-28 19:21:16 +02:00
2021-11-02 12:45:50 +01:00
Channel URL ( ID - based )
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/about
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/channels
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/community
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/featured
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/join
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/playlists
https : //www.youtube.com/channel/UCfMJ2MchTSW2kWaT0kK94Yw/videos
Custom URL
https : //www.youtube.com/c/YouTubeCreators
https : //www.youtube.com/c/YouTubeCreators/about
etc .
Legacy Username URL
https : //www.youtube.com/user/ufoludek
https : //www.youtube.com/user/ufoludek/about
etc .
* /
2021-04-28 19:21:16 +02:00
case 'channel' : {
2022-08-08 11:26:04 +02:00
const match = url . pathname . match ( channelPattern )
const channelId = match . groups . channelId
2021-04-28 19:21:16 +02:00
if ( ! channelId ) {
throw new Error ( 'Channel: could not extract id' )
}
2021-11-02 12:45:50 +01:00
let subPath = null
2023-03-03 21:38:27 +01:00
switch ( match . groups . tab ) {
2023-05-23 02:59:24 +02:00
case 'shorts' :
subPath = 'shorts'
break
2023-03-15 07:37:52 +01:00
case 'live' :
case 'streams' :
subPath = 'live'
break
2021-11-02 12:45:50 +01:00
case 'playlists' :
subPath = 'playlists'
break
2023-07-20 00:16:45 +02:00
case 'podcasts' :
subPath = 'podcasts'
break
case 'releases' :
subPath = 'releases'
break
2021-11-02 12:45:50 +01:00
case 'channels' :
case 'about' :
subPath = 'about'
break
case 'community' :
Channel community page (#1568)
* Comunity page strings, Communtiy tab, Community initial API call
Added:
1) Community page strings - the first few strings are now available
2) Community tab - A clickable tab is now displayed on channel pages
3) Community initial API call - on loading the page, the initial access
* Comunity page strings, Communtiy tab, Community initial API call
Added:
1) Community page strings - the first few strings are now available
2) Community tab - A clickable tab is now displayed on channel pages
3) Community initial API call - on loading the page, the initial access
* Data returning added
* Comunity page strings, Communtiy tab, Community initial API call
Added:
1) Community page strings - the first few strings are now available
2) Community tab - A clickable tab is now displayed on channel pages
3) Community initial API call - on loading the page, the initial access
* Data returning added
* Images are now displayed in the community tab
* Comunity page strings, Communtiy tab, Community initial API call
Added:
1) Community page strings - the first few strings are now available
2) Community tab - A clickable tab is now displayed on channel pages
3) Community initial API call - on loading the page, the initial access
* Data returning added
* Images are now displayed in the community tab
* Added primitive video display
* Current changes
* Added preston's change with the ftcard and started on some layout basics
* Created Community Post Component and added fetch more button + functionality
* Fixed problem with videothumbnails not loading and adjusted their height to 100% in the ft-list sass file
* Added poll and ft-list-video to the community page
* Added author name placeholder (missing in module), the published date, the likes and dislikes as well as comment counts to posts. Additionally scaling of images was added
* Added basis for community page playlists
* Finalized a setup for playlists when wide enough
* Fix for missing key in custom list
* Added publish date translation
* Add empty alt tags
Co-authored-by: Jason <84899178+jasonhenriquez@users.noreply.github.com>
* fix accessibility issue
Co-authored-by: Jason <84899178+jasonhenriquez@users.noreply.github.com>
* change: ununique ids to classes
* add missing alt tag
* Redirect channel/id/community to the channel's community tab
* update yt-channel-info
* update to 3.0.1
* Update yarn.lock
* add basic multiImage support
* use tiny-slider for multiImage community posts
* update getChannelCommunityPostsMore
* Update yarn.lock
* fix yarn lock
* swap community and about tab
* Update yarn.lock
* Fix missing comma
* Removed trailing spaces
* Clearing all community post data when changing to another channel
* Restructuring of how the post cards are added, Empty page text,
ft-element-list props customization
1) Now the community page uses the same setup of ft-element-list as the
other pages on the channel.
2) If no posts are available, now it displays a message saying so
3) The ft-element-list component's display style can now be forced into
a certain display mode (list/grid) with the new prop. It will overwrite
the corresponding default value for list display
* Fixed display text path
* Fix lint"
* Adjusted css to fit to new layout
* Final touches community page to tidy up the console
* fix icons, fix linter
* fix hiding showmore button for community page
* fix showToast calls
* change all this.showToast to showToaast
* reinstall tinyslider
* use helpers
* small fixes
* fix: getting continuation of community posts
* remove unused code
* improve slider style import
* fix hiding 'ShowMore' button
* fix weird typo in css
* add invidous community tab support
* remove console testing code
* Apply suggestions from code review
Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
* implement suggestions, improve thumbnail replacement
* use flip horizontal
* readd invidious fallback code, remove author name workaround
* replace another google domain when using invidious
* suppport invidious multiImage posts
* Use youtube.js for community posts
* add invidious polls, remove support for fetching more
* reorder icons alpabetically
* re-allow loading more when using localapi
* fix styling of multiImage, hide NA text
* fix loading playlist
* fix spacing of items
* fix issue with direct url to community tab
* make review recommendations
Co-Authored-By: absidue <48293849+absidue@users.noreply.github.com>
* fix displaying selected tab, get best quality image
---------
Co-authored-by: Preston <freetubeapp@protonmail.com>
Co-authored-by: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com>
Co-authored-by: Jason <84899178+jasonhenriquez@users.noreply.github.com>
Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2023-03-04 09:56:04 +01:00
subPath = 'community'
break
2021-11-02 12:45:50 +01:00
default :
subPath = 'videos'
break
}
2021-04-28 19:21:16 +02:00
return {
urlType : 'channel' ,
2021-11-02 12:45:50 +01:00
channelId ,
2023-03-01 01:39:33 +01:00
subPath ,
2024-01-30 01:39:10 +01:00
// The original URL could be from Invidious.
// We need to make sure it starts with youtube.com, so that YouTube's resolve endpoint can recognise it
url : ` https://www.youtube.com ${ url . pathname } `
2021-04-28 19:21:16 +02:00
}
}
default : {
// Unknown URL type
return {
urlType : 'unknown'
}
}
}
2021-01-15 17:34:44 +01:00
} ,
2020-08-22 22:51:04 +02:00
clearSessionSearchHistory ( { commit } ) {
commit ( 'setSessionSearchHistory' , [ ] )
} ,
2022-12-29 02:19:48 +01:00
async getExternalPlayerCmdArgumentsData ( { commit } , payload ) {
2021-06-13 17:31:43 +02:00
const fileName = 'external-player-map.json'
2023-01-12 07:55:21 +01:00
/* eslint-disable-next-line n/no-path-concat */
2022-09-26 22:15:13 +02:00
const fileLocation = process . env . NODE _ENV === 'development' ? './static/' : ` ${ _ _dirname } /static/ `
2021-06-13 17:31:43 +02:00
2024-02-04 21:45:37 +01:00
const fileData = await fs . readFile ( ` ${ fileLocation } ${ fileName } ` )
2021-06-13 17:31:43 +02:00
const externalPlayerMap = JSON . parse ( fileData ) . map ( ( entry ) => {
2024-04-07 16:58:15 +02:00
return { name : entry . name , value : entry . value , cmdArguments : entry . cmdArguments }
2021-06-13 17:31:43 +02:00
} )
2023-09-12 19:21:52 +02:00
// Sort external players alphabetically & case-insensitive, keep default entry at the top
const playerNone = externalPlayerMap . shift ( )
externalPlayerMap . sort ( ( a , b ) => a . name . localeCompare ( b . name , undefined , { sensitivity : 'base' } ) )
externalPlayerMap . unshift ( playerNone )
2021-06-13 17:31:43 +02:00
const externalPlayerNames = externalPlayerMap . map ( ( entry ) => { return entry . name } )
const externalPlayerValues = externalPlayerMap . map ( ( entry ) => { return entry . value } )
const externalPlayerCmdArguments = externalPlayerMap . reduce ( ( result , item ) => {
result [ item . value ] = item . cmdArguments
return result
} , { } )
commit ( 'setExternalPlayerNames' , externalPlayerNames )
commit ( 'setExternalPlayerValues' , externalPlayerValues )
commit ( 'setExternalPlayerCmdArguments' , externalPlayerCmdArguments )
} ,
2023-01-01 03:52:49 +01:00
openInExternalPlayer ( { state , rootState } , payload ) {
2021-06-13 17:31:43 +02:00
const args = [ ]
const externalPlayer = rootState . settings . externalPlayer
const cmdArgs = state . externalPlayerCmdArguments [ externalPlayer ]
const executable = rootState . settings . externalPlayerExecutable !== ''
? rootState . settings . externalPlayerExecutable
: cmdArgs . defaultExecutable
const ignoreWarnings = rootState . settings . externalPlayerIgnoreWarnings
2024-01-13 05:06:52 +01:00
const ignoreDefaultArgs = rootState . settings . externalPlayerIgnoreDefaultArgs
2021-06-13 17:31:43 +02:00
const customArgs = rootState . settings . externalPlayerCustomArgs
2024-01-13 05:06:52 +01:00
if ( ignoreDefaultArgs ) {
if ( typeof customArgs === 'string' && customArgs !== '' ) {
const custom = customArgs . split ( ';' )
args . push ( ... custom )
2021-06-13 17:31:43 +02:00
}
2024-01-13 05:06:52 +01:00
if ( payload . videoId != null ) args . push ( ` ${ cmdArgs . videoUrl } https://www.youtube.com/watch?v= ${ payload . videoId } ` )
} else {
// Append custom user-defined arguments,
// or use the default ones specified for the external player.
if ( typeof customArgs === 'string' && customArgs !== '' ) {
const custom = customArgs . split ( ';' )
args . push ( ... custom )
} else if ( typeof cmdArgs . defaultCustomArguments === 'string' && cmdArgs . defaultCustomArguments !== '' ) {
const defaultCustomArguments = cmdArgs . defaultCustomArguments . split ( ';' )
args . push ( ... defaultCustomArguments )
2021-06-13 17:31:43 +02:00
}
2024-01-13 05:06:52 +01:00
if ( payload . watchProgress > 0 && payload . watchProgress < payload . videoLength - 10 ) {
if ( typeof cmdArgs . startOffset === 'string' ) {
if ( cmdArgs . defaultExecutable . startsWith ( 'mpc' ) ) {
// For mpc-hc and mpc-be, which require startOffset to be in milliseconds
args . push ( cmdArgs . startOffset , ( Math . trunc ( payload . watchProgress ) * 1000 ) )
} else if ( cmdArgs . startOffset . endsWith ( '=' ) ) {
// For players using `=` in arguments
// e.g. vlc --start-time=xxxxx
args . push ( ` ${ cmdArgs . startOffset } ${ payload . watchProgress } ` )
} else {
// For players using space in arguments
// e.g. smplayer -start xxxxx
args . push ( cmdArgs . startOffset , Math . trunc ( payload . watchProgress ) )
}
2022-10-13 13:51:15 +02:00
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.starting video at offset' ) )
2021-06-13 17:31:43 +02:00
}
}
2024-01-13 05:06:52 +01:00
if ( payload . playbackRate != null ) {
if ( typeof cmdArgs . playbackRate === 'string' ) {
args . push ( ` ${ cmdArgs . playbackRate } ${ payload . playbackRate } ` )
2022-10-13 13:51:15 +02:00
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.setting a playback rate' ) )
2021-06-13 17:31:43 +02:00
}
}
2024-01-13 05:06:52 +01:00
// Check whether the video is in a playlist
if ( typeof cmdArgs . playlistUrl === 'string' && payload . playlistId != null && payload . playlistId !== '' ) {
if ( payload . playlistIndex != null ) {
if ( typeof cmdArgs . playlistIndex === 'string' ) {
args . push ( ` ${ cmdArgs . playlistIndex } ${ payload . playlistIndex } ` )
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.opening specific video in a playlist (falling back to opening the video)' ) )
2024-01-13 05:06:52 +01:00
}
2021-06-13 17:31:43 +02:00
}
2024-01-13 05:06:52 +01:00
if ( payload . playlistReverse ) {
if ( typeof cmdArgs . playlistReverse === 'string' ) {
args . push ( cmdArgs . playlistReverse )
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.reversing playlists' ) )
2024-01-13 05:06:52 +01:00
}
}
if ( payload . playlistShuffle ) {
if ( typeof cmdArgs . playlistShuffle === 'string' ) {
args . push ( cmdArgs . playlistShuffle )
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.shuffling playlists' ) )
2024-01-13 05:06:52 +01:00
}
}
if ( payload . playlistLoop ) {
if ( typeof cmdArgs . playlistLoop === 'string' ) {
args . push ( cmdArgs . playlistLoop )
} else if ( ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.looping playlists' ) )
2024-01-13 05:06:52 +01:00
}
2021-06-13 17:31:43 +02:00
}
2023-07-03 18:26:56 +02:00
2024-01-13 05:06:52 +01:00
// If the player supports opening playlists but not indexes, send only the video URL if an index is specified
if ( cmdArgs . playlistIndex == null && payload . playlistIndex != null && payload . playlistIndex !== '' ) {
args . push ( ` ${ cmdArgs . videoUrl } https://youtube.com/watch?v= ${ payload . videoId } ` )
} else {
args . push ( ` ${ cmdArgs . playlistUrl } https://youtube.com/playlist?list= ${ payload . playlistId } ` )
}
2023-07-28 09:56:40 +02:00
} else {
2024-01-13 05:06:52 +01:00
if ( payload . playlistId != null && payload . playlistId !== '' && ! ignoreWarnings ) {
2024-04-07 16:58:15 +02:00
showExternalPlayerUnsupportedActionToast ( externalPlayer , i18n . t ( 'Video.External Player.Unsupported Actions.opening playlists' ) )
2024-01-13 05:06:52 +01:00
}
if ( payload . videoId != null ) {
args . push ( ` ${ cmdArgs . videoUrl } https://www.youtube.com/watch?v= ${ payload . videoId } ` )
}
2021-06-13 17:31:43 +02:00
}
}
2023-02-09 19:24:27 +01:00
const videoOrPlaylist = payload . playlistId != null && payload . playlistId !== ''
? i18n . t ( 'Video.External Player.playlist' )
: i18n . t ( 'Video.External Player.video' )
2022-10-13 13:51:15 +02:00
2022-10-14 07:59:49 +02:00
showToast ( i18n . t ( 'Video.External Player.OpeningTemplate' , { videoOrPlaylist , externalPlayer } ) )
2021-06-13 17:31:43 +02:00
2024-04-14 15:16:55 +02:00
if ( process . env . IS _ELECTRON ) {
const { ipcRenderer } = require ( 'electron' )
ipcRenderer . send ( IpcChannels . OPEN _IN _EXTERNAL _PLAYER , { executable , args } )
}
2024-04-17 23:54:46 +02:00
} ,
updateLastCommunityRefreshTimestampByProfile ( { commit } , payload ) {
commit ( 'updateLastCommunityRefreshTimestampByProfile' , payload )
} ,
updateLastShortRefreshTimestampByProfile ( { commit } , payload ) {
commit ( 'updateLastShortRefreshTimestampByProfile' , payload )
} ,
updateLastLiveRefreshTimestampByProfile ( { commit } , payload ) {
commit ( 'updateLastLiveRefreshTimestampByProfile' , payload )
} ,
updateLastVideoRefreshTimestampByProfile ( { commit } , payload ) {
commit ( 'updateLastVideoRefreshTimestampByProfile' , payload )
2020-05-23 23:29:42 +02:00
}
}
2020-02-16 19:30:00 +01:00
const mutations = {
toggleSideNav ( state ) {
state . isSideNavOpen = ! state . isSideNavOpen
} ,
2023-11-17 03:08:10 +01:00
setOutlinesHidden ( state , value ) {
state . outlinesHidden = value
} ,
2020-08-31 23:35:22 +02:00
setShowProgressBar ( state , value ) {
state . showProgressBar = value
} ,
setProgressBarPercentage ( state , value ) {
state . progressBarPercentage = value
} ,
2020-02-16 19:30:00 +01:00
setSessionSearchHistory ( state , history ) {
state . sessionSearchHistory = history
} ,
2023-07-03 18:27:49 +02:00
setDeArrowCache ( state , cache ) {
state . deArrowCache = cache
} ,
addVideoToDeArrowCache ( state , payload ) {
const sameVideo = state . deArrowCache [ payload . videoId ]
if ( ! sameVideo ) {
2023-08-03 14:48:23 +02:00
// setting properties directly doesn't trigger watchers in Vue 2,
// so we need to use Vue's set function
vueSet ( state . deArrowCache , payload . videoId , payload )
2023-07-03 18:27:49 +02:00
}
} ,
2024-01-15 05:20:15 +01:00
addThumbnailToDeArrowCache ( state , payload ) {
vueSet ( state . deArrowCache , payload . videoId , payload )
} ,
2020-02-16 19:30:00 +01:00
addToSessionSearchHistory ( state , payload ) {
const sameSearch = state . sessionSearchHistory . findIndex ( ( search ) => {
2022-11-05 16:13:25 +01:00
return search . query === payload . query && searchFiltersMatch ( payload . searchSettings , search . searchSettings )
2020-02-16 19:30:00 +01:00
} )
if ( sameSearch !== - 1 ) {
2022-03-26 02:20:22 +01:00
state . sessionSearchHistory [ sameSearch ] . data = payload . data
2023-05-13 13:18:49 +02:00
if ( payload . nextPageRef ) {
// Local API
state . sessionSearchHistory [ sameSearch ] . nextPageRef = payload . nextPageRef
} else if ( payload . searchPage ) {
// Invidious API
state . sessionSearchHistory [ sameSearch ] . searchPage = payload . searchPage
}
2020-02-16 19:30:00 +01:00
} else {
state . sessionSearchHistory . push ( payload )
}
} ,
2024-01-03 19:44:57 +01:00
setShowAddToPlaylistPrompt ( state , payload ) {
state . showAddToPlaylistPrompt = payload
} ,
setShowCreatePlaylistPrompt ( state , payload ) {
state . showCreatePlaylistPrompt = payload
} ,
setToBeAddedToPlaylistVideoList ( state , payload ) {
state . toBeAddedToPlaylistVideoList = payload
} ,
setNewPlaylistDefaultProperties ( state , payload ) {
state . newPlaylistDefaultProperties = payload
} ,
resetNewPlaylistDefaultProperties ( state ) {
state . newPlaylistDefaultProperties = { }
} ,
setNewPlaylistVideoObject ( state , payload ) {
state . newPlaylistVideoObject = payload
} ,
2020-08-13 16:26:20 +02:00
setPopularCache ( state , value ) {
state . popularCache = value
} ,
2022-04-09 21:34:55 +02:00
setTrendingCache ( state , { value , page } ) {
2021-08-21 23:08:38 +02:00
state . trendingCache [ page ] = value
2020-08-22 22:37:09 +02:00
} ,
2024-04-17 23:54:46 +02:00
setLastTrendingRefreshTimestamp ( state , timestamp ) {
state . lastTrendingRefreshTimestamp = timestamp
} ,
setLastPopularRefreshTimestamp ( state , timestamp ) {
state . lastPopularRefreshTimestamp = timestamp
} ,
updateLastCommunityRefreshTimestampByProfile ( state , { profileId , timestamp } ) {
vueSet ( state . lastCommunityRefreshTimestampByProfile , profileId , timestamp )
} ,
updateLastShortRefreshTimestampByProfile ( state , { profileId , timestamp } ) {
vueSet ( state . lastShortRefreshTimestampByProfile , profileId , timestamp )
} ,
updateLastLiveRefreshTimestampByProfile ( state , { profileId , timestamp } ) {
vueSet ( state . lastLiveRefreshTimestampByProfile , profileId , timestamp )
} ,
updateLastVideoRefreshTimestampByProfile ( state , { profileId , timestamp } ) {
vueSet ( state . lastVideoRefreshTimestampByProfile , profileId , timestamp )
} ,
2023-03-26 11:32:36 +02:00
clearTrendingCache ( state ) {
state . trendingCache = {
default : null ,
music : null ,
gaming : null ,
movies : null
}
} ,
2022-12-19 11:43:28 +01:00
setCachedPlaylist ( state , value ) {
state . cachedPlaylist = value
} ,
2020-02-16 19:30:00 +01:00
setSearchSortBy ( state , value ) {
state . searchSettings . sortBy = value
} ,
setSearchTime ( state , value ) {
state . searchSettings . time = value
} ,
setSearchType ( state , value ) {
state . searchSettings . type = value
} ,
setSearchDuration ( state , value ) {
state . searchSettings . duration = value
2020-09-20 20:22:39 +02:00
} ,
2020-10-22 20:56:49 +02:00
setRegionNames ( state , value ) {
state . regionNames = value
} ,
setRegionValues ( state , value ) {
state . regionValues = value
} ,
2020-09-20 20:22:39 +02:00
setRecentBlogPosts ( state , value ) {
state . recentBlogPosts = value
2021-06-13 17:31:43 +02:00
} ,
setExternalPlayerNames ( state , value ) {
state . externalPlayerNames = value
} ,
setExternalPlayerValues ( state , value ) {
state . externalPlayerValues = value
} ,
setExternalPlayerCmdArguments ( state , value ) {
state . externalPlayerCmdArguments = value
2020-02-16 19:30:00 +01:00
}
}
export default {
state ,
getters ,
actions ,
mutations
}