From b86b6f21ace9a05fcf8296e40c4ce35919cf9262 Mon Sep 17 00:00:00 2001 From: trivernis Date: Sat, 12 Feb 2022 13:26:51 +0100 Subject: [PATCH] Move thumbnail loading to background tasks and add retry to image loading Signed-off-by: trivernis --- mediarepo-api/src/client_api/error.rs | 3 ++ .../src/tauri_plugin/custom_schemes.rs | 52 ++++++++++++++----- mediarepo-api/src/tauri_plugin/mod.rs | 25 +++++++++ mediarepo-api/src/tauri_plugin/state.rs | 39 ++++++++++++++ .../content-aware-image.component.html | 5 +- .../content-aware-image.component.ts | 36 +++++++++++-- .../file/file-card/file-card.component.html | 10 ++-- .../file/file-card/file-card.component.scss | 4 ++ .../file/file-card/file-card.component.ts | 4 +- .../file-thumbnail.component.html | 5 +- .../file-thumbnail.component.ts | 10 ++++ 11 files changed, 166 insertions(+), 27 deletions(-) diff --git a/mediarepo-api/src/client_api/error.rs b/mediarepo-api/src/client_api/error.rs index f5e9e05..24b6c55 100644 --- a/mediarepo-api/src/client_api/error.rs +++ b/mediarepo-api/src/client_api/error.rs @@ -10,3 +10,6 @@ pub enum ApiError { #[error("The servers api version (version {server:?}) is incompatible with the api client {client:?}")] VersionMismatch { server: String, client: String }, } + +unsafe impl Send for ApiError {} +unsafe impl Sync for ApiError {} diff --git a/mediarepo-api/src/tauri_plugin/custom_schemes.rs b/mediarepo-api/src/tauri_plugin/custom_schemes.rs index 41e458d..05a3cc5 100644 --- a/mediarepo-api/src/tauri_plugin/custom_schemes.rs +++ b/mediarepo-api/src/tauri_plugin/custom_schemes.rs @@ -1,5 +1,6 @@ +use crate::client_api::ApiClient; use crate::tauri_plugin::error::{PluginError, PluginResult}; -use crate::tauri_plugin::state::{ApiState, BufferState}; +use crate::tauri_plugin::state::{ApiState, AppState, AsyncTask, BufferState}; use crate::types::identifier::FileIdentifier; use std::borrow::Cow; use std::collections::HashMap; @@ -89,6 +90,7 @@ async fn content_scheme(app: &AppHandle, request: &Request) -> Re async fn thumb_scheme(app: &AppHandle, request: &Request) -> Result { let api_state = app.state::(); let buf_state = app.state::(); + let app_state = app.state::(); let url = Url::parse(request.uri())?; let hash = url @@ -116,26 +118,50 @@ async fn thumb_scheme(app: &AppHandle, request: &Request) -> Resu .mimetype(&buffer.mime) .body(buffer.buf) } else { - tracing::debug!("Fetching content from daemon"); + tracing::debug!("Content not loaded. Singnaling retry."); let api = api_state.api().await?; + let buf_state = buf_state.inner().clone(); + + app_state + .add_async_task(build_fetch_thumbnail_task( + buf_state, + api, + hash.to_string(), + request.uri().to_string(), + width, + height, + )) + .await; + + ResponseBuilder::new() + .mimetype("text/plain") + .status(301) + .header("Retry-After", "1") + .body("Content loading. Retry in 1s.".as_bytes().to_vec()) + } +} + +fn build_fetch_thumbnail_task( + buf_state: BufferState, + api: ApiClient, + hash: String, + request_uri: String, + width: u32, + height: u32, +) -> AsyncTask { + AsyncTask::new(async move { + tracing::debug!("Fetching content from daemon"); let (thumb, bytes) = api .file .get_thumbnail_of_size( - FileIdentifier::CD(hash.to_string()), + FileIdentifier::CD(hash), ((height as f32 * 0.5) as u32, (width as f32 * 0.5) as u32), ((height as f32 * 1.5) as u32, (width as f32 * 1.5) as u32), ) .await?; tracing::debug!("Received {} content bytes", bytes.len()); - buf_state.add_entry( - request.uri().to_string(), - thumb.mime_type.clone(), - bytes.clone(), - ); + buf_state.add_entry(request_uri, thumb.mime_type.clone(), bytes.clone()); - ResponseBuilder::new() - .mimetype(&thumb.mime_type) - .status(200) - .body(bytes) - } + Ok(()) + }) } diff --git a/mediarepo-api/src/tauri_plugin/mod.rs b/mediarepo-api/src/tauri_plugin/mod.rs index 7a570a7..4c0c269 100644 --- a/mediarepo-api/src/tauri_plugin/mod.rs +++ b/mediarepo-api/src/tauri_plugin/mod.rs @@ -4,6 +4,8 @@ use tauri::{AppHandle, Builder, Invoke, Manager, Runtime}; use state::ApiState; use crate::tauri_plugin::state::{AppState, BufferState}; +use futures::future; +use std::mem; use std::thread; use std::time::Duration; @@ -97,6 +99,7 @@ impl Plugin for MediarepoPlugin { app.manage(buffer_state.clone()); let repo_state = AppState::load()?; + let background_tasks = repo_state.background_tasks(); app.manage(repo_state); thread::spawn(move || loop { @@ -104,6 +107,28 @@ impl Plugin for MediarepoPlugin { buffer_state.clear_expired(); buffer_state.trim_to_size(MAX_BUFFER_SIZE); }); + thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .thread_name("background_tasks") + .enable_time() + .build() + .expect("failed to build background task runtime") + .block_on(async move { + tracing::debug!("background task listener ready"); + loop { + let tasks = mem::take(&mut *background_tasks.lock().await); + + if tasks.len() > 0 { + tracing::debug!("executing {} async background tasks", tasks.len()); + future::join_all(tasks.into_iter().map(|t| t.exec())).await; + tracing::debug!("background tasks executed"); + } else { + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + }); + tracing::error!("background task executor exited!"); + }); Ok(()) } diff --git a/mediarepo-api/src/tauri_plugin/state.rs b/mediarepo-api/src/tauri_plugin/state.rs index e053325..c63ed9b 100644 --- a/mediarepo-api/src/tauri_plugin/state.rs +++ b/mediarepo-api/src/tauri_plugin/state.rs @@ -1,12 +1,16 @@ use std::collections::HashMap; +use std::fmt::{Debug, Formatter}; +use std::future::Future; use std::mem; use std::ops::Deref; +use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use parking_lot::Mutex; use parking_lot::RwLock as ParkingRwLock; use tauri::async_runtime::RwLock; +use tokio::sync::Mutex as TokioMutex; use tokio::time::Instant; use crate::client_api::ApiClient; @@ -169,6 +173,7 @@ pub struct AppState { pub active_repo: Arc>>, pub settings: Arc>, pub running_daemons: Arc>>, + pub background_tasks: Arc>>, } impl AppState { @@ -180,6 +185,7 @@ impl AppState { active_repo: Default::default(), settings: Arc::new(RwLock::new(settings)), running_daemons: Default::default(), + background_tasks: Default::default(), }; Ok(state) @@ -218,4 +224,37 @@ impl AppState { Ok(()) } + + pub async fn add_async_task(&self, task: AsyncTask) { + self.background_tasks.lock().await.push(task); + } + + pub fn background_tasks(&self) -> Arc>> { + self.background_tasks.clone() + } +} + +pub struct AsyncTask { + inner: Pin>>>, +} + +impl Debug for AsyncTask { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + "AsyncTask".fmt(f) + } } + +impl AsyncTask { + pub fn new>>(inner: F) -> Self { + Self { + inner: Box::pin(inner), + } + } + + pub async fn exec(self) -> PluginResult<()> { + self.inner.await + } +} + +unsafe impl Send for AsyncTask {} +unsafe impl Sync for AsyncTask {} diff --git a/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.html b/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.html index aba12b2..2cca033 100644 --- a/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.html +++ b/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.html @@ -1,5 +1,6 @@
-
diff --git a/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.ts b/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.ts index f5b6742..22b7187 100644 --- a/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.ts +++ b/mediarepo-ui/src/app/components/shared/app-common/content-aware-image/content-aware-image.component.ts @@ -4,9 +4,11 @@ import { Component, DoCheck, ElementRef, + EventEmitter, Input, OnDestroy, OnInit, + Output, ViewChild } from "@angular/core"; import {SafeResourceUrl} from "@angular/platform-browser"; @@ -23,11 +25,17 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy { @Input() maximizeWidth: boolean = true; @Input() borderRadius: string | undefined; @Input() decoding: "async" | "sync" | "auto" = "auto"; - @ViewChild("image") image?: ElementRef; + @Input() maxRetry = 3; + @Input() retryDelay = 200; + @ViewChild("image") imageElement?: ElementRef; @ViewChild("imageContainer") imageContainer?: ElementRef; + @Output() appLoadEnd = new EventEmitter(); + @Output() appLoadError = new EventEmitter(); + scaleWidth = false; private previousHeight = 0; private previousWidth = 0; + private retryCount = 0; private readonly checkInterval?: number; constructor(private changeDetector: ChangeDetectorRef) { @@ -35,8 +43,8 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy { } public ngOnInit(): void { - if (this.image) { - this.image.nativeElement.decoding = this.decoding; + if (this.imageElement) { + this.imageElement.nativeElement.decoding = this.decoding; this.changeDetector.detach(); } } @@ -50,11 +58,16 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy { } public checkSize(): void { - if (this.image?.nativeElement && this.imageContainer?.nativeElement) { - this.adjustSize(this.image.nativeElement, this.imageContainer.nativeElement); + if (this.imageElement?.nativeElement && this.imageContainer?.nativeElement) { + this.adjustSize(this.imageElement.nativeElement, this.imageContainer.nativeElement); } } + public onImageLoad(image: HTMLImageElement, imageContainer: HTMLDivElement): void { + this.adjustSize(image, imageContainer); + this.appLoadEnd.emit(); + } + /** * Fits the image into the container * @param {HTMLImageElement} image @@ -77,4 +90,17 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy { } } } + + public onImageLoadError(error: ErrorEvent, image: HTMLImageElement): void { + const imageSrc = image.src; + + if (this.retryCount < this.maxRetry) { + this.retryCount++; + setTimeout(() => { + image.src = imageSrc; + }, this.retryDelay * this.retryCount); + } else { + this.appLoadError.emit(); + } + } } diff --git a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.html b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.html index ee71963..3d6ff8f 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.html +++ b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.html @@ -4,8 +4,10 @@
- + diff --git a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.scss b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.scss index c6d56d4..f3b7778 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.scss +++ b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.scss @@ -66,3 +66,7 @@ height: 2em; } } + +.hidden { + display: none; +} diff --git a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.ts b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.ts index 4d0aa90..1936c67 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.ts +++ b/mediarepo-ui/src/app/components/shared/file/file-card/file-card.component.ts @@ -39,13 +39,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy { async ngOnInit() { this.cachedId = this.entry.data.id; - this.setImageDelayed(); + this.loading = true; } async ngOnChanges(changes: SimpleChanges) { if (changes["entry"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) { this.cachedId = this.entry.data.id; - this.setImageDelayed(); + this.loading = true; } if (changes["fileChanged"]) { this.fileChanged.subscribe(() => this.changeDetector.markForCheck()); diff --git a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.html b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.html index 613030a..32f2a27 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.html +++ b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.html @@ -1,4 +1,7 @@ -
diff --git a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts index 8cb6fa1..63888b1 100644 --- a/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts +++ b/mediarepo-ui/src/app/components/shared/file/file-thumbnail/file-thumbnail.component.ts @@ -4,8 +4,10 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + EventEmitter, Input, OnChanges, + Output, SimpleChanges } from "@angular/core"; import {File} from "../../../../../api/models/File"; @@ -24,10 +26,12 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterVi @Input() file!: File; @Input() public fileChanged: BehaviorSubject = new BehaviorSubject(undefined); + @Output() loadEnd = new EventEmitter(); public thumbUrl: SafeResourceUrl | undefined; public fileType!: string; public thumbnailSupported: boolean = false; + public displayError = false; private supportedThumbnailTypes = ["image", "video"]; private previousStatus = "imported"; @@ -55,12 +59,18 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterVi ); this.fileType = this.getFileType(); this.thumbnailSupported = this.getThumbnailSupported(); + this.displayError = false; } if (changes["fileChanged"]) { this.fileChanged.subscribe(() => this.changeDetector.markForCheck()); } } + public onImageLoadError(): void { + this.loadEnd.emit(); + this.displayError = true; + } + private getThumbnailSupported(): boolean { const mimeParts = FileHelper.parseMime(this.file.mimeType);