@apocaliss92/scrypted-reolink-hub
Version:
Reolink (HUBs) Plugin for Scrypted
796 lines (665 loc) • 32.9 kB
text/typescript
import { sleep } from "@scrypted/common/src/sleep";
import sdk, { MediaObject, Setting, Settings, VideoClip, VideoClipOptions, VideoClips, VideoClipThumbnailOptions } from "@scrypted/sdk";
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/sdk/settings-mixin";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import url from 'url';
import { getBaseLogger } from "../../scrypted-apocaliss-base/src/basePlugin";
import { calculateSize } from '../../scrypted-events-recorder/src/util';
import { VideoSearchResult, VideoSearchTime, VideoSearchType } from "../../scrypted-reolink-videoclips/src/client";
import { getFolderPaths, parseVideoclipName, splitDateRangeByDay } from "../../scrypted-reolink-videoclips/src/utils";
import { ReolinkCamera } from "./camera";
import { pluginId } from "./main";
import ReolinkVideoclips from "./videoclips";
const { endpointManager } = sdk;
interface VideoclipSrcFtpData {
filename: string;
fullPath: string;
size: number;
timestamp: number;
}
const videoclippathRegex = new RegExp('(.*)([0-9]{4})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})([0-9]{2})(.*)');
export default class ReolinkVideoclipssMixin extends SettingsMixinDeviceBase<any> implements Settings, VideoClips {
killed: boolean;
ftpScanTimeout: NodeJS.Timeout;
parseVideoclipsTimeout: NodeJS.Timeout;
logger: Console;
camera: ReolinkCamera;
parsingVideoclips = false;
videoErrorCount: Map<string, number> = new Map();
ffmpegGenerationTimeout: Map<string, NodeJS.Timeout> = new Map();
blacklistedVideos: Set<string> = new Set();
storageSettings = new StorageSettings(this, {
ftp: {
title: 'Fetch from FTP folder',
type: 'boolean',
immediate: true,
defaultValue: true,
},
ftpFolder: {
title: 'FTP folder',
description: 'FTP folder where reolink stores the clips',
type: 'string',
},
filenamePrefix: {
title: 'Filename content (leave empty to let plugin find the clips)',
description: 'This should contain any relevant text to identify the camera clips. I.e. Videocamera dispensa_00_20250105123640.mp4 -> Videocamera dispensa_00_',
type: 'string',
},
maxSpaceInGb: {
title: 'Dedicated memory in GB',
type: 'number',
defaultValue: 20,
onPut: async (_, newValue) => await this.scanMemoryUsage(newValue)
},
occupiedSpaceInGb: {
title: 'Memory occupancy in GB',
type: 'number',
range: [0, 20],
readonly: true,
placeholder: 'GB'
},
});
constructor(options: SettingsMixinDeviceOptions<any>, private plugin: ReolinkVideoclips) {
super(options);
this.plugin.currentMixinsMap[this.id] = this;
this.camera = this.plugin.plugin.devices.get(this.nativeId);
setTimeout(() => this.checkFtpScan().catch(this.getLogger()?.log), 3000);
}
public getLogger() {
// return this.camera?.getLogger();
return getBaseLogger({
console: this.console,
storage: this.camera.storageSettings,
});
}
async release() {
this.killed = true;
this.stopVideoclipsParser();
this.stopFtpScan();
}
async checkFtpScan() {
if (!this.killed) {
const { ftp, ftpFolder } = this.storageSettings.values;
const logger = this.getLogger();
if (ftp && ftpFolder) {
!this.ftpScanTimeout && logger.log(`FTP folder scan interval started`);
await this.startFtpScan();
!this.parseVideoclipsTimeout && logger.log(`FTP videoclips parser started`);
await this.startVideoclipsParser();
} else {
this.ftpScanTimeout && logger.log(`FTP folder scan interval stopped`);
this.stopFtpScan();
this.parseVideoclipsTimeout && logger.log(`FTP videoclips parser stopped`);
await this.stopVideoclipsParser();
}
}
}
stopFtpScan() {
if (this.ftpScanTimeout) {
clearInterval(this.ftpScanTimeout);
}
this.ftpScanTimeout = undefined;
}
get fileNamePrefix() {
const { filenamePrefix } = this.storageSettings.values;
if (filenamePrefix) {
return filenamePrefix;
}
let channel = String(this.camera.getRtspChannel());
if (channel.length === 1) {
channel = `0${channel}`;
}
return `_${channel}_`;
}
/**
* Tracks video processing errors and blacklists videos that fail more than 2 times
* @param videoPath The path of the video that failed
* @returns true if the video should be blacklisted, false otherwise
*/
private handleVideoError(videoPath: string): boolean {
const currentCount = this.videoErrorCount.get(videoPath) || 0;
const newCount = currentCount + 1;
this.videoErrorCount.set(videoPath, newCount);
if (newCount > 2) {
this.blacklistedVideos.add(videoPath);
this.getLogger().warn(`Video ${videoPath} blacklisted after ${newCount} failed attempts`);
return true;
}
this.getLogger().debug(`Video ${videoPath} error count: ${newCount}/3`);
return false;
}
async startVideoclipsParser() {
this.stopVideoclipsParser();
const logger = this.getLogger();
const parsedFolder = this.getFtpParsedDirectory();
await fs.promises.mkdir(parsedFolder, { recursive: true });
this.parseVideoclipsTimeout = setInterval(async () => {
if (!this.parsingVideoclips) {
try {
const { ftpFolder } = this.storageSettings.values;
const foundVideos = await this.searchSourceFptFiles(ftpFolder);
if (foundVideos.length) {
const videosToProcess = foundVideos.filter(video => !this.blacklistedVideos.has(video.fullPath));
const blacklistedCount = foundVideos.length - videosToProcess.length;
if (blacklistedCount > 0) {
logger.log(`${blacklistedCount} videos are blacklisted and will be skipped`);
}
if (videosToProcess.length === 0) {
logger.log(`No videos to process (${foundVideos.length} total, ${blacklistedCount} blacklisted)`);
return;
}
logger.log(`${videosToProcess.length} videoclips will be parsed (${blacklistedCount} blacklisted)`);
for (let i = 0; i < videosToProcess.length; i++) {
const video = videosToProcess[i];
const { fullPath, filename } = video;
const remainingCount = videosToProcess.length - i - 1;
try {
this.parsingVideoclips = true;
const duration = await this.getVideoDurationWithFFmpeg(fullPath);
if (duration) {
logger.log(`Duration found for video ${fullPath}: ${duration}`);
let foundThumbnailBuffer = await this.findFtpThumbnail(fullPath);
if (!foundThumbnailBuffer) {
logger.log(`Thumbnail not found for video ${filename}, generating`);
foundThumbnailBuffer = await this.generateThumbnail(fullPath);
logger.log(`Thumbnail for video ${filename} generated`);
}
if (foundThumbnailBuffer) {
const parts = fullPath.split('.')[0].split('_');
const timestampString = parts[parts.length - 1];
const timestampMillis = this.convertTimestampToMillis(timestampString);
const newVideoName = `${timestampMillis}_${Math.round(duration)}.mp4`;
const newThumbnailName = `${timestampMillis}.jpeg`;
const newVideoPath = path.join(parsedFolder, newVideoName);
const newThumbnailPath = path.join(parsedFolder, newThumbnailName);
await fs.promises.copyFile(fullPath, newVideoPath);
await fs.promises.rm(fullPath, { force: true, maxRetries: 10 });
await fs.promises.writeFile(newThumbnailPath, foundThumbnailBuffer);
logger.log(`Successfully processed video: ${filename} -> ${newVideoName} (${duration}s)`);
logger.log(`Successfully processed thumbnail: ${foundThumbnailBuffer.length} bytes -> ${newThumbnailName}`);
this.videoErrorCount.delete(fullPath)
} else {
logger.warn(`No thumbnail found for video: ${filename}`);
// Handle error for missing thumbnail
const shouldBlacklist = this.handleVideoError(fullPath);
if (shouldBlacklist) {
logger.warn(`Video ${filename} will be skipped in future processing`);
}
}
} else {
logger.warn(`Could not extract duration for video: ${filename}`);
// Handle error for duration extraction failure
const shouldBlacklist = this.handleVideoError(fullPath);
if (shouldBlacklist) {
logger.warn(`Video ${filename} will be skipped in future processing`);
}
}
} catch (e) {
logger.error(`Error during parsing video ${fullPath}:`, e);
// Handle general processing error
const shouldBlacklist = this.handleVideoError(fullPath);
if (shouldBlacklist) {
logger.warn(`Video ${filename} will be skipped in future processing`);
}
} finally {
logger.log(`Remaining videoclips to process: ${remainingCount}`);
await sleep(2000);
}
}
}
} catch (e) {
logger.log('Error in scanning the ftp folder', e);
} finally {
this.parsingVideoclips = false;
}
}
}, 1000 * 10);
}
async stopVideoclipsParser() {
if (this.parseVideoclipsTimeout) {
clearInterval(this.parseVideoclipsTimeout);
}
this.parseVideoclipsTimeout = undefined;
}
async searchSourceFptFiles(dir: string, currentResult: VideoclipSrcFtpData[] = []) {
const filenamePrefix = this.fileNamePrefix;
const logger = this.getLogger();
const result: VideoclipSrcFtpData[] = [...currentResult];
const files = await fs.promises.readdir(dir) || [];
const filteredFiles = files.filter(file =>
(filenamePrefix ? file.includes(filenamePrefix) : true) &&
file.endsWith('mp4')
);
logger.debug(`Files found: ${JSON.stringify({ files, filteredFiles })}`);
for (const file of filteredFiles) {
const fullPath = path.join(dir, file);
const fileStat = fs.statSync(fullPath);
if (fileStat.isDirectory()) {
result.push(...(await this.searchSourceFptFiles(fullPath, result)));
} else {
let timestamp = file;
if (filenamePrefix) {
const splitted = file.split(filenamePrefix);
timestamp = splitted[1];
}
logger.debug(`Parsing filename: ${JSON.stringify({
file,
timestamp,
videoclippathRegex
})}`);
try {
const parts = fullPath.split('.')[0].split('_');
const timestampString = parts[parts.length - 1];
const timestamp = this.convertTimestampToMillis(timestampString);
result.push({
filename: file,
fullPath,
timestamp,
size: fileStat.size
});
} catch (e) {
logger.log(`Error parsing file ${file} in path ${dir}`);
}
}
}
return result;
}
async startFtpScan() {
const logger = this.getLogger();
this.stopFtpScan();
this.ftpScanTimeout = setInterval(async () => {
try {
if (this.storageSettings.values.ftp) {
await this.scanMemoryUsage();
}
}
catch (e) {
logger.log('Error in scanning the ftp folder', e);
}
}, 1000 * 60 * 60);
await this.scanMemoryUsage();
}
async scanMemoryUsage(newMaxMemory?: number) {
const logger = this.getLogger();
logger.log(`FS memory scan initialized`);
const { ftpFolder } = this.storageSettings.values;
const { maxSpaceInGb: maxSpaceInGbSrc } = this.storageSettings.values;
const maxSpaceInGb = newMaxMemory ?? maxSpaceInGbSrc;
const filenamePrefix = this.fileNamePrefix;
const { occupiedSpaceInGb, occupiedSpaceInGbNumber, freeMemory } = await calculateSize({
currentPath: ftpFolder,
filenamePrefix,
maxSpaceInGb
});
this.storageSettings.settings.occupiedSpaceInGb.range = [0, maxSpaceInGb]
this.putMixinSetting('occupiedSpaceInGb', occupiedSpaceInGb);
logger.debug(`Occupied space: ${occupiedSpaceInGb} GB`);
const cleanupMemoryThresholderInGb = maxSpaceInGb * 0.05;
if (freeMemory <= cleanupMemoryThresholderInGb) {
const parsedFolder = this.getFtpParsedDirectory();
const files = await fs.promises.readdir(parsedFolder);
const fileDetails = files
.map((file) => {
if (file.endsWith('.mp4')) {
try {
const [timestampString] = file.split('.')[0].split('_');
const timestamp = Number(timestampString);
const videoClipPath = path.join(parsedFolder, file);
const thumbnailPath = path.join(parsedFolder, `${timestampString}.jpeg`);
return {
file,
fullPath: videoClipPath,
thumbnailPath,
timeStart: timestamp
};
} catch (e) {
logger.warn(`Error parsing file ${file} for cleanup`);
return null;
}
}
return null;
})
.filter(Boolean);
fileDetails.sort((a, b) => a.timeStart - b.timeStart);
const clipsToCleanup = Math.floor(fileDetails.length * 0.1);
const filesToDelete = Math.min(fileDetails.length, clipsToCleanup);
logger.log(`Deleting ${filesToDelete} oldest files... ${JSON.stringify({ freeMemory, cleanupMemoryThresholderInGb })}`);
for (let i = 0; i < filesToDelete; i++) {
const { fullPath, file, thumbnailPath } = fileDetails[i];
try {
await fs.promises.rm(fullPath, { force: true, maxRetries: 10 });
logger.log(`Deleted videoclip: ${file}`);
if (fs.existsSync(thumbnailPath)) {
await fs.promises.rm(thumbnailPath, { force: true, maxRetries: 10 });
logger.log(`Deleted thumbnail: ${path.basename(thumbnailPath)}`);
}
} catch (e) {
logger.error(`Error deleting files: ${file}`, e);
}
}
}
logger.log(`FS scan executed: ${JSON.stringify({
freeMemory,
occupiedSpaceInGbNumber,
maxSpaceInGb,
cleanupMemoryThresholderInGb
})}`);
}
async getClient() {
return this.camera.getClient();
}
async getVideoclipWebhookUrls(videoclipPath: string) {
const cloudEndpoint = await endpointManager.getCloudEndpoint(undefined, { public: true });
const [endpoint, parameters] = cloudEndpoint.split('?') ?? '';
const params = {
nativeId: this.nativeId,
deviceId: this.id,
videoclipPath,
};
const videoclipUrl = `${endpoint}videoclip?params=${JSON.stringify(params)}&${parameters}`;
const thumbnailUrl = `${endpoint}thumbnail?params=${JSON.stringify(params)}&${parameters}`;
return { videoclipUrl, thumbnailUrl };
}
private processDate(date: VideoSearchTime) {
let timeDate = new Date();
timeDate.setFullYear(date.year);
timeDate.setMonth(date.mon - 1);
timeDate.setDate(date.day);
timeDate.setHours(date.hour);
timeDate.setMinutes(date.min);
timeDate.setSeconds(date.sec);
return timeDate.getTime();
}
private convertTimestampToMillis(timestampString: string): number {
const year = parseInt(timestampString.substring(0, 4), 10);
const month = parseInt(timestampString.substring(4, 6), 10);
const day = parseInt(timestampString.substring(6, 8), 10);
const hour = parseInt(timestampString.substring(8, 10), 10);
const min = parseInt(timestampString.substring(10, 12), 10);
const sec = parseInt(timestampString.substring(12, 14), 10);
const date = new Date(year, month - 1, day, hour, min, sec);
return date.getTime();
}
clearFfmpegTimeout(videoPath: string) {
const timeout = this.ffmpegGenerationTimeout.get(videoPath);
if (timeout) {
clearTimeout(timeout);
this.ffmpegGenerationTimeout.delete(videoPath);
}
}
async getVideoDurationWithFFmpeg(videoPath: string): Promise<number | null> {
const logger = this.getLogger();
return new Promise(async (resolve, reject) => {
try {
await fs.promises.access(videoPath);
const ffmpeg = spawn(this.plugin.ffmpegPath, [
'-i', videoPath,
'-f', 'null',
'-'
], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stderr = '';
ffmpeg.stderr.on('data', (data) => {
stderr += data.toString();
});
ffmpeg.on('close', () => {
try {
const durationMatch = stderr.match(/Duration: (\d{2}):(\d{2}):(\d{2})\.(\d{2})/);
if (durationMatch) {
const hours = parseInt(durationMatch[1], 10);
const minutes = parseInt(durationMatch[2], 10);
const seconds = parseInt(durationMatch[3], 10);
const centiseconds = parseInt(durationMatch[4], 10);
const durationSeconds = (hours * 3600 + minutes * 60 + seconds) + centiseconds / 100;
logger.debug(`Video duration ${videoPath}: ${durationSeconds}s`);
this.clearFfmpegTimeout(videoPath);
resolve(durationSeconds);
} else {
logger.error(`Unable to extract duration from video: ${videoPath}`, stderr);
this.clearFfmpegTimeout(videoPath);
resolve(null);
}
} catch (error) {
logger.error(`Error parsing duration for ${videoPath}:`, error);
this.clearFfmpegTimeout(videoPath);
reject(error);
}
});
ffmpeg.on('error', (error) => {
logger.error(`Error executing FFmpeg for ${videoPath}:`, error);
this.clearFfmpegTimeout(videoPath);
reject(error);
});
const timeout = setTimeout(() => {
ffmpeg.kill();
logger.error(`FFmpeg timeout for ${videoPath}`);
this.clearFfmpegTimeout(videoPath);
reject('timeout');
}, 15000);
this.ffmpegGenerationTimeout.set(videoPath, timeout);
} catch {
logger.error(`Videoclip not found: ${videoPath}`);
reject('not found');
}
});
}
async getVideoClips(options?: VideoClipOptions, streamType: VideoSearchType = 'main') {
const logger = this.getLogger();
try {
const { ftp } = this.storageSettings.values;
const parsedFolder = this.getFtpParsedDirectory();
const videoclips: VideoClip[] = [];
if (ftp) {
const files = await fs.promises.readdir(parsedFolder) || [];
const filtered = files.filter(file => file.endsWith('.mp4'));
for (const item of filtered) {
const [timestampString, duration] = item.split('.')[0].split('_');
const timestamp = Number(timestampString);
const videoclipPath = path.join(parsedFolder, item);
const event = 'motion';
const { thumbnailUrl, videoclipUrl } = await this.getVideoclipWebhookUrls(videoclipPath);
if (timestamp >= options.startTime && timestamp <= options.endTime) {
videoclips.push({
id: videoclipPath,
startTime: timestamp,
duration: Number(duration) * 1000,
videoId: videoclipPath,
thumbnailId: videoclipPath,
detectionClasses: [event],
event,
description: event,
resources: {
thumbnail: {
href: thumbnailUrl
},
video: {
href: videoclipUrl
}
}
});
}
}
} else {
const api = await this.getClient();
const dateRanges = splitDateRangeByDay(options.startTime, options.endTime);
let allSearchedElements: VideoSearchResult[] = [];
for (const dateRange of dateRanges) {
const response = await api.getVideoClips(
this.camera.getRtspChannel(),
{ startTime: dateRange.start, endTime: dateRange.end }
);
allSearchedElements.push(...response);
}
for (const searchElement of allSearchedElements) {
const videoclipPath = searchElement.name;
try {
const startTime = this.processDate(searchElement.StartTime);
const entdTime = this.processDate(searchElement.EndTime);
const durationInMs = entdTime - startTime;
const { detectionClasses } = parseVideoclipName(videoclipPath, this.console) ?? {};
const event = 'motion';
const { thumbnailUrl, videoclipUrl } = await this.getVideoclipWebhookUrls(videoclipPath);
videoclips.push({
id: videoclipPath,
startTime,
duration: Math.round(durationInMs),
videoId: videoclipPath,
thumbnailId: videoclipPath,
detectionClasses: detectionClasses ?? [event],
event,
description: pluginId,
resources: {
thumbnail: {
href: thumbnailUrl
},
video: {
href: videoclipUrl
}
}
});
} catch (e) {
logger.log(`error parsing videoclip ${videoclipPath}: ${JSON.stringify(searchElement)}`, e);
}
}
}
logger.info(`Videoclips found:`, JSON.stringify({
videoclips,
}));
return videoclips;
} catch (e) {
logger.log('Error during get videoClips', e);
}
}
async getVideoClip(videoId: string): Promise<MediaObject> {
const logger = this.getLogger();
logger.log('Fetching videoId ', videoId);
const { videoclipUrl } = await this.getVideoclipWebhookUrls(videoId);
const videoclipMo = await sdk.mediaManager.createMediaObject(videoclipUrl, 'video/mp4');
return videoclipMo;
}
async generateThumbnail(videoclipPath: string) {
const mo = await sdk.mediaManager.createFFmpegMediaObject({
inputArguments: [
'-ss', '00:00:05',
'-i', videoclipPath,
],
});
const jpeg = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
return jpeg;
}
async findFtpThumbnail(videoclipPath: string): Promise<Buffer | null> {
const parts = videoclipPath.split('.')[0].split('_');
const timestamp = Number(parts[parts.length - 1]);
const fileNamePrefix = this.fileNamePrefix;
const dir = path.dirname(videoclipPath);
const logger = this.getLogger();
let sameTimestamp: string;
try {
const jpgCandidates = fs.readdirSync(dir)
.filter(file => {
const m = file.endsWith('.jpg') && file.includes(fileNamePrefix);
if (!m) return false;
const partsInner = file.split('.')[0].split('_');
const timestampInner = Number(partsInner[partsInner.length - 1]);
if (timestamp === timestampInner) {
sameTimestamp = file;
return true;
}
const diff = Math.abs(timestampInner - timestamp);
return diff <= 200;
});
const jpgNearby = sameTimestamp ?? jpgCandidates[0];
if (jpgNearby) {
const jpegPath = path.join(this.storageSettings.values.ftpFolder, jpgNearby);
const jpegBuffer = await fs.promises.readFile(jpegPath);
if (jpegBuffer.length > 0) {
logger.debug(`Found thumbnail buffer for video ${videoclipPath}: ${jpegBuffer.length} bytes`);
return jpegBuffer;
} else {
logger.warn(`Thumbnail file is empty: ${jpegPath}`);
return null;
}
} else {
logger.debug(`No thumbnail found for video: ${videoclipPath}`);
return null;
}
} catch (error) {
logger.error(`Error reading thumbnail for video ${videoclipPath}:`, error);
return null;
}
}
async getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
const logger = this.getLogger();
const { thumbnailUrl } = await this.getVideoclipParams(thumbnailId);
logger.log(`Fetching thumbnailId ${thumbnailId} from ${thumbnailUrl}`);
try {
const { ftp } = this.storageSettings.values;
if (ftp) {
const fileURLToPath = url.pathToFileURL(thumbnailUrl).toString();
return await sdk.mediaManager.createMediaObjectFromUrl(fileURLToPath);
} else {
if (fs.existsSync(thumbnailUrl)) {
const fileURLToPath = url.pathToFileURL(thumbnailUrl).toString();
return await sdk.mediaManager.createMediaObjectFromUrl(fileURLToPath);
} else {
logger.log(`Thumbnail ${thumbnailUrl} not found, adding to generation queue.`);
this.plugin.thumbnailsToGenerate.push({ thumbnailId, deviceId: this.id });
return null;
}
}
} catch (e) {
logger.error(`Error retrieving thumbnail of videoclip ${thumbnailId}`, e);
return null;
}
}
getFtpParsedDirectory() {
const { ftpFolder } = this.storageSettings.values;
return path.join(ftpFolder, 'parsed', this.id);
}
async getVideoclipParams(videoclipId?: string) {
const { ftp, ftpFolder } = this.storageSettings.values;
const { thumbnailFolder } = getFolderPaths(this.id, this.camera.plugin.storageSettings.values.downloadFolder);
let videoclipUrl: string;
let thumbnailUrl: string;
if (ftp) {
videoclipUrl = videoclipId;
const parsedFolder = path.join(ftpFolder, 'parsed', this.id);
if (videoclipId) {
const [timestamp] = videoclipId.split('/').pop().split('.')[0].split('_');
thumbnailUrl = path.join(parsedFolder, `${timestamp}.jpeg`);
}
} else {
const api = await this.getClient();
if (videoclipId) {
const { downloadPathWithHost } = await api.getVideoClipUrl(
videoclipId,
this.camera.getRtspChannel(),
);
videoclipUrl = downloadPathWithHost;
const filename = `${videoclipId.split('/').pop().split('.')[0]}`;
const parsedFilename = filename.replaceAll(' ', '_');
thumbnailUrl = path.join(thumbnailFolder, `${parsedFilename}.jpeg`);
}
}
return { videoclipUrl, thumbnailFolder, thumbnailUrl };
}
removeVideoClips(...videoClipIds: string[]): Promise<void> {
throw new Error("Method not implemented.");
}
async getMixinSettings(): Promise<Setting[]> {
const isFtp = this.storageSettings.values.ftp;
this.storageSettings.settings.filenamePrefix.hide = !isFtp;
this.storageSettings.settings.ftpFolder.hide = !isFtp;
this.storageSettings.settings.maxSpaceInGb.hide = !isFtp;
this.storageSettings.settings.occupiedSpaceInGb.hide = !isFtp;
const settings = await this.storageSettings.getSettings();
return settings;
}
async putMixinSetting(key: string, value: string) {
this.storage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
}
}