diff --git a/.dockerignore b/.dockerignore index 6bc822e..69e4249 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ build/ .idea/ .packages -data \ No newline at end of file +data +cache \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3aeddc1..da7fdd7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ # Created by `dart pub` .dart_tool/ -data \ No newline at end of file +data +cache \ No newline at end of file diff --git a/bin/router.dart b/bin/router.dart index 2dc5eae..33f4d55 100644 --- a/bin/router.dart +++ b/bin/router.dart @@ -5,9 +5,11 @@ import 'dart:async'; import 'package:shelf/shelf.dart'; import 'package:shelf_router/shelf_router.dart'; +import 'util/vips.dart'; import 'domain/repositories/photo_repository.dart'; import 'domain/repositories/album_repository.dart'; +final vips = Vips(); final albumRepository = AlbumRepository(basePath: 'data'); final photoRepository = PhotoRepository('data'); @@ -24,9 +26,10 @@ Router createApiV1Router() { ..get('/', _rootHandler) ..get("/album", _listAlbumHandler) ..get("/album/", _getAlbumHandler) - ..get("/album//photo", _getPhotosOfAlbumHander) + ..get("/album//photo", _getPhotosOfAlbumHandler) ..get('/photo/', _getPhotoHandler) ..get('/photo//file', _getPhotoFileHandler) + ..get('/photo//preview', _getPhotoPreviewHandler) ..get('/echo/', _echoHandler); return router; @@ -35,10 +38,10 @@ Router createApiV1Router() { /// 创建主路由器,将 API v1 挂载到 /api/v1 路径下 Router createRouter() { final router = Router(); - + // 将 API v1 路由挂载到 /api/v1 路径下 router.mount('/api/v1', createApiV1Router().call); - + return router; } @@ -79,10 +82,7 @@ Future _getAlbumHandler(Request req) async { if (album == null) { return Response.notFound('Album not found'); } - return jsonResponse({ - 'id': album.id, - 'name': album.name, - }); + return jsonResponse({'id': album.id, 'name': album.name}); } Future _getPhotoHandler(Request req) async { @@ -127,7 +127,7 @@ Future _getPhotoFileHandler(Request req) async { // ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流 final streamController = StreamController>(); final fileStream = file.openRead(); - + // 订阅文件流,并将数据转发到响应流 final subscription = fileStream.listen( streamController.add, @@ -135,7 +135,7 @@ Future _getPhotoFileHandler(Request req) async { onDone: streamController.close, cancelOnError: true, ); - + // 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件 streamController.onCancel = () { subscription.cancel(); @@ -151,7 +151,7 @@ Future _getPhotoFileHandler(Request req) async { ); } -Future _getPhotosOfAlbumHander(Request req) async { +Future _getPhotosOfAlbumHandler(Request req) async { final idParam = req.params['id']; if (idParam == null) { return Response.badRequest(body: 'Missing album id'); @@ -163,3 +163,51 @@ Future _getPhotosOfAlbumHander(Request req) async { final photos = await photoRepository.getPhotosByAlbumId(albumId); return jsonResponse(photos); } + +Future _getPhotoPreviewHandler(Request req) async { + final idParam = req.params['id']; + if (idParam == null) { + return Response.badRequest(body: 'Missing photo id'); + } + final id = int.tryParse(idParam); + if (id == null) { + return Response.badRequest(body: 'Invalid photo id: $idParam'); + } + + final photo = await photoRepository.getPhotoById(id); + if (photo == null) { + return Response.notFound('Photo not found'); + } + + final file = await vips.generatePreview(photo.filePath); + + if (!await file.exists()) { + return Response.notFound('File not found'); + } + + // ✅ 修复:使用 StreamController 包装,确保在客户端断开时关闭文件流 + final streamController = StreamController>(); + final fileStream = file.openRead(); + + // 订阅文件流,并将数据转发到响应流 + final subscription = fileStream.listen( + streamController.add, + onError: streamController.addError, + onDone: streamController.close, + cancelOnError: true, + ); + + // 当客户端取消订阅时(断开连接),取消文件流订阅并关闭文件 + streamController.onCancel = () { + subscription.cancel(); + return Future.value(); + }; + + return Response.ok( + streamController.stream, + headers: { + 'Content-Type': photo.mimeType, + 'Content-Length': file.statSync().size.toString(), + }, + ); +} diff --git a/bin/util/vips.dart b/bin/util/vips.dart new file mode 100644 index 0000000..29a7b7f --- /dev/null +++ b/bin/util/vips.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:path/path.dart'; + +class Vips { + String? vipsExecuteFile; + + Vips({this.vipsExecuteFile}); + + Future generatePreview(String imgPath) async { + final imgOut = + "cache/preview/" + basename(imgPath).hashCode.toString() + ".webp"; + final outFile = File(imgOut); + if (outFile.existsSync()) { + return outFile; + } + final result = await Process.run("vips", [ + "webpsave", + imgPath, + imgOut, + "--Q", + "80", + "--smart-subsample", + "--strip", + ]); + if (result.exitCode == 0) { + return outFile; + } + throw Exception("vips image transcode failed!"); + } +} diff --git a/web/.idea/.gitignore b/web/.idea/.gitignore new file mode 100644 index 0000000..b6b1ecf --- /dev/null +++ b/web/.idea/.gitignore @@ -0,0 +1,10 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 已忽略包含查询文件的默认文件夹 +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ diff --git a/web/.idea/codeStyles/Project.xml b/web/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..5055868 --- /dev/null +++ b/web/.idea/codeStyles/Project.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/.idea/codeStyles/codeStyleConfig.xml b/web/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/web/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/web/.idea/inspectionProfiles/Project_Default.xml b/web/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/web/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/web/.idea/modules.xml b/web/.idea/modules.xml new file mode 100644 index 0000000..f589ca3 --- /dev/null +++ b/web/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/web/.idea/prettier.xml b/web/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/web/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/web/.idea/vcs.xml b/web/.idea/vcs.xml new file mode 100644 index 0000000..6c0b863 --- /dev/null +++ b/web/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/web/.idea/web.iml b/web/.idea/web.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/web/.idea/web.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/web/src/lib/api/client.js b/web/src/lib/api/client.js index b69f688..b86e013 100644 --- a/web/src/lib/api/client.js +++ b/web/src/lib/api/client.js @@ -58,3 +58,12 @@ export async function getPhoto(id) { export function getPhotoFileUrl(id) { return `${API_BASE}/photo/${id}/file`; } + +/** + * Get photo preview URL + * @param {number} id + * @returns {string} + */ +export function getPhotoPreviewUrl(id) { + return `${API_BASE}/photo/${id}/preview`; +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index d5436a6..350b19a 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,6 +1,5 @@