554 lines
14 KiB
JavaScript
554 lines
14 KiB
JavaScript
import {useStorage} from '@vueuse/core'
|
|
|
|
const toast = useToast()
|
|
|
|
const AG_SEPARATOR = '.'
|
|
|
|
const TOOLTIP_DATE_FORMAT = 'YYYY-MM-DD h:mm:ss A'
|
|
|
|
const guid_links = {
|
|
'episode': {
|
|
'imdb': 'https://www.imdb.com/title/{_guid}',
|
|
'tmdb': 'https://www.themoviedb.org/tv/{parent.guid_tmdb}/season/{season}/episode/{episode}',
|
|
'tvdb': 'https://thetvdb.com/dereferrer/episode/{_guid}',
|
|
'tvmaze': 'https://www.tvmaze.com/episodes/{_guid}',
|
|
'anidb': 'https://anidb.net/episode/{_guid}',
|
|
'youtube_video': 'https://www.youtube.com/watch?v={_guid}',
|
|
}, 'series': {
|
|
'imdb': 'https://www.imdb.com/title/{_guid}',
|
|
'tmdb': 'https://www.themoviedb.org/tv/{_guid}',
|
|
'tvdb': 'https://thetvdb.com/dereferrer/series/{_guid}',
|
|
'tvmaze': 'https://www.tvmaze.com/shows/{_guid}/-',
|
|
'anidb': 'https://anidb.net/anime/{_guid}',
|
|
'youtube_channel': 'https://www.youtube.com/channel/{_guid}',
|
|
'youtube_playlist': 'https://www.youtube.com/playlist?list={_guid}',
|
|
}, 'movie': {
|
|
'imdb': 'https://www.imdb.com/title/{_guid}',
|
|
'tmdb': 'https://www.themoviedb.org/movie/{_guid}',
|
|
'tvdb': 'https://thetvdb.com/dereferrer/movie/{_guid}',
|
|
'anidb': 'https://anidb.net/anime/{_guid}',
|
|
'youtube_video': 'https://www.youtube.com/watch?v={_guid}',
|
|
},
|
|
}
|
|
|
|
const YT_CH = new RegExp('(UC|HC)[a-zA-Z0-9\\-_]{22}')
|
|
const YT_PL = new RegExp('PL[^\\[\\]]{32}|PL[^\\[\\]]{16}|(UU|FL|LP|RD)[^\\[\\]]{22}')
|
|
|
|
/**
|
|
* Get value from object or function
|
|
*
|
|
* @param {Function|*} obj
|
|
* @returns {*}
|
|
*/
|
|
const getValue = (obj) => 'function' === typeof obj ? obj() : obj;
|
|
|
|
/**
|
|
* Get value from object or function and return default value if it's undefined or null
|
|
*
|
|
* @param {Object|Array} obj The object to get the value from.
|
|
* @param {string} path The path to the value.
|
|
* @param {*} defaultValue The default value to return if the path is not found.
|
|
*
|
|
* @returns {*} The value at the path or the default value.
|
|
*/
|
|
const ag = (obj, path, defaultValue = null) => {
|
|
const keys = path.split(AG_SEPARATOR)
|
|
let at = obj
|
|
|
|
for (let key of keys) {
|
|
if (typeof at === 'object' && null !== at && key in at) {
|
|
at = at[key]
|
|
} else {
|
|
return getValue(defaultValue)
|
|
}
|
|
}
|
|
|
|
return getValue(null === at ? defaultValue : at)
|
|
}
|
|
|
|
/**
|
|
* Set value in object by path
|
|
*
|
|
* @param {Object} obj The object to set the value in.
|
|
* @param {string} path The path to the value.
|
|
* @param {*} value The value to set.
|
|
*
|
|
* @returns {Object} The object with the value set.
|
|
*/
|
|
const ag_set = (obj, path, value) => {
|
|
const keys = path.split(AG_SEPARATOR)
|
|
let at = obj
|
|
|
|
while (keys.length > 0) {
|
|
if (keys.length === 1) {
|
|
if (typeof at === 'object' && at !== null) {
|
|
at[keys.shift()] = value
|
|
} else {
|
|
throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
|
|
}
|
|
} else {
|
|
const key = keys.shift();
|
|
if (!at[key]) {
|
|
at[key] = {}
|
|
}
|
|
at = at[key]
|
|
}
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
/**
|
|
* Convert bytes to human-readable file size
|
|
*
|
|
* @param {number} bytes The number of bytes.
|
|
* @param {boolean} showUnit Whether to show the unit.
|
|
* @param {number} decimals The number of decimals.
|
|
* @param {number} mod The mod.
|
|
*
|
|
* @returns {string} The human-readable file size.
|
|
*/
|
|
const humanFileSize = (bytes = 0, showUnit = true, decimals = 2, mod = 1000) => {
|
|
const sz = 'BKMGTP'
|
|
const factor = Math.floor((bytes.toString().length - 1) / 3)
|
|
return `${(bytes / (mod ** factor)).toFixed(decimals)}${showUnit ? sz[factor] : ''}`
|
|
}
|
|
|
|
/**
|
|
* Wait for an element to be loaded in the DOM
|
|
*
|
|
* @param {string} sel The selector of the element.
|
|
* @param {Function} callback The callback function.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
const awaitElement = (sel, callback) => {
|
|
let interval = undefined
|
|
let $elm = document.querySelector(sel)
|
|
|
|
if ($elm) {
|
|
callback(sel, $elm)
|
|
return
|
|
}
|
|
|
|
interval = setInterval(() => {
|
|
let $elm = document.querySelector(sel)
|
|
if ($elm) {
|
|
clearInterval(interval)
|
|
callback(sel, $elm)
|
|
}
|
|
}, 200)
|
|
}
|
|
|
|
/**
|
|
* Uppercase the first letter of a string
|
|
*
|
|
* @param {string} str The string to uppercase.
|
|
*
|
|
* @returns {string} The string with the first letter uppercased.
|
|
*/
|
|
const ucFirst = (str) => {
|
|
if (typeof str !== 'string') {
|
|
return str
|
|
}
|
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Display a notification
|
|
*
|
|
* @param {string} type The type of the notification.
|
|
* @param {string} title The title of the notification.
|
|
* @param {string} text The text of the notification.
|
|
* @param {number} duration The duration of the notification.
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
const notification = (type, title, text, duration = 3000) => {
|
|
let method = '', options = {
|
|
timeout: duration,
|
|
}
|
|
|
|
switch (type.toLowerCase()) {
|
|
case 'info':
|
|
default:
|
|
method = 'info'
|
|
break
|
|
case 'success':
|
|
method = 'success'
|
|
break
|
|
case 'warning':
|
|
method = 'warning'
|
|
break
|
|
case 'error':
|
|
method = 'error'
|
|
if (3000 === duration) {
|
|
options.timeout = 10000
|
|
}
|
|
break
|
|
}
|
|
toast[method](text, options)
|
|
}
|
|
|
|
/**
|
|
* Replace tags in text with values from context
|
|
*
|
|
* @param {string} text The text with tags
|
|
* @param {object} context The context with values
|
|
*
|
|
* @returns {string} The text with replaced tags
|
|
*/
|
|
const r = (text, context = {}) => {
|
|
const tagLeft = '{';
|
|
const tagRight = '}';
|
|
|
|
if (!text.includes(tagLeft) || !text.includes(tagRight)) {
|
|
return text
|
|
}
|
|
|
|
const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g');
|
|
const matches = text.match(pattern);
|
|
|
|
if (!matches) {
|
|
return text
|
|
}
|
|
|
|
let replacements = {};
|
|
|
|
matches.forEach(match => replacements[match] = ag(context, match.slice(1, -1), ''));
|
|
|
|
for (let key in replacements) {
|
|
text = text.replace(new RegExp(key, 'g'), replacements[key]);
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
/**
|
|
* Make GUID link
|
|
*
|
|
* @param {string} type
|
|
* @param {string} source
|
|
* @param {string} guid
|
|
* @param {object} data
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
const makeGUIDLink = (type, source, guid, data) => {
|
|
if ('youtube' === source) {
|
|
if (YT_CH.test(guid)) {
|
|
source = 'youtube_channel'
|
|
} else if (YT_PL.test(guid)) {
|
|
source = 'youtube_playlist'
|
|
} else {
|
|
source = 'youtube_video'
|
|
}
|
|
}
|
|
|
|
type = type.toLowerCase();
|
|
|
|
if ('show' === type) {
|
|
type = 'series'
|
|
}
|
|
|
|
const link = ag(guid_links, `${type}.${source}`, null)
|
|
|
|
return null == link ? '' : r(link, {_guid: guid, ...toRaw(data)})
|
|
}
|
|
|
|
/**
|
|
* Format duration
|
|
*
|
|
* @param {number} milliseconds
|
|
*
|
|
* @returns {string} The formatted duration.
|
|
*/
|
|
const formatDuration = (milliseconds) => {
|
|
milliseconds = parseInt(milliseconds)
|
|
let seconds = Math.floor(milliseconds / 1000);
|
|
let minutes = Math.floor(seconds / 60);
|
|
let hours = Math.floor(minutes / 60);
|
|
seconds %= 60;
|
|
minutes %= 60;
|
|
|
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
const copyText = (str, showNotification = true) => {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(str).then(() => {
|
|
if (!showNotification) {
|
|
return
|
|
}
|
|
notification('success', 'Success', 'Text copied to clipboard.')
|
|
}).catch((error) => {
|
|
console.error('Failed to copy: ', error)
|
|
if (!showNotification) {
|
|
return
|
|
}
|
|
notification('error', 'Error', 'Failed to copy text to clipboard.')
|
|
});
|
|
return
|
|
}
|
|
|
|
const el = document.createElement('textarea')
|
|
el.value = str
|
|
document.body.appendChild(el)
|
|
el.select()
|
|
document.execCommand('copy')
|
|
document.body.removeChild(el)
|
|
if (!showNotification) {
|
|
return
|
|
}
|
|
notification('success', 'Success', 'Text copied to clipboard.')
|
|
}
|
|
|
|
const makeConsoleCommand = (cmd, run = false) => {
|
|
const params = new URLSearchParams();
|
|
if (run) {
|
|
params.append('run', 'true');
|
|
}
|
|
// -- base64 encode the command to prevent XSS
|
|
params.append('cmd', btoa(cmd));
|
|
return `/console?${params.toString()}`
|
|
}
|
|
|
|
const stringToRegex = (str) => new RegExp(str.match(/\/(.+)\/.*/)[1], str.match(/\/.+\/(.*)/)[1])
|
|
|
|
/**
|
|
* Make history search link.
|
|
*
|
|
* @param {string} type
|
|
* @param {string} query
|
|
*
|
|
* @returns {string}
|
|
*/
|
|
const makeSearchLink = (type, query) => {
|
|
const params = new URLSearchParams();
|
|
params.append('perpage', 50);
|
|
params.append('page', 1);
|
|
params.append('q', query);
|
|
params.append('key', type);
|
|
return `/history?${params.toString()}`
|
|
}
|
|
|
|
/**
|
|
* Dispatch event.
|
|
*
|
|
* @param eventName
|
|
* @param detail
|
|
* @returns {boolean}
|
|
*/
|
|
const dEvent = (eventName, detail = {}) => window.dispatchEvent(new CustomEvent(eventName, {detail}))
|
|
|
|
/**
|
|
* Make name
|
|
*
|
|
* @param item {Object}
|
|
* @param asMovie {boolean}
|
|
*
|
|
* @returns {string|null} The name of the item.
|
|
*/
|
|
const makeName = (item, asMovie = false) => {
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
const year = ag(item, 'year', '0000');
|
|
const title = ag(item, 'title', '??');
|
|
const type = ag(item, 'type', 'movie');
|
|
|
|
if (['show', 'movie'].includes(type) || asMovie) {
|
|
return r('{title} ({year})', {title, year})
|
|
}
|
|
|
|
return r('{title} ({year}) - {season}x{episode}', {
|
|
title,
|
|
year,
|
|
season: ag(item, 'season', 0).toString().padStart(2, '0'),
|
|
episode: ag(item, 'episode', 0).toString().padStart(3, '0'),
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Make pagination
|
|
*
|
|
* @param {number} current
|
|
* @param {number} last
|
|
* @param {number} delta
|
|
*
|
|
* @returns {Array}
|
|
*/
|
|
const makePagination = (current, last, delta = 5) => {
|
|
let pagination = []
|
|
|
|
if (last < 2) {
|
|
return pagination
|
|
}
|
|
|
|
const strR = '-'.repeat(9 + `${last}`.length)
|
|
|
|
const left = current - delta, right = current + delta + 1;
|
|
|
|
for (let i = 1; i <= last; i++) {
|
|
if (i === 1 || i === last || (i >= left && i < right)) {
|
|
if (i === left && i > 2) {
|
|
pagination.push({
|
|
page: 0, text: strR, selected: false,
|
|
});
|
|
}
|
|
|
|
pagination.push({
|
|
page: i, text: `Page #${i}`, selected: i === current
|
|
});
|
|
|
|
if (i === right - 1 && i < last - 1) {
|
|
pagination.push({
|
|
page: 0, text: strR, selected: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return pagination;
|
|
}
|
|
|
|
const makeSecret = (len = 8) => {
|
|
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let result = '';
|
|
let counter = 0;
|
|
while (counter < len) {
|
|
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
|
counter += 1;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Explode string by delimiter.
|
|
*
|
|
* @param {string} delimiter
|
|
* @param {string} string
|
|
* @param {number} limit
|
|
*
|
|
* @returns {string[]}
|
|
*/
|
|
const explode = (delimiter, string, limit = undefined) => {
|
|
if ('' === delimiter) {
|
|
return [string];
|
|
}
|
|
|
|
const parts = string.split(delimiter);
|
|
|
|
if (undefined === limit || 0 === limit) {
|
|
return parts;
|
|
}
|
|
|
|
if (limit > 0) {
|
|
return parts.slice(0, limit - 1).concat(parts.slice(limit - 1).join(delimiter));
|
|
}
|
|
|
|
if (limit < 0) {
|
|
return parts.slice(0, limit);
|
|
}
|
|
}
|
|
const basename = (path, ext = '') => {
|
|
if (!path) {
|
|
return ''
|
|
}
|
|
const segments = path.replace(/\\/g, '/').split('/')
|
|
let base = segments.pop()
|
|
while (segments.length && base === '') {
|
|
base = segments.pop()
|
|
}
|
|
if (ext && base.endsWith(ext) && base !== ext) {
|
|
base = base.substring(0, base.length - ext.length)
|
|
}
|
|
return base
|
|
}
|
|
|
|
const parse_api_response = async r => {
|
|
try {
|
|
return await r.json()
|
|
} catch (e) {
|
|
return {error: {code: r.status, message: r.statusText}}
|
|
}
|
|
}
|
|
|
|
const goto_history_item = async item => {
|
|
if (!item.item_id) {
|
|
return
|
|
}
|
|
|
|
const api_user = useStorage('api_user', 'main')
|
|
|
|
const log_user = item?.user ?? api_user.value
|
|
|
|
if (log_user !== api_user.value) {
|
|
if (false === confirm(`This item is related to '${item.user}' user. And you are currently using '${api_user.value}' Do you want to switch to view the item?`)) {
|
|
return
|
|
}
|
|
api_user.value = log_user
|
|
}
|
|
|
|
await navigateTo(`/history/${item.item_id}`)
|
|
}
|
|
|
|
/**
|
|
* Queue event.
|
|
*
|
|
* @param {string} event The event name.
|
|
* @param {object} event_data The event data.
|
|
* @param {int} delay delay running the event in XXX seconds.
|
|
* @param {object} opts additional options.
|
|
*
|
|
* @returns {Promise<number>} The status code of the response.
|
|
*/
|
|
const queue_event = async (event, event_data = {}, delay = 0, opts = {}) => {
|
|
let reqData = {event}
|
|
if (event_data) {
|
|
reqData.event_data = event_data
|
|
}
|
|
|
|
delay = parseInt(delay)
|
|
|
|
if (0 !== delay) {
|
|
reqData.DELAY_BY = delay
|
|
}
|
|
|
|
if (opts) {
|
|
reqData = {...reqData, ...opts}
|
|
}
|
|
|
|
const resp = await request(`/system/events`, {
|
|
method: 'POST', body: JSON.stringify(reqData)
|
|
})
|
|
|
|
return resp.status
|
|
}
|
|
|
|
export {
|
|
r,
|
|
ag_set,
|
|
ag,
|
|
humanFileSize,
|
|
awaitElement,
|
|
ucFirst,
|
|
notification,
|
|
makeGUIDLink,
|
|
formatDuration,
|
|
copyText,
|
|
stringToRegex,
|
|
makeConsoleCommand,
|
|
makeSearchLink,
|
|
dEvent,
|
|
makeName,
|
|
makePagination,
|
|
TOOLTIP_DATE_FORMAT,
|
|
makeSecret,
|
|
explode,
|
|
basename,
|
|
parse_api_response,
|
|
goto_history_item,
|
|
queue_event
|
|
}
|