FreeTube/src/renderer/store/modules/utils.js

678 lines
19 KiB
JavaScript

import fs from 'fs/promises'
import path from 'path'
import i18n from '../../i18n/index'
import { IpcChannels } from '../../../constants'
import { pathExists } from '../../helpers/filesystem'
import {
CHANNEL_HANDLE_REGEX,
createWebURL,
getVideoParamsFromUrl,
openExternalLink,
replaceFilenameForbiddenChars,
searchFiltersMatch,
showExternalPlayerUnsupportedActionToast,
showSaveDialog,
showToast
} from '../../helpers/utils'
const state = {
isSideNavOpen: false,
sessionSearchHistory: [],
popularCache: null,
trendingCache: {
default: null,
music: null,
gaming: null,
movies: null
},
cachedPlaylist: null,
showProgressBar: false,
progressBarPercentage: 0,
regionNames: [],
regionValues: [],
recentBlogPosts: [],
searchSettings: {
sortBy: 'relevance',
time: '',
type: 'all',
duration: ''
},
externalPlayerNames: [],
externalPlayerNameTranslationKeys: [],
externalPlayerValues: [],
externalPlayerCmdArguments: {}
}
const getters = {
getIsSideNavOpen () {
return state.isSideNavOpen
},
getCurrentVolume () {
return state.currentVolume
},
getSessionSearchHistory () {
return state.sessionSearchHistory
},
getPopularCache () {
return state.popularCache
},
getTrendingCache () {
return state.trendingCache
},
getCachedPlaylist() {
return state.cachedPlaylist
},
getSearchSettings () {
return state.searchSettings
},
getShowProgressBar () {
return state.showProgressBar
},
getProgressBarPercentage () {
return state.progressBarPercentage
},
getRegionNames () {
return state.regionNames
},
getRegionValues () {
return state.regionValues
},
getRecentBlogPosts () {
return state.recentBlogPosts
},
getExternalPlayerNames () {
return state.externalPlayerNames
},
getExternalPlayerNameTranslationKeys () {
return state.externalPlayerNameTranslationKeys
},
getExternalPlayerValues () {
return state.externalPlayerValues
},
getExternalPlayerCmdArguments () {
return state.externalPlayerCmdArguments
}
}
const actions = {
async downloadMedia({ rootState }, { url, title, extension, fallingBackPath }) {
if (!process.env.IS_ELECTRON) {
openExternalLink(url)
return
}
const fileName = `${replaceFilenameForbiddenChars(title)}.${extension}`
const errorMessage = i18n.t('Downloading failed', { videoTitle: title })
let folderPath = rootState.settings.downloadFolderPath
if (folderPath === '') {
const options = {
defaultPath: fileName,
filters: [
{
name: extension.toUpperCase(),
extensions: [extension]
}
]
}
const response = await showSaveDialog(options)
if (response.canceled || response.filePath === '') {
// User canceled the save dialog
return
}
folderPath = response.filePath
} else {
if (!(await pathExists(folderPath))) {
try {
await fs.mkdir(folderPath, { recursive: true })
} catch (err) {
console.error(err)
showToast(err)
return
}
}
folderPath = path.join(folderPath, fileName)
}
showToast(i18n.t('Starting download', { videoTitle: title }))
const response = await fetch(url).catch((error) => {
console.error(error)
showToast(errorMessage)
})
const reader = response.body.getReader()
const chunks = []
const handleError = (err) => {
console.error(err)
showToast(errorMessage)
}
const processText = async ({ done, value }) => {
if (done) {
return
}
chunks.push(value)
// Can be used in the future to determine download percentage
// const contentLength = response.headers.get('Content-Length')
// const receivedLength = value.length
// const percentage = receivedLength / contentLength
await reader.read().then(processText).catch(handleError)
}
await reader.read().then(processText).catch(handleError)
const blobFile = new Blob(chunks)
const buffer = await blobFile.arrayBuffer()
try {
await fs.writeFile(folderPath, new DataView(buffer))
showToast(i18n.t('Downloading has completed', { videoTitle: title }))
} catch (err) {
console.error(err)
showToast(errorMessage)
}
},
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)
}
if (parsedString !== replaceFilenameForbiddenChars(parsedString)) {
reject(new Error('Forbidden Characters')) // use message as translation key
}
let filename
if (parsedString.indexOf(path.sep) !== -1) {
const lastIndex = parsedString.lastIndexOf(path.sep)
filename = parsedString.substring(lastIndex + 1)
} else {
filename = parsedString
}
if (!filename) {
reject(new Error('Empty File Name'))
}
resolve(parsedString)
})
},
updateShowProgressBar ({ commit }, value) {
commit('setShowProgressBar', value)
},
async getRegionData ({ commit }, { locale }) {
let localePathExists
// Exclude __dirname from path if not in electron
const fileLocation = `${process.env.IS_ELECTRON ? process.env.NODE_ENV === 'development' ? '.' : __dirname : ''}/static/geolocations/`
if (process.env.IS_ELECTRON) {
localePathExists = await pathExists(`${fileLocation}${locale}`)
} else {
localePathExists = process.env.GEOLOCATION_NAMES.includes(locale)
}
const pathName = `${fileLocation}${localePathExists ? locale : 'en-US'}/countries.json`
const fileData = process.env.IS_ELECTRON ? JSON.parse(await fs.readFile(pathName)) : await (await fetch(createWebURL(pathName))).json()
const countries = fileData.map((entry) => { return { id: entry.id, name: entry.name, code: entry.alpha2 } })
countries.sort((a, b) => { return a.id - b.id })
const regionNames = countries.map((entry) => { return entry.name })
const regionValues = countries.map((entry) => { return entry.code })
commit('setRegionNames', regionNames)
commit('setRegionValues', regionValues)
},
async getYoutubeUrlInfo({ rootState, state }, urlStr) {
// 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
if (CHANNEL_HANDLE_REGEX.test(urlStr)) {
urlStr = `https://www.youtube.com/${urlStr}`
}
const { videoId, timestamp, playlistId } = getVideoParamsFromUrl(urlStr)
if (videoId) {
return {
urlType: 'video',
videoId,
playlistId,
timestamp
}
}
let url
try {
url = new URL(urlStr)
} catch {
return {
urlType: 'invalid_url'
}
}
let urlType = 'unknown'
const channelPattern =
/^\/(?:(?:channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(join|featured|videos|playlists|about|community|channels))?\/?$/
const typePatterns = new Map([
['playlist', /^(\/playlist\/?|\/embed(\/?videoseries)?)$/],
['search', /^\/results\/?$/],
['hashtag', /^\/hashtag\/([^#&/?]+)$/],
['channel', channelPattern]
])
for (const [type, pattern] of typePatterns) {
const matchFound = pattern.test(url.pathname)
if (matchFound) {
urlType = type
break
}
}
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
}
}
case 'search': {
if (!url.searchParams.has('search_query')) {
throw new Error('Search: "search_query" field not found')
}
const searchQuery = url.searchParams.get('search_query')
url.searchParams.delete('search_query')
const searchSettings = state.searchSettings
const query = {
sortBy: searchSettings.sortBy,
time: searchSettings.time,
type: searchSettings.type,
duration: searchSettings.duration
}
for (const [param, value] of url.searchParams) {
query[param] = value
}
return {
urlType: 'search',
searchQuery,
query
}
}
case 'hashtag': {
return {
urlType: 'hashtag'
}
}
/*
Using RegExp named capture groups from ES2018
To avoid access to specific captured value broken
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.
*/
case 'channel': {
const match = url.pathname.match(channelPattern)
const channelId = match.groups.channelId
if (!channelId) {
throw new Error('Channel: could not extract id')
}
let subPath = null
switch (url.pathname.split('/').filter(i => i)[2]) {
case 'playlists':
subPath = 'playlists'
break
case 'channels':
case 'about':
subPath = 'about'
break
case 'community':
default:
subPath = 'videos'
break
}
return {
urlType: 'channel',
channelId,
subPath,
url: url.toString()
}
}
default: {
// Unknown URL type
return {
urlType: 'unknown'
}
}
}
},
clearSessionSearchHistory ({ commit }) {
commit('setSessionSearchHistory', [])
},
async getExternalPlayerCmdArgumentsData ({ commit }, payload) {
const fileName = 'external-player-map.json'
let fileData
/* eslint-disable-next-line n/no-path-concat */
const fileLocation = process.env.NODE_ENV === 'development' ? './static/' : `${__dirname}/static/`
if (await pathExists(`${fileLocation}${fileName}`)) {
fileData = await fs.readFile(`${fileLocation}${fileName}`)
} else {
fileData = '[{"name":"None","value":"","cmdArguments":null}]'
}
const externalPlayerMap = JSON.parse(fileData).map((entry) => {
return { name: entry.name, nameTranslationKey: entry.nameTranslationKey, value: entry.value, cmdArguments: entry.cmdArguments }
})
const externalPlayerNames = externalPlayerMap.map((entry) => { return entry.name })
const externalPlayerNameTranslationKeys = externalPlayerMap.map((entry) => { return entry.nameTranslationKey })
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('setExternalPlayerNameTranslationKeys', externalPlayerNameTranslationKeys)
commit('setExternalPlayerValues', externalPlayerValues)
commit('setExternalPlayerCmdArguments', externalPlayerCmdArguments)
},
openInExternalPlayer ({ state, rootState }, payload) {
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
const customArgs = rootState.settings.externalPlayerCustomArgs
// 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)
}
if (payload.watchProgress > 0 && payload.watchProgress < payload.videoLength - 10) {
if (typeof cmdArgs.startOffset === 'string') {
args.push(`${cmdArgs.startOffset}${payload.watchProgress}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'starting video at offset')
}
}
if (payload.playbackRate != null) {
if (typeof cmdArgs.playbackRate === 'string') {
args.push(`${cmdArgs.playbackRate}${payload.playbackRate}`)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'setting a playback rate')
}
}
// 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) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening specific video in a playlist (falling back to opening the video)')
}
}
if (payload.playlistReverse) {
if (typeof cmdArgs.playlistReverse === 'string') {
args.push(cmdArgs.playlistReverse)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'reversing playlists')
}
}
if (payload.playlistShuffle) {
if (typeof cmdArgs.playlistShuffle === 'string') {
args.push(cmdArgs.playlistShuffle)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'shuffling playlists')
}
}
if (payload.playlistLoop) {
if (typeof cmdArgs.playlistLoop === 'string') {
args.push(cmdArgs.playlistLoop)
} else if (!ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'looping playlists')
}
}
if (cmdArgs.supportsYtdlProtocol) {
args.push(`${cmdArgs.playlistUrl}ytdl://${payload.playlistId}`)
} else {
args.push(`${cmdArgs.playlistUrl}https://youtube.com/playlist?list=${payload.playlistId}`)
}
} else {
if (payload.playlistId != null && payload.playlistId !== '' && !ignoreWarnings) {
showExternalPlayerUnsupportedActionToast(externalPlayer, 'opening playlists')
}
if (payload.videoId != null) {
if (cmdArgs.supportsYtdlProtocol) {
args.push(`${cmdArgs.videoUrl}ytdl://${payload.videoId}`)
} else {
args.push(`${cmdArgs.videoUrl}https://www.youtube.com/watch?v=${payload.videoId}`)
}
}
}
const videoOrPlaylist = payload.playlistId != null && payload.playlistId !== ''
? i18n.t('Video.External Player.playlist')
: i18n.t('Video.External Player.video')
showToast(i18n.t('Video.External Player.OpeningTemplate', { videoOrPlaylist, externalPlayer }))
const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, { executable, args })
}
}
const mutations = {
toggleSideNav (state) {
state.isSideNavOpen = !state.isSideNavOpen
},
setShowProgressBar (state, value) {
state.showProgressBar = value
},
setProgressBarPercentage (state, value) {
state.progressBarPercentage = value
},
setSessionSearchHistory (state, history) {
state.sessionSearchHistory = history
},
addToSessionSearchHistory (state, payload) {
const sameSearch = state.sessionSearchHistory.findIndex((search) => {
return search.query === payload.query && searchFiltersMatch(payload.searchSettings, search.searchSettings)
})
if (sameSearch !== -1) {
state.sessionSearchHistory[sameSearch].data = payload.data
state.sessionSearchHistory[sameSearch].nextPageRef = payload.nextPageRef
} else {
state.sessionSearchHistory.push(payload)
}
},
setPopularCache (state, value) {
state.popularCache = value
},
setTrendingCache (state, { value, page }) {
state.trendingCache[page] = value
},
setCachedPlaylist(state, value) {
state.cachedPlaylist = value
},
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
},
setRegionNames (state, value) {
state.regionNames = value
},
setRegionValues (state, value) {
state.regionValues = value
},
setRecentBlogPosts (state, value) {
state.recentBlogPosts = value
},
setExternalPlayerNames (state, value) {
state.externalPlayerNames = value
},
setExternalPlayerNameTranslationKeys (state, value) {
state.externalPlayerNameTranslationKeys = value
},
setExternalPlayerValues (state, value) {
state.externalPlayerValues = value
},
setExternalPlayerCmdArguments (state, value) {
state.externalPlayerCmdArguments = value
}
}
export default {
state,
getters,
actions,
mutations
}