From 1aad8a5447512595eaaccf2809b12f3fc4c90a8f Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Mon, 15 Apr 2024 11:55:51 +0200 Subject: [PATCH 01/18] use new format for plugin dirs --- server/src/Core/Providers/Twitch/TwitchAutomator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/Core/Providers/Twitch/TwitchAutomator.ts b/server/src/Core/Providers/Twitch/TwitchAutomator.ts index ef024628..8167af33 100644 --- a/server/src/Core/Providers/Twitch/TwitchAutomator.ts +++ b/server/src/Core/Providers/Twitch/TwitchAutomator.ts @@ -715,7 +715,7 @@ export class TwitchAutomator extends BaseAutomator { // streamlink-ttvlol plugin if (Config.getInstance().cfg("capture.twitch-ttv-lol-plugin") && LiveStreamDVR.ttvLolPluginAvailable) { - cmd.push("--plugin-dirs", BaseConfigDataFolder.streamlink_plugins); + cmd.push("--plugin-dir", BaseConfigDataFolder.streamlink_plugins); if ( Config.getInstance().hasValue("capture.twitch-proxy-playlist") From c19cd97415b356615a1afc434a5ec00b24ef5768 Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Mon, 15 Apr 2024 14:33:01 +0200 Subject: [PATCH 02/18] use json for ntfy related to #493 --- common/ServerConfig.ts | 8 ++++- server/src/Core/Notifiers/Ntfy.ts | 50 ++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/common/ServerConfig.ts b/common/ServerConfig.ts index 8c7eea22..ce19bba3 100644 --- a/common/ServerConfig.ts +++ b/common/ServerConfig.ts @@ -614,7 +614,13 @@ export const settingsFields: Record = { "notifications.ntfy.url": { group: "Notifications (Ntfy)", - text: "Ntfy url with topic", + text: "Ntfy url without topic", + type: "string", + }, + + "notifications.ntfy.topic": { + group: "Notifications (Ntfy)", + text: "Ntfy topic", type: "string", }, diff --git a/server/src/Core/Notifiers/Ntfy.ts b/server/src/Core/Notifiers/Ntfy.ts index c1ee9f09..27b0eed4 100644 --- a/server/src/Core/Notifiers/Ntfy.ts +++ b/server/src/Core/Notifiers/Ntfy.ts @@ -12,18 +12,24 @@ type Action = { body?: string; }; -// action=, label=, paramN=... [; action=, label=, ...] -function buildActions(actions: Action[]) { - return actions - .map((action) => { - return `action=${action.action}, label=${action.label}, ${ - action.url ? `url=${action.url}, ` : "" - }${action.clear ? `clear=${action.clear}, ` : ""}${ - action.body ? `body='${action.body}', ` : "" - }`; - }) - .join("; "); -} +type NtfyPublish = { + topic: string; + message?: string; + title?: string; + tags?: string[]; + priority?: number; + actions?: Action[]; + click?: string; + attach?: string; + markdown?: boolean; + icon?: string; + filename?: string; + delay?: string; + email?: string; + call?: string; +}; + +// action=, label=, paramN=... [; action=, label=, ...]/* export default function notify({ title, @@ -43,6 +49,9 @@ export default function notify({ actions?: Action[]; }) { const ntfyUrl = Config.getInstance().cfg("notifications.ntfy.url"); + const ntfyTopic = Config.getInstance().cfg( + "notifications.ntfy.topic" + ); if (url) { actions.push({ @@ -52,18 +61,23 @@ export default function notify({ }); } + const bodyData: NtfyPublish = { + topic: ntfyTopic, + title: title, + message: body, + actions: actions, + icon: icon ?? undefined, + tags: emoji ? [emoji, category] : [category], + }; + if (ntfyUrl) { axios .request({ url: ntfyUrl, + data: bodyData, headers: { - Title: title, - // Actions: url ? `view, Open, ${url}` : undefined, - Actions: buildActions(actions), - Icon: icon ?? undefined, - Tags: emoji ? `${emoji},${category}` : category, + "Content-Type": "application/json", }, - data: body, method: "POST", }) .then((res) => { From cc82c576be036f98957eeaf242a4b4df0bf08890 Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Tue, 16 Apr 2024 15:04:23 +0200 Subject: [PATCH 03/18] bump server version --- server/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/package.json b/server/package.json index e1ccc332..2f59cdf2 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "livestreamdvr-server", - "version": "1.7.3", + "version": "1.7.3.1", "description": "", "main": "index.ts", "scripts": { From 638e3639e5e67211edbbfc3158caa2e5bcd8be77 Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Tue, 16 Apr 2024 15:15:40 +0200 Subject: [PATCH 04/18] replace template on diretory too for exporters fixes #494 --- server/src/Controllers/Exporter.ts | 10 +++++ server/src/Exporters/FTP.ts | 15 +++---- server/src/Exporters/File.ts | 69 +++++++++++++++++++++++++++++- server/src/Exporters/RClone.ts | 10 +++-- server/src/Exporters/SFTP.ts | 14 +++--- 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/server/src/Controllers/Exporter.ts b/server/src/Controllers/Exporter.ts index 3f8f1f8f..137ee6e6 100644 --- a/server/src/Controllers/Exporter.ts +++ b/server/src/Controllers/Exporter.ts @@ -51,6 +51,11 @@ export function GetExporter( if (!options.host) throw new Error("No host set"); if (!options.username) throw new Error("No username set"); exporter = new SFTPExporter(); + if (!(exporter instanceof SFTPExporter)) { + throw new Error( + "Exporter is not an SFTPExporter (why does typescript need this?)" + ); + } exporter.setDirectory(output_directory); exporter.setHost(options.host); exporter.setUsername(options.username); @@ -86,6 +91,11 @@ export function GetExporter( if (!output_directory) throw new Error("No directory set"); if (!options.remote) throw new Error("No remote set"); exporter = new RCloneExporter(); + if (!(exporter instanceof RCloneExporter)) { + throw new Error( + "Exporter is not an RCloneExporter (why does typescript need this?)" + ); + } exporter.setDirectory(output_directory); exporter.setRemote(options.remote); } diff --git a/server/src/Exporters/FTP.ts b/server/src/Exporters/FTP.ts index 63937527..4e56d058 100644 --- a/server/src/Exporters/FTP.ts +++ b/server/src/Exporters/FTP.ts @@ -1,16 +1,16 @@ import { execSimple, startJob } from "@/Helpers/Execute"; import path from "node:path"; import sanitize from "sanitize-filename"; -import { BaseExporter } from "./Base"; +import { FileExporter } from "./File"; /** * Basic FTP exporter to transfer the VOD to a remote FTP server. * Uses curl to transfer the file. */ -export class FTPExporter extends BaseExporter { +export class FTPExporter extends FileExporter { public type = "FTP"; - public directory = ""; + // public directory = ""; public host = ""; public username = ""; public password = ""; @@ -19,10 +19,6 @@ export class FTPExporter extends BaseExporter { // public supportsDirectories = true; - public setDirectory(directory: string): void { - this.directory = directory; - } - public setHost(host: string): void { this.host = host; } @@ -51,7 +47,10 @@ export class FTPExporter extends BaseExporter { const finalFilename = sanitize(this.getFormattedTitle()) + "." + this.extension; - const filesystemPath = path.join(this.directory, finalFilename); + const filesystemPath = path.join( + this.getFormattedDirectory(), + finalFilename + ); const linuxPath = filesystemPath.replace(/\\/g, "/"); const webPath = encodeURIComponent(linuxPath); diff --git a/server/src/Exporters/File.ts b/server/src/Exporters/File.ts index 870dbca8..997d3a50 100644 --- a/server/src/Exporters/File.ts +++ b/server/src/Exporters/File.ts @@ -1,6 +1,11 @@ +import { Config } from "@/Core/Config"; import { Job } from "@/Core/Job"; import { log, LOGLEVEL } from "@/Core/Log"; import { xClearInterval, xInterval } from "@/Helpers/Timeout"; +import { isTwitchVOD } from "@/Helpers/Types"; +import { formatString } from "@common/Format"; +import type { ExporterFilenameTemplate } from "@common/Replacements"; +import { format } from "date-fns"; import fs from "node:fs"; import path from "node:path"; import sanitize from "sanitize-filename"; @@ -28,7 +33,10 @@ export class FileExporter extends BaseExporter { const finalFilename = sanitize(this.getFormattedTitle()) + "." + this.extension; - this.final_path = path.join(this.directory, finalFilename); + this.final_path = path.join( + this.getFormattedDirectory(), + finalFilename + ); if (fs.existsSync(this.final_path)) { throw new Error(`File already exists: ${this.final_path}`); @@ -79,4 +87,63 @@ export class FileExporter extends BaseExporter { }); }); } + + public getFormattedDirectory() { + if (!this.vod) { + return this.directory; // no vod loaded, return directory instead + } + if (!this.vod.video_metadata) throw new Error("No video_metadata"); + if (!this.vod.started_at) throw new Error("No started_at"); + + let title = "Title"; + // TODO: why is this done here? + if (isTwitchVOD(this.vod) && this.vod.external_vod_title) + title = this.vod.external_vod_title; + if (this.vod.chapters[0]) title = this.vod.chapters[0].title; + + const replacements: ExporterFilenameTemplate = { + login: this.vod.getChannel().internalName, + internalName: this.vod.getChannel().internalName, + displayName: this.vod.getChannel().displayName, + title: title, + date: format(this.vod.started_at, Config.getInstance().dateFormat), + year: this.vod.started_at + ? format(this.vod.started_at, "yyyy") + : "", + month: this.vod.started_at ? format(this.vod.started_at, "MM") : "", + day: this.vod.started_at ? format(this.vod.started_at, "dd") : "", + comment: this.vod.comment || "", + + stream_number: this.vod.stream_number + ? this.vod.stream_number.toString() + : "", // deprecated + episode: this.vod.stream_number?.toString() || "", + absolute_episode: this.vod.stream_absolute_number?.toString() || "", + season: this.vod.stream_season?.toString() || "", + absolute_season: this.vod.stream_absolute_season?.toString() || "", + + resolution: "", + id: this.vod.capture_id, + }; + + for (const literal in replacements) { + if ( + replacements[literal] === undefined || + replacements[literal] === null || + replacements[literal] === "" + ) { + log( + LOGLEVEL.WARNING, + "BaseExporter.getFormattedTitle", + `No value for replacement literal '${literal}', using template '${this.template_filename}'` + ); + } + } + + if (this.vod.video_metadata.type == "video") { + replacements.resolution = `${this.vod.video_metadata.height}p`; + } + + return formatString(this.directory, replacements); + } } diff --git a/server/src/Exporters/RClone.ts b/server/src/Exporters/RClone.ts index 3ec8336d..38c583d8 100644 --- a/server/src/Exporters/RClone.ts +++ b/server/src/Exporters/RClone.ts @@ -4,7 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import sanitize from "sanitize-filename"; import { z } from "zod"; -import { BaseExporter } from "./Base"; +import { FileExporter } from "./File"; // export const rCloneExporterConfigSchema = z.object({ // type: z.literal("RClone"), @@ -23,10 +23,9 @@ const rCloneLsJsonSchema = z.array( }) ); -export class RCloneExporter extends BaseExporter { +export class RCloneExporter extends FileExporter { public type = "RClone"; - public directory = ""; // public host = ""; public remote_file = ""; @@ -84,7 +83,10 @@ export class RCloneExporter extends BaseExporter { const finalFilename = sanitize(this.getFormattedTitle()) + "." + this.extension; - const filesystemPath = path.join(this.directory, finalFilename); + const filesystemPath = path.join( + this.getFormattedDirectory(), + finalFilename + ); const linuxPath = filesystemPath.replace(/\\/g, "/"); // const escaped_remote_path = linux_path.includes(" ") ? `'${linux_path}'` : linux_path; const remotePath = `${this.remote}:${linuxPath}`; diff --git a/server/src/Exporters/SFTP.ts b/server/src/Exporters/SFTP.ts index 5af997b7..6389709b 100644 --- a/server/src/Exporters/SFTP.ts +++ b/server/src/Exporters/SFTP.ts @@ -1,16 +1,15 @@ import { execSimple, startJob } from "@/Helpers/Execute"; import path from "node:path"; import sanitize from "sanitize-filename"; -import { BaseExporter } from "./Base"; +import { FileExporter } from "./File"; /** * Basic SFTP exporter to transfer the VOD to a remote SFTP server. * Uses scp to transfer the file. */ -export class SFTPExporter extends BaseExporter { +export class SFTPExporter extends FileExporter { public type = "SFTP"; - public directory = ""; public host = ""; public username = ""; @@ -18,10 +17,6 @@ export class SFTPExporter extends BaseExporter { public supportsDirectories = true; - public setDirectory(directory: string): void { - this.directory = directory; - } - public setHost(host: string): void { this.host = host; } @@ -41,7 +36,10 @@ export class SFTPExporter extends BaseExporter { const finalFilename = sanitize(this.getFormattedTitle()) + "." + this.extension; - const filesystemPath = path.join(this.directory, finalFilename); + const filesystemPath = path.join( + this.getFormattedDirectory(), + finalFilename + ); const linuxPath = filesystemPath.replace(/\\/g, "/"); let remotePath = `${this.host}:'${linuxPath}'`; if (this.username) { From d32923f678262edc64d5fe8329a48a5e3920bde1 Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Tue, 16 Apr 2024 15:19:42 +0200 Subject: [PATCH 05/18] add replacements to directory config and input related to #494 --- client-vue/src/components/vod/ExportModal.vue | 10 +++++++++- common/ServerConfig.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/client-vue/src/components/vod/ExportModal.vue b/client-vue/src/components/vod/ExportModal.vue index 3f664a54..487b5df3 100644 --- a/client-vue/src/components/vod/ExportModal.vue +++ b/client-vue/src/components/vod/ExportModal.vue @@ -79,7 +79,15 @@
- + +
    +
  • + {{ k }} +
  • +
+

+ {{ templatePreview(exportVodSettings.directory || "") }} +

The folder where you want the file to end up in. Both local and remote.

diff --git a/common/ServerConfig.ts b/common/ServerConfig.ts index ce19bba3..c5deb33f 100644 --- a/common/ServerConfig.ts +++ b/common/ServerConfig.ts @@ -811,7 +811,8 @@ export const settingsFields: Record = { "exporter.default.directory": { group: "Exporter", text: "Default directory", - type: "string", + type: "template", + replacements: ExporterFilenameFields, }, "exporter.default.host": { group: "Exporter", From c622243f703776cff23d76cb2240abf995e0a8d7 Mon Sep 17 00:00:00 2001 From: "CATERPILLAR\\Braxen" Date: Wed, 17 Apr 2024 10:33:02 +0200 Subject: [PATCH 06/18] restrict config values to keys of fields --- .../components/forms/ChannelUpdateForm.vue | 2 +- .../src/components/forms/SettingsForm.vue | 26 ++-- client-vue/src/components/menu/SideMenu.vue | 2 +- .../src/components/vod/VodItemStatus.vue | 4 +- client-vue/src/store/index.ts | 4 +- common/Config.ts | 6 + common/ServerConfig.ts | 12 +- server/src/Controllers/Channels.ts | 7 +- server/src/Controllers/Settings.ts | 28 +++- server/src/Core/Config.ts | 143 +++++++++++------- server/src/Core/LiveStreamDVR.ts | 23 ++- .../Core/Providers/YouTube/YouTubeChannel.ts | 2 +- server/src/Providers/TikTok.ts | 10 +- server/tests/api.test.ts | 5 +- server/tests/config.test.ts | 6 +- 15 files changed, 177 insertions(+), 103 deletions(-) diff --git a/client-vue/src/components/forms/ChannelUpdateForm.vue b/client-vue/src/components/forms/ChannelUpdateForm.vue index c5d493f1..99918057 100644 --- a/client-vue/src/components/forms/ChannelUpdateForm.vue +++ b/client-vue/src/components/forms/ChannelUpdateForm.vue @@ -376,7 +376,7 @@ function deleteChannel() { } function subscribeChannel() { - if ((!store.cfg("app_url") || store.cfg("app_url") == "debug") && store.cfg("twitchapi.twitchapi.eventsub_type") === "webhook") { + if ((!store.cfg("app_url") || store.cfg("app_url") == "debug") && store.cfg("twitchapi.eventsub_type") === "webhook") { alert("Please set the app url in the settings"); return; } diff --git a/client-vue/src/components/forms/SettingsForm.vue b/client-vue/src/components/forms/SettingsForm.vue index ac4dc99e..1fb6a8b1 100644 --- a/client-vue/src/components/forms/SettingsForm.vue +++ b/client-vue/src/components/forms/SettingsForm.vue @@ -9,8 +9,8 @@
  • - Under {{ setting.group }}: {{ setting.text }} ({{ setting.help }}) + Under {{ setting?.group }}: {{ setting?.text }} ({{ setting !== undefined && "help" in setting ? setting.help : "No help" }})
@@ -197,7 +197,7 @@ const { t, te } = useI18n(); const formStatusText = ref("Ready"); const formStatus = ref("IDLE"); const formData = ref<{ config: Record }>({ config: {} }); -const fetchedSettingsFields = ref({}); +const fetchedSettingsFields = ref(); const loading = ref(false); const searchText = ref(""); @@ -205,13 +205,14 @@ const searchText = ref(""); const settingsGroups = computed((): SettingsGroup[] => { if (!fetchedSettingsFields.value) return []; const groups: Record = {}; - for (const key in fetchedSettingsFields.value) { + for (const rawKey in fetchedSettingsFields.value) { + const key = rawKey as keyof typeof fetchedSettingsFields.value; const field = fetchedSettingsFields.value[key]; if (!field.group) continue; if (searchText.value) { if ( !key.toLowerCase().includes(searchText.value.toLowerCase()) && - !field.help?.toLowerCase().includes(searchText.value.toLowerCase()) && + ("help" in field && !field.help?.toLowerCase().includes(searchText.value.toLowerCase())) && !field.text?.toLowerCase().includes(searchText.value.toLowerCase()) ) continue; @@ -232,11 +233,14 @@ const settingsGroups = computed((): SettingsGroup[] => { */ }); -const newAndInterestingSettings = computed((): typeof settingsFields => { - const newSettings: typeof settingsFields = {}; - for (const key in fetchedSettingsFields.value) { +const newAndInterestingSettings = computed((): Record => { + const newSettings: Record = {} as any; + for (const rawKey in fetchedSettingsFields.value) { + const key = rawKey as keyof typeof fetchedSettingsFields.value; const field = fetchedSettingsFields.value[key]; - if (field.new) newSettings[key] = field; + if (field !== undefined && "new" in field && field.new) { + newSettings[key] = field; + } } return newSettings; // return fetchedSettingsFields.value.filter((field) => field.new); @@ -286,8 +290,8 @@ function fetchData(): void { // set defaults for (const key in fetchedSettingsFields.value) { - const field = fetchedSettingsFields.value[key]; - if (field.default !== undefined && formData.value.config[key] === undefined) { + const field = fetchedSettingsFields.value[key as keyof typeof fetchedSettingsFields.value]; + if ("default" in field && field.default !== undefined && formData.value.config[key] === undefined) { formData.value.config[key] = field.default; } } diff --git a/client-vue/src/components/menu/SideMenu.vue b/client-vue/src/components/menu/SideMenu.vue index b93c4667..1095a84c 100644 --- a/client-vue/src/components/menu/SideMenu.vue +++ b/client-vue/src/components/menu/SideMenu.vue @@ -4,7 +4,7 @@
- +

{{ store.app_name }}