Move thumbnail loading to background tasks and add retry to image loading

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/14/head
trivernis 2 years ago
parent f83770cf9f
commit b86b6f21ac
Signed by: Trivernis
GPG Key ID: DFFFCC2C7A02DB45

@ -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 {}

@ -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<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Re
async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Result<Response> {
let api_state = app.state::<ApiState>();
let buf_state = app.state::<BufferState>();
let app_state = app.state::<AppState>();
let url = Url::parse(request.uri())?;
let hash = url
@ -116,26 +118,50 @@ async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, 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(())
})
}

@ -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<R: Runtime> Plugin<R> for MediarepoPlugin<R> {
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<R: Runtime> Plugin<R> for MediarepoPlugin<R> {
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(())
}

@ -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<RwLock<Option<Repository>>>,
pub settings: Arc<RwLock<Settings>>,
pub running_daemons: Arc<RwLock<HashMap<String, DaemonCli>>>,
pub background_tasks: Arc<TokioMutex<Vec<AsyncTask>>>,
}
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<TokioMutex<Vec<AsyncTask>>> {
self.background_tasks.clone()
}
}
pub struct AsyncTask {
inner: Pin<Box<dyn Future<Output = PluginResult<()>>>>,
}
impl Debug for AsyncTask {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
"AsyncTask".fmt(f)
}
}
impl AsyncTask {
pub fn new<F: 'static + Future<Output = PluginResult<()>>>(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 {}

@ -1,5 +1,6 @@
<div #imageContainer (window:resize)="this.adjustSize(image, imageContainer)" class="image-container">
<img #image (load)="this.adjustSize(image, imageContainer)" [class.scale-height]="(!scaleWidth) && maximizeHeight"
[class.scale-width]="scaleWidth && maximizeWidth" [src]="this.imageSrc"
<img #image (error)="this.onImageLoadError($event, image)" (load)="this.onImageLoad(image, imageContainer)"
[class.scale-height]="(!scaleWidth) && maximizeHeight" [class.scale-width]="scaleWidth && maximizeWidth"
[src]="this.imageSrc"
[style]="{borderRadius: this.borderRadius}" alt="">
</div>

@ -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<HTMLImageElement>;
@Input() maxRetry = 3;
@Input() retryDelay = 200;
@ViewChild("image") imageElement?: ElementRef<HTMLImageElement>;
@ViewChild("imageContainer") imageContainer?: ElementRef<HTMLDivElement>;
@Output() appLoadEnd = new EventEmitter<void>();
@Output() appLoadError = new EventEmitter<void>();
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();
}
}
}

@ -4,8 +4,10 @@
<app-middle-centered *ngIf="this.loading">
<div class="loading-indicator"></div>
</app-middle-centered>
<app-file-thumbnail *ngIf="!this.loading"
[fileChanged]="this.fileChanged"
[file]="this.entry.data"
class="entry-image"></app-file-thumbnail>
<app-file-thumbnail
(loadEnd)="this.loading = false"
[class.hidden]="this.loading"
[fileChanged]="this.fileChanged"
[file]="this.entry.data"
class="entry-image"></app-file-thumbnail>
</div>

@ -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());

@ -1,4 +1,7 @@
<app-content-aware-image *ngIf="this.thumbnailSupported && this.thumbUrl" [imageSrc]="this.thumbUrl"
<app-content-aware-image (appLoadEnd)="this.loadEnd.emit()" (appLoadError)="this.onImageLoadError()"
*ngIf="this.thumbnailSupported && this.thumbUrl"
[imageSrc]="this.thumbUrl"
[maxRetry]="5" [retryDelay]="250"
borderRadius="0.25em"></app-content-aware-image>
<div *ngIf="this.thumbnailSupported && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>

@ -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<void> = new BehaviorSubject<void>(undefined);
@Output() loadEnd = new EventEmitter<void>();
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);

Loading…
Cancel
Save