mirror of
https://codeberg.org/PLG-Development/PLG-MuDiCS
synced 2026-07-06 17:07:08 +00:00
521 lines
16 KiB
TypeScript
521 lines
16 KiB
TypeScript
import { get, writable, type Writable } from 'svelte/store';
|
||
import { db } from './database';
|
||
import { get_display_by_id } from './stores/displays';
|
||
import {
|
||
create_path_on_all_selected_displays,
|
||
get_folder_elements,
|
||
get_file_by_id,
|
||
remove_all_files_without_display,
|
||
remove_file_from_display_recusively
|
||
} from './stores/files';
|
||
import { notifications } from './stores/notification';
|
||
import { generate_thumbnail } from './stores/thumbnails';
|
||
import {
|
||
get_file_primary_key,
|
||
is_folder,
|
||
type FileLoadingData,
|
||
type FileOnDisplay,
|
||
type FileTransferTask,
|
||
type Inode,
|
||
type ShortDisplay
|
||
} from './types';
|
||
import { get_sanitized_file_url, get_uuid, make_valid_name } from './utils';
|
||
|
||
const START_LOADING_DATA = {
|
||
percentage: 0,
|
||
bytes_per_second: 0,
|
||
seconds_until_finish: -1
|
||
};
|
||
|
||
export const file_transfer_tasks: Writable<Record<string, FileTransferTask>> = writable<
|
||
Record<string, FileTransferTask>
|
||
>({});
|
||
|
||
let is_processing: boolean = false;
|
||
|
||
export async function add_upload(
|
||
file_list: FileList,
|
||
selected_display_ids: string[],
|
||
current_file_path: string
|
||
) {
|
||
if (file_list.length === 0) return console.warn('Upload canceled: no selected files');
|
||
await create_path_on_all_selected_displays(current_file_path, selected_display_ids);
|
||
|
||
const used_file_names: string[] = await (
|
||
await get_folder_elements(current_file_path, selected_display_ids)
|
||
).map((e) => e.name);
|
||
|
||
for (const file of file_list) {
|
||
const file_name = generate_valid_file_name(file.name, used_file_names);
|
||
used_file_names.push(file_name);
|
||
|
||
const db_file: Inode = {
|
||
path: current_file_path,
|
||
name: file_name,
|
||
size: file.size,
|
||
type: file.type,
|
||
thumbnail: null
|
||
};
|
||
const file_primary_key = get_file_primary_key(db_file);
|
||
|
||
if (Object.prototype.hasOwnProperty.call(file_transfer_tasks, file_primary_key))
|
||
return show_already_in_tasks_error(db_file, 'upload'); // file is already in task
|
||
|
||
await db.files.put(db_file);
|
||
|
||
const upload_task_promises = selected_display_ids.map(async (display_id) => {
|
||
const display_ip = (await get_display_by_id(display_id))?.ip;
|
||
if (!display_ip) return;
|
||
|
||
const db_file_on_display: FileOnDisplay = {
|
||
display_id,
|
||
file_primary_key,
|
||
date_created: new Date()
|
||
};
|
||
await db.files_on_display.put(db_file_on_display);
|
||
|
||
const new_task = {
|
||
data: {
|
||
type: 'upload' as const,
|
||
file
|
||
},
|
||
display: {
|
||
id: display_id,
|
||
ip: display_ip
|
||
},
|
||
path: current_file_path,
|
||
file_name: file_name,
|
||
loading_data: START_LOADING_DATA,
|
||
bytes_total: file.size
|
||
};
|
||
file_transfer_tasks.update((tasks) => ({
|
||
...tasks,
|
||
[file_primary_key]: new_task
|
||
}));
|
||
});
|
||
|
||
await Promise.all(upload_task_promises);
|
||
}
|
||
|
||
await start_task_processing();
|
||
}
|
||
|
||
export async function add_sync_recursively(
|
||
selected_file_id: string,
|
||
selected_display_ids: string[]
|
||
) {
|
||
const file_data = await find_file_data_on_active_selected_display(
|
||
selected_file_id,
|
||
selected_display_ids
|
||
);
|
||
if (!file_data) return console.warn('Sync canceled: no file_data');
|
||
|
||
if (is_folder(file_data.file)) {
|
||
const new_path = file_data.file.path + file_data.file.name + '/';
|
||
const elements_in_folder = await get_folder_elements(new_path, selected_display_ids);
|
||
if (elements_in_folder.length === 0) {
|
||
await create_path_on_all_selected_displays(new_path, selected_display_ids);
|
||
} else {
|
||
for (const el of elements_in_folder) {
|
||
await add_sync_recursively(get_file_primary_key(el), selected_display_ids);
|
||
}
|
||
}
|
||
return; // cannot sync folder
|
||
}
|
||
|
||
if (file_data.short_displays_without_file.length === 0) return; // file is present on all selected displays
|
||
if (Object.prototype.hasOwnProperty.call(file_transfer_tasks, selected_file_id))
|
||
return show_already_in_tasks_error(file_data.file, 'sync'); // file is already in task
|
||
await create_path_on_all_selected_displays(file_data.file.path, selected_display_ids);
|
||
|
||
const new_task: FileTransferTask = {
|
||
data: {
|
||
type: 'sync',
|
||
destination_display_data: file_data.short_displays_without_file.map((display) => ({
|
||
display,
|
||
loading_data: START_LOADING_DATA
|
||
}))
|
||
},
|
||
display: file_data.short_display_with_file,
|
||
path: file_data.file.path,
|
||
file_name: file_data.file.name,
|
||
loading_data: START_LOADING_DATA,
|
||
bytes_total: file_data.file.size
|
||
};
|
||
|
||
file_transfer_tasks.update((tasks) => ({
|
||
...tasks,
|
||
[selected_file_id]: new_task
|
||
}));
|
||
|
||
const display_ids_without_file = file_data.short_displays_without_file.map((d) => d.id);
|
||
const new_fods: FileOnDisplay[] = display_ids_without_file.map((display_id) => ({
|
||
display_id,
|
||
file_primary_key: selected_file_id,
|
||
date_created: new Date()
|
||
}));
|
||
await db.files_on_display.bulkPut(new_fods);
|
||
|
||
await start_task_processing();
|
||
}
|
||
|
||
async function find_file_data_on_active_selected_display(
|
||
selected_file_id: string,
|
||
selected_display_ids: string[]
|
||
): Promise<{
|
||
file_on_display: FileOnDisplay;
|
||
file: Inode;
|
||
short_display_with_file: ShortDisplay;
|
||
short_displays_without_file: ShortDisplay[];
|
||
} | null> {
|
||
const active_selected_displays = await db.displays
|
||
.where('id')
|
||
.anyOf(selected_display_ids)
|
||
.filter((d) => d.status === 'app_online')
|
||
.toArray();
|
||
const active_selected_display_ids = active_selected_displays.map((d) => d.id);
|
||
|
||
const fods = await db.files_on_display
|
||
.where('file_primary_key')
|
||
.equals(selected_file_id)
|
||
.filter((e) => active_selected_display_ids.includes(e.display_id))
|
||
.toArray();
|
||
if (fods.length < 0) return null;
|
||
const fod_with_file = fods[0];
|
||
const display_ids_with_file = fods.map((f) => f.display_id);
|
||
|
||
const ip = active_selected_displays.find((e) => e.id === fod_with_file.display_id)?.ip;
|
||
if (!ip) return null;
|
||
|
||
const short_displays_without_file: ShortDisplay[] = selected_display_ids
|
||
.filter((d) => !display_ids_with_file.includes(d))
|
||
.map((id) => {
|
||
const ip = active_selected_displays.find((d) => d.id === id)?.ip;
|
||
if (!ip) return null;
|
||
return { id, ip };
|
||
})
|
||
.filter((sd) => !!sd);
|
||
|
||
const inode = await get_file_by_id(selected_file_id);
|
||
if (!inode) return null;
|
||
|
||
return {
|
||
file_on_display: fod_with_file,
|
||
file: inode,
|
||
short_display_with_file: { id: fod_with_file.display_id, ip },
|
||
short_displays_without_file
|
||
};
|
||
}
|
||
|
||
function generate_valid_file_name(original_file_name: string, used_file_names: string[]): string {
|
||
const regex = /\s\((\d{1,3})\)$/;
|
||
|
||
let name: string = make_valid_name(original_file_name);
|
||
while (used_file_names.includes(name)) {
|
||
const last_dot = name.lastIndexOf('.');
|
||
let name_without_extension = last_dot > 0 ? name.slice(0, last_dot) : name;
|
||
const extension = last_dot > 0 ? name.slice(last_dot) : '';
|
||
if (!regex.test(name_without_extension)) {
|
||
name_without_extension += ' (1)';
|
||
} else {
|
||
const match = name_without_extension.match(regex);
|
||
const current_number: number = match ? Number(match[1]) : 0;
|
||
name_without_extension = name_without_extension.replace(regex, ` (${current_number + 1})`);
|
||
}
|
||
name = name_without_extension + extension;
|
||
}
|
||
return name;
|
||
}
|
||
|
||
async function upload(file_primary_key: string, task: FileTransferTask): Promise<void> {
|
||
const task_data = task.data;
|
||
if (task_data.type !== 'upload' || !task_data.file)
|
||
return console.warn('Task cancelled: wrong task type:', task);
|
||
|
||
await upload_file_via_xhr(file_primary_key, task, task_data.file);
|
||
}
|
||
|
||
export async function sync(file_primary_key: string, task: FileTransferTask) {
|
||
if (task.data.type !== 'sync') return console.warn('Task cancelled: wrong task type:', task);
|
||
|
||
const hasOPFS =
|
||
typeof navigator !== 'undefined' &&
|
||
'storage' in navigator &&
|
||
'getDirectory' in navigator.storage;
|
||
if (!hasOPFS) {
|
||
return show_general_error(
|
||
file_primary_key,
|
||
task,
|
||
'OPFS (navigator.storage.getDirectory) nicht verfügbar – bitte Chromium/Edge/Chrome nutzen.'
|
||
);
|
||
}
|
||
|
||
const temp_name = `sync-${get_uuid()}.tmp`;
|
||
|
||
try {
|
||
// 01 - download file to disk (in browser, not for user)
|
||
const url = `http://${task.display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`;
|
||
const fetch_source = await fetch(url, { method: 'GET' });
|
||
if (!fetch_source.ok || !fetch_source.body)
|
||
return show_general_error(file_primary_key, task, `HTTP ${fetch_source.status}`);
|
||
|
||
const dir = await navigator.storage.getDirectory();
|
||
const file_handle = await dir.getFileHandle(temp_name, { create: true });
|
||
const writable = await file_handle.createWritable();
|
||
const reader = fetch_source.body.getReader();
|
||
|
||
const start_time = new Date();
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
if (!value) continue;
|
||
|
||
update_current_loading_data(file_primary_key, value.byteLength, start_time);
|
||
await writable.write(value);
|
||
}
|
||
await writable.close();
|
||
|
||
finish_loading_data(file_primary_key);
|
||
|
||
// 02 - send downloaded file to every destination_display
|
||
const temp_file = await file_handle.getFile();
|
||
|
||
for (const current_short_display of task.data.destination_display_data) {
|
||
await upload_file_via_xhr(file_primary_key, task, temp_file, current_short_display.display);
|
||
}
|
||
|
||
await dir.removeEntry(temp_name);
|
||
} catch (e) {
|
||
show_general_error(file_primary_key, task, String(e));
|
||
}
|
||
}
|
||
|
||
export async function download_file(selected_file_id: string, selected_display_ids: string[]) {
|
||
const file_data = await find_file_data_on_active_selected_display(
|
||
selected_file_id,
|
||
selected_display_ids
|
||
);
|
||
if (!file_data || is_folder(file_data.file))
|
||
return console.warn('Download cancelled: is folder');
|
||
|
||
try {
|
||
const url = `http://${file_data.short_display_with_file.ip}:1323/api${get_sanitized_file_url(file_data.file.path + file_data.file.name)}`;
|
||
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = file_data.file.name; // Hint für den Browser (Server-Header ist trotzdem besser)
|
||
a.rel = 'noopener';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
} catch (e) {
|
||
notifications.push(
|
||
'error',
|
||
'Fehler beim Dateidownload',
|
||
`Datei: "${file_data.file.name}", Display-IP: ${file_data.short_display_with_file.ip}\nFehler: ${e}`
|
||
);
|
||
}
|
||
}
|
||
|
||
async function start_task_processing() {
|
||
if (!is_processing) {
|
||
is_processing = Object.keys(get(file_transfer_tasks)).length !== 0;
|
||
await start_task_loop();
|
||
}
|
||
}
|
||
|
||
async function start_task_loop() {
|
||
while (Object.keys(get(file_transfer_tasks)).length > 0) {
|
||
const tasks = get(file_transfer_tasks);
|
||
const current_file_id = Object.keys(tasks)[0];
|
||
const current_task = tasks[current_file_id];
|
||
if (current_task.data.type === 'upload') {
|
||
await upload(current_file_id, current_task);
|
||
} else if (current_task.data.type === 'sync') {
|
||
await sync(current_file_id, current_task);
|
||
}
|
||
|
||
file_transfer_tasks.update((all_tasks) => {
|
||
const next = { ...all_tasks };
|
||
delete next[current_file_id];
|
||
return next;
|
||
});
|
||
}
|
||
is_processing = false;
|
||
}
|
||
|
||
async function upload_file_via_xhr(
|
||
file_primary_key: string,
|
||
task: FileTransferTask,
|
||
current_file: File,
|
||
destination_short_display: ShortDisplay | null = null
|
||
) {
|
||
const start_time = new Date();
|
||
|
||
return new Promise<void>((resolve) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open(
|
||
'POST',
|
||
`http://${destination_short_display ? destination_short_display.ip : task.display.ip}:1323/api${get_sanitized_file_url(task.path + task.file_name)}`,
|
||
true
|
||
);
|
||
xhr.setRequestHeader('content-type', 'application/octet-stream');
|
||
|
||
xhr.upload.onprogress = (e) => {
|
||
const apply = async () => {
|
||
update_current_loading_data(
|
||
file_primary_key,
|
||
e.loaded,
|
||
start_time,
|
||
destination_short_display ? destination_short_display.id : null
|
||
);
|
||
};
|
||
apply();
|
||
};
|
||
|
||
xhr.onerror = async (e: ProgressEvent) => {
|
||
await show_general_error(file_primary_key, task, e);
|
||
resolve();
|
||
};
|
||
|
||
xhr.onreadystatechange = async () => {
|
||
if (xhr.readyState === 4) {
|
||
if (xhr.status == 200) {
|
||
// set loading_data to 100%
|
||
finish_loading_data(
|
||
file_primary_key,
|
||
destination_short_display ? destination_short_display.id : null
|
||
);
|
||
// Generate Thumbnail if not done already
|
||
setTimeout(async () => {
|
||
const inode_element: Inode | undefined = await db.files.get(
|
||
JSON.parse(file_primary_key) as [string, string, number, string]
|
||
);
|
||
if (!!inode_element && inode_element.thumbnail === null) {
|
||
await generate_thumbnail(task.display.ip, task.path, inode_element);
|
||
}
|
||
}, 10);
|
||
} else {
|
||
await show_general_error(file_primary_key, task, `HTTP ${xhr.status}`);
|
||
}
|
||
resolve();
|
||
}
|
||
};
|
||
|
||
xhr.send(current_file);
|
||
});
|
||
}
|
||
|
||
function finish_loading_data(
|
||
file_primary_key: string,
|
||
destination_display_id: string | null = null
|
||
) {
|
||
file_transfer_tasks.update((tasks) => {
|
||
const task = tasks[file_primary_key];
|
||
const current_loading_data = {
|
||
percentage: 100,
|
||
bytes_per_second: 0,
|
||
seconds_until_finish: 0
|
||
};
|
||
|
||
return {
|
||
...tasks,
|
||
[file_primary_key]: get_updated_task(current_loading_data, destination_display_id, task)
|
||
};
|
||
});
|
||
}
|
||
|
||
function update_current_loading_data(
|
||
file_primary_key: string,
|
||
current_bytes: number,
|
||
start_time: Date,
|
||
destination_display_id: string | null = null
|
||
) {
|
||
file_transfer_tasks.update((tasks) => {
|
||
const task = tasks[file_primary_key];
|
||
if (!task) return tasks;
|
||
|
||
const current_percentage = Math.min(
|
||
task.bytes_total > 0 ? Math.round((current_bytes / task.bytes_total) * 100) : 1,
|
||
99
|
||
);
|
||
|
||
const prognosed_data = get_prognosed_data(start_time, current_bytes, task.bytes_total);
|
||
|
||
const current_loading_data: FileLoadingData = {
|
||
percentage: current_percentage,
|
||
bytes_per_second: prognosed_data.bytes_per_second,
|
||
seconds_until_finish: prognosed_data.seconds_until_finish
|
||
};
|
||
|
||
return {
|
||
...tasks,
|
||
[file_primary_key]: get_updated_task(current_loading_data, destination_display_id, task)
|
||
};
|
||
});
|
||
}
|
||
|
||
function get_updated_task(current_loading_data: FileLoadingData, destination_display_id: string | null, task: FileTransferTask): FileTransferTask {
|
||
if (destination_display_id && task.data.type === 'sync') {
|
||
const updatedDestinations = task.data.destination_display_data.map((dd) =>
|
||
dd.display.id === destination_display_id ? { ...dd, loading_data: current_loading_data } : dd
|
||
);
|
||
|
||
return {
|
||
...task,
|
||
data: {
|
||
...task.data,
|
||
destination_display_data: updatedDestinations
|
||
}
|
||
};
|
||
} else {
|
||
return {
|
||
...task,
|
||
loading_data: current_loading_data
|
||
};
|
||
}
|
||
}
|
||
|
||
function get_prognosed_data(
|
||
start_time: Date,
|
||
bytes_done: number,
|
||
bytes_total: number
|
||
): { bytes_per_second: number; seconds_until_finish: number } {
|
||
const diff_seconds = (Date.now() - start_time.getTime()) / 1000;
|
||
const bytes_per_second = bytes_done / diff_seconds;
|
||
|
||
const seconds_until_finish = bytes_total / bytes_per_second;
|
||
return { bytes_per_second, seconds_until_finish };
|
||
}
|
||
|
||
async function show_general_error(
|
||
file_primary_key: string,
|
||
task: FileTransferTask,
|
||
error: ProgressEvent | string
|
||
) {
|
||
const task_data = task.data;
|
||
console.error('Error in File-Transfer-Handler:', error, task);
|
||
notifications.push(
|
||
'error',
|
||
`Fehler beim ${task_data.type === 'upload' ? 'Dateiupload' : 'Sychronisieren von Dateien'}`,
|
||
`Datei: "${task.file_name}", Display-IP: ${task.display.ip}\nFehler: ${error}`
|
||
);
|
||
if (task_data.type === 'upload') {
|
||
await remove_file_from_display_recusively(task.display.id, file_primary_key);
|
||
await remove_all_files_without_display();
|
||
} else if (task_data.type === 'sync') {
|
||
for (const display_id of task_data.destination_display_data.map((e) => e.display.id)) {
|
||
await remove_file_from_display_recusively(display_id, file_primary_key);
|
||
}
|
||
await remove_all_files_without_display();
|
||
}
|
||
}
|
||
|
||
function show_already_in_tasks_error(file: Inode, process: 'upload' | 'sync') {
|
||
notifications.push(
|
||
'error',
|
||
`Datei '${file.name}' konnte nicht ${process === 'upload' ? 'hochgeladen' : 'synchronisiert'} werden`,
|
||
`Eine Datei kann nicht in mehreren file_transfer_tasks enthalten sein. Bitte erneut versuchen, wenn die aktuellen Aktionen fertiggestellt wurden`
|
||
);
|
||
}
|