feat: Implement a custom thumb:// URI scheme to serve raw thumbnail images directly from the database, replacing base64 data URLs.

This commit is contained in:
julyXX
2026-03-28 00:50:03 +08:00
parent 11958dba7d
commit 1399a1e6e3
9 changed files with 82 additions and 22 deletions
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"chrono",
"dirs 5.0.1",
"ffmpeg-next",
"http",
"image",
"imagesize",
"kamadak-exif",
+1
View File
@@ -47,6 +47,7 @@ dirs = "5.0.1"
walkdir = "2.5.0"
trash = "3.0.0"
base64 = "0.22.1"
http = "1"
image = "0.25.5"
imagesize = "0.13.0"
tokio = { version = "1.44.1", features = ["full"] }
+37
View File
@@ -47,6 +47,43 @@ fn main() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.register_uri_scheme_protocol("thumb", |_ctx, request| {
// URL format: thumb://localhost/{library_id}/{file_id}
// library_id is for browser cache isolation only; file_id is the last segment
let path = request.uri().path();
let file_id_str = path.rsplit('/').next().unwrap_or("");
let file_id: i64 = file_id_str.parse().unwrap_or(0);
if file_id <= 0 {
return http::Response::builder()
.status(http::StatusCode::BAD_REQUEST)
.header(http::header::CONTENT_TYPE, "text/plain")
.body("invalid file_id".as_bytes().to_vec())
.unwrap();
}
match t_sqlite::AThumb::fetch_raw(file_id) {
Ok(Some(data)) => {
// Detect format: PNG starts with 0x89504E47, else assume JPEG
let mime = if data.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
"image/png"
} else {
"image/jpeg"
};
http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, mime)
.header(http::header::CACHE_CONTROL, "max-age=31536000, immutable")
.body(data)
.unwrap()
}
_ => http::Response::builder()
.status(http::StatusCode::NOT_FOUND)
.header(http::header::CONTENT_TYPE, "text/plain")
.body("thumbnail not found".as_bytes().to_vec())
.unwrap(),
}
})
.manage(t_ai::AiState(std::sync::Mutex::new(t_ai::AiEngine::new())))
.manage(t_face::FaceState(std::sync::Arc::new(
std::sync::Mutex::new(t_face::FaceEngine::new()),
+17
View File
@@ -2217,6 +2217,23 @@ impl AThumb {
Self::fetch(file_id)
}
/// fetch raw thumbnail bytes from db (no base64 encoding) for protocol handler
pub fn fetch_raw(file_id: i64) -> Result<Option<Vec<u8>>, String> {
let conn = open_conn()?;
let result = conn
.query_row(
"SELECT thumb_data FROM athumbs WHERE file_id = ?1 AND error_code = 0",
params![file_id],
|row| {
let data: Option<Vec<u8>> = row.get(0)?;
Ok(data)
},
)
.optional()
.map_err(|e| e.to_string())?;
Ok(result.flatten())
}
/// delete a thumbnail from db
pub fn delete(file_id: i64) -> Result<usize, String> {
let conn = open_conn()?;
+17 -4
View File
@@ -285,14 +285,27 @@ export function formatFolderBreadcrumb(
.join(' > ');
}
let _thumbLibraryId = 'default';
export function setThumbLibraryId(id: string) {
_thumbLibraryId = id || 'default';
}
export function getThumbUrl(fileId: number | null | undefined, bustCache = false): string {
if (!fileId || fileId <= 0) return '';
const base = `thumb://localhost/${_thumbLibraryId}/${fileId}`;
return bustCache ? `${base}?t=${Date.now()}` : base;
}
export function getThumbnailDataUrl(
thumb: { thumb_data_base64?: string | null; thumb_mime_type?: string | null } | null | undefined,
placeholder = ''
thumb: { file_id?: number | null; error_code?: number | null } | null | undefined,
placeholder = '',
bustCache = false
): string {
if (!thumb?.thumb_data_base64) {
if (!thumb || thumb.error_code !== 0 || !thumb.file_id) {
return placeholder;
}
return `data:${thumb.thumb_mime_type || 'image/jpeg'};base64,${thumb.thumb_data_base64}`;
return getThumbUrl(thumb.file_id, bustCache) || placeholder;
}
export function getRelativePath(path: string, basePath: string): string {
+4 -8
View File
@@ -183,7 +183,7 @@ import { VueDraggable } from 'vue-draggable-plus'
import { listen, emit as tauriEmit } from '@tauri-apps/api/event';
import { config, libConfig } from '@/common/config';
import { useUIStore } from '@/stores/uiStore';
import { scrollToFolder, formatTimestamp } from '@/common/utils';
import { scrollToFolder, formatTimestamp, getThumbUrl } from '@/common/utils';
import { getAlbumQueueIndex, getAlbumScanState, getAlbumScanIcon, shouldAnimateAlbumScanIcon } from '@/common/scanStatus';
import { getAllAlbums, setDisplayOrder, addAlbum, editAlbum, removeAlbum,
fetchFolder, expandFinalFolder, getFileThumb,
@@ -340,13 +340,9 @@ const getMoreMenuItems = (album: any) => {
// Load cover thumbnail for a single album
const loadAlbumCover = async (albumId: number, coverFileId: number | null) => {
if (coverFileId) {
try {
const thumb = await getFileThumb(coverFileId, "", 0, 0, config.settings.thumbnailSize, false);
if (thumb && thumb.error_code === 0) {
albumCovers.value[albumId] = `data:image/jpeg;base64,${thumb.thumb_data_base64}`;
}
} catch (error) {
console.error(`Failed to load cover for album ${albumId}:`, error);
const url = getThumbUrl(coverFileId);
if (url) {
albumCovers.value[albumId] = url;
}
} else {
delete albumCovers.value[albumId];
+1 -1
View File
@@ -3747,7 +3747,7 @@ const updateThumbForFile = async (file: any) => {
const thumb = await getFileThumb(file.id, file.file_path, file.file_type, file.e_orientation || 0, config.settings.thumbnailSize, true);
if(thumb) {
if(thumb.error_code === 0) {
file.thumbnail = getThumbnailDataUrl(thumb, thumbnailPlaceholder);
file.thumbnail = getThumbnailDataUrl(thumb, thumbnailPlaceholder, true);
} else if(thumb.error_code === 1) {
file.thumbnail = thumbnailPlaceholder;
}
+2 -9
View File
@@ -208,6 +208,7 @@ import { listen } from '@tauri-apps/api/event';
import { useUIStore } from '@/stores/uiStore';
import MessageBox from '@/components/MessageBox.vue';
import { getFileThumb, getFileInfo } from '@/common/api';
import { getThumbUrl } from '@/common/utils';
import { IconMore, IconTrash, IconSearch, IconDot, IconClose } from '@/common/icons';
import ContextMenu from '@/components/ContextMenu.vue';
@@ -423,15 +424,7 @@ async function fetchThumbnailsForIds(ids: number[]) {
}
if (historyItems.value[fileId] && !thumbnails.value[fileId]) {
try {
const file = historyItems.value[fileId];
const thumb = await getFileThumb(file.id, file.file_path, file.file_type || 1, file.e_orientation || 0, config.settings.thumbnailSize, false);
if (thumb && thumb.error_code === 0) {
thumbnails.value[file.id] = `data:image/jpeg;base64,${thumb.thumb_data_base64}`;
}
} catch (e) {
console.error('Failed to load thumbnail for history item', fileId, e);
}
thumbnails.value[fileId] = getThumbUrl(fileId);
}
}
}
+2
View File
@@ -3,6 +3,7 @@
*/
import { defineStore } from 'pinia';
import { getCurrentLibraryState, saveLibraryState, getAppConfig } from '@/common/api';
import { setThumbLibraryId } from '@/common/utils';
export const useLibraryStore = defineStore('libraryStore', {
state: () => ({
@@ -102,6 +103,7 @@ export const useLibraryStore = defineStore('libraryStore', {
const appConfig = await getAppConfig();
if (appConfig) {
this._libraryId = appConfig.current_library_id;
setThumbLibraryId(appConfig.current_library_id);
}
// Load library state from backend