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

Signed-off-by: trivernis <trivernis@protonmail.com>
pull/14/head
trivernis 3 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:?}")] #[error("The servers api version (version {server:?}) is incompatible with the api client {client:?}")]
VersionMismatch { server: String, client: String }, 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::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 crate::types::identifier::FileIdentifier;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; 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> { async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Result<Response> {
let api_state = app.state::<ApiState>(); let api_state = app.state::<ApiState>();
let buf_state = app.state::<BufferState>(); let buf_state = app.state::<BufferState>();
let app_state = app.state::<AppState>();
let url = Url::parse(request.uri())?; let url = Url::parse(request.uri())?;
let hash = url let hash = url
@ -116,26 +118,50 @@ async fn thumb_scheme<R: Runtime>(app: &AppHandle<R>, request: &Request) -> Resu
.mimetype(&buffer.mime) .mimetype(&buffer.mime)
.body(buffer.buf) .body(buffer.buf)
} else { } else {
tracing::debug!("Fetching content from daemon"); tracing::debug!("Content not loaded. Singnaling retry.");
let api = api_state.api().await?; 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 let (thumb, bytes) = api
.file .file
.get_thumbnail_of_size( .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 * 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), ((height as f32 * 1.5) as u32, (width as f32 * 1.5) as u32),
) )
.await?; .await?;
tracing::debug!("Received {} content bytes", bytes.len()); tracing::debug!("Received {} content bytes", bytes.len());
buf_state.add_entry( buf_state.add_entry(request_uri, thumb.mime_type.clone(), bytes.clone());
request.uri().to_string(),
thumb.mime_type.clone(),
bytes.clone(),
);
ResponseBuilder::new() Ok(())
.mimetype(&thumb.mime_type) })
.status(200)
.body(bytes)
}
} }

@ -4,6 +4,8 @@ use tauri::{AppHandle, Builder, Invoke, Manager, Runtime};
use state::ApiState; use state::ApiState;
use crate::tauri_plugin::state::{AppState, BufferState}; use crate::tauri_plugin::state::{AppState, BufferState};
use futures::future;
use std::mem;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
@ -97,6 +99,7 @@ impl<R: Runtime> Plugin<R> for MediarepoPlugin<R> {
app.manage(buffer_state.clone()); app.manage(buffer_state.clone());
let repo_state = AppState::load()?; let repo_state = AppState::load()?;
let background_tasks = repo_state.background_tasks();
app.manage(repo_state); app.manage(repo_state);
thread::spawn(move || loop { thread::spawn(move || loop {
@ -104,6 +107,28 @@ impl<R: Runtime> Plugin<R> for MediarepoPlugin<R> {
buffer_state.clear_expired(); buffer_state.clear_expired();
buffer_state.trim_to_size(MAX_BUFFER_SIZE); 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(()) Ok(())
} }

@ -1,12 +1,16 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::mem; use std::mem;
use std::ops::Deref; use std::ops::Deref;
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use parking_lot::Mutex; use parking_lot::Mutex;
use parking_lot::RwLock as ParkingRwLock; use parking_lot::RwLock as ParkingRwLock;
use tauri::async_runtime::RwLock; use tauri::async_runtime::RwLock;
use tokio::sync::Mutex as TokioMutex;
use tokio::time::Instant; use tokio::time::Instant;
use crate::client_api::ApiClient; use crate::client_api::ApiClient;
@ -169,6 +173,7 @@ pub struct AppState {
pub active_repo: Arc<RwLock<Option<Repository>>>, pub active_repo: Arc<RwLock<Option<Repository>>>,
pub settings: Arc<RwLock<Settings>>, pub settings: Arc<RwLock<Settings>>,
pub running_daemons: Arc<RwLock<HashMap<String, DaemonCli>>>, pub running_daemons: Arc<RwLock<HashMap<String, DaemonCli>>>,
pub background_tasks: Arc<TokioMutex<Vec<AsyncTask>>>,
} }
impl AppState { impl AppState {
@ -180,6 +185,7 @@ impl AppState {
active_repo: Default::default(), active_repo: Default::default(),
settings: Arc::new(RwLock::new(settings)), settings: Arc::new(RwLock::new(settings)),
running_daemons: Default::default(), running_daemons: Default::default(),
background_tasks: Default::default(),
}; };
Ok(state) Ok(state)
@ -218,4 +224,37 @@ impl AppState {
Ok(()) 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"> <div #imageContainer (window:resize)="this.adjustSize(image, imageContainer)" class="image-container">
<img #image (load)="this.adjustSize(image, imageContainer)" [class.scale-height]="(!scaleWidth) && maximizeHeight" <img #image (error)="this.onImageLoadError($event, image)" (load)="this.onImageLoad(image, imageContainer)"
[class.scale-width]="scaleWidth && maximizeWidth" [src]="this.imageSrc" [class.scale-height]="(!scaleWidth) && maximizeHeight" [class.scale-width]="scaleWidth && maximizeWidth"
[src]="this.imageSrc"
[style]="{borderRadius: this.borderRadius}" alt=""> [style]="{borderRadius: this.borderRadius}" alt="">
</div> </div>

@ -4,9 +4,11 @@ import {
Component, Component,
DoCheck, DoCheck,
ElementRef, ElementRef,
EventEmitter,
Input, Input,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output,
ViewChild ViewChild
} from "@angular/core"; } from "@angular/core";
import {SafeResourceUrl} from "@angular/platform-browser"; import {SafeResourceUrl} from "@angular/platform-browser";
@ -23,11 +25,17 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy {
@Input() maximizeWidth: boolean = true; @Input() maximizeWidth: boolean = true;
@Input() borderRadius: string | undefined; @Input() borderRadius: string | undefined;
@Input() decoding: "async" | "sync" | "auto" = "auto"; @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>; @ViewChild("imageContainer") imageContainer?: ElementRef<HTMLDivElement>;
@Output() appLoadEnd = new EventEmitter<void>();
@Output() appLoadError = new EventEmitter<void>();
scaleWidth = false; scaleWidth = false;
private previousHeight = 0; private previousHeight = 0;
private previousWidth = 0; private previousWidth = 0;
private retryCount = 0;
private readonly checkInterval?: number; private readonly checkInterval?: number;
constructor(private changeDetector: ChangeDetectorRef) { constructor(private changeDetector: ChangeDetectorRef) {
@ -35,8 +43,8 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy {
} }
public ngOnInit(): void { public ngOnInit(): void {
if (this.image) { if (this.imageElement) {
this.image.nativeElement.decoding = this.decoding; this.imageElement.nativeElement.decoding = this.decoding;
this.changeDetector.detach(); this.changeDetector.detach();
} }
} }
@ -50,11 +58,16 @@ export class ContentAwareImageComponent implements OnInit, DoCheck, OnDestroy {
} }
public checkSize(): void { public checkSize(): void {
if (this.image?.nativeElement && this.imageContainer?.nativeElement) { if (this.imageElement?.nativeElement && this.imageContainer?.nativeElement) {
this.adjustSize(this.image.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 * Fits the image into the container
* @param {HTMLImageElement} image * @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"> <app-middle-centered *ngIf="this.loading">
<div class="loading-indicator"></div> <div class="loading-indicator"></div>
</app-middle-centered> </app-middle-centered>
<app-file-thumbnail *ngIf="!this.loading" <app-file-thumbnail
[fileChanged]="this.fileChanged" (loadEnd)="this.loading = false"
[file]="this.entry.data" [class.hidden]="this.loading"
class="entry-image"></app-file-thumbnail> [fileChanged]="this.fileChanged"
[file]="this.entry.data"
class="entry-image"></app-file-thumbnail>
</div> </div>

@ -66,3 +66,7 @@
height: 2em; height: 2em;
} }
} }
.hidden {
display: none;
}

@ -39,13 +39,13 @@ export class FileCardComponent implements OnInit, OnChanges, OnDestroy {
async ngOnInit() { async ngOnInit() {
this.cachedId = this.entry.data.id; this.cachedId = this.entry.data.id;
this.setImageDelayed(); this.loading = true;
} }
async ngOnChanges(changes: SimpleChanges) { async ngOnChanges(changes: SimpleChanges) {
if (changes["entry"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) { if (changes["entry"] && (this.cachedId === undefined || this.entry.data.id !== this.cachedId)) {
this.cachedId = this.entry.data.id; this.cachedId = this.entry.data.id;
this.setImageDelayed(); this.loading = true;
} }
if (changes["fileChanged"]) { if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck()); 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> borderRadius="0.25em"></app-content-aware-image>
<div *ngIf="this.thumbnailSupported && this.thumbUrl" class="file-icon-overlay"> <div *ngIf="this.thumbnailSupported && this.thumbUrl" class="file-icon-overlay">
<ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon> <ng-icon *ngIf="fileType === 'video'" name="mat-movie"></ng-icon>

@ -4,8 +4,10 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
EventEmitter,
Input, Input,
OnChanges, OnChanges,
Output,
SimpleChanges SimpleChanges
} from "@angular/core"; } from "@angular/core";
import {File} from "../../../../../api/models/File"; import {File} from "../../../../../api/models/File";
@ -24,10 +26,12 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterVi
@Input() file!: File; @Input() file!: File;
@Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined); @Input() public fileChanged: BehaviorSubject<void> = new BehaviorSubject<void>(undefined);
@Output() loadEnd = new EventEmitter<void>();
public thumbUrl: SafeResourceUrl | undefined; public thumbUrl: SafeResourceUrl | undefined;
public fileType!: string; public fileType!: string;
public thumbnailSupported: boolean = false; public thumbnailSupported: boolean = false;
public displayError = false;
private supportedThumbnailTypes = ["image", "video"]; private supportedThumbnailTypes = ["image", "video"];
private previousStatus = "imported"; private previousStatus = "imported";
@ -55,12 +59,18 @@ export class FileThumbnailComponent implements OnChanges, AfterViewInit, AfterVi
); );
this.fileType = this.getFileType(); this.fileType = this.getFileType();
this.thumbnailSupported = this.getThumbnailSupported(); this.thumbnailSupported = this.getThumbnailSupported();
this.displayError = false;
} }
if (changes["fileChanged"]) { if (changes["fileChanged"]) {
this.fileChanged.subscribe(() => this.changeDetector.markForCheck()); this.fileChanged.subscribe(() => this.changeDetector.markForCheck());
} }
} }
public onImageLoadError(): void {
this.loadEnd.emit();
this.displayError = true;
}
private getThumbnailSupported(): boolean { private getThumbnailSupported(): boolean {
const mimeParts = FileHelper.parseMime(this.file.mimeType); const mimeParts = FileHelper.parseMime(this.file.mimeType);

Loading…
Cancel
Save