diff --git a/appinfo/routes.php b/appinfo/routes.php index 83c755d2..3324b3e8 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -56,6 +56,7 @@ return [ ['name' => 'Archive#archive', 'url' => '/api/archive/{id}', 'verb' => 'PATCH'], ['name' => 'Image#preview', 'url' => '/api/image/preview/{id}', 'verb' => 'GET'], + ['name' => 'Image#multipreview', 'url' => '/api/image/multipreview', 'verb' => 'POST'], ['name' => 'Image#info', 'url' => '/api/image/info/{id}', 'verb' => 'GET'], ['name' => 'Image#setExif', 'url' => '/api/image/set-exif/{id}', 'verb' => 'PATCH'], ['name' => 'Image#jpeg', 'url' => '/api/image/jpeg/{id}', 'verb' => 'GET'], diff --git a/lib/Controller/ImageController.php b/lib/Controller/ImageController.php index 7b26ce75..2a7a1804 100644 --- a/lib/Controller/ImageController.php +++ b/lib/Controller/ImageController.php @@ -29,6 +29,7 @@ use OCA\Memories\Exif; use OCP\AppFramework\Http; use OCP\AppFramework\Http\FileDisplayResponse; use OCP\AppFramework\Http\JSONResponse; +use OCP\Files\IRootFolder; class ImageController extends ApiBase { @@ -76,6 +77,90 @@ class ImageController extends ApiBase } } + /** + * @NoAdminRequired + * + * @NoCSRFRequired + * + * @PublicPage + * + * Get preview of many images + */ + public function multipreview() { + // read body to array + try { + $body = file_get_contents('php://input'); + $files = json_decode($body, true); + } catch (\Exception $e) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } + + /** @var \OCP\IPreview $previewManager */ + $previewManager = \OC::$server->get(\OCP\IPreview::class); + + // For checking max previews + $previewRoot = new \OC\Preview\Storage\Root( + \OC::$server->get(IRootFolder::class), + \OC::$server->getSystemConfig(), + ); + + // stream the response + header('Content-Type: application/octet-stream'); + header('Expires: ' . \gmdate('D, d M Y H:i:s \G\M\T', \time() + 7 * 3600 * 24)); + header('Cache-Control: max-age=' . 7 * 3600 * 24 . ', private'); + + foreach ($files as $bodyFile) { + $reqid = $bodyFile['reqid']; + $fileid = (int) $bodyFile['fileid']; + $x = (int) $bodyFile['x']; + $y = (int) $bodyFile['y']; + $a = $bodyFile['a'] === '1'; + + $file = $this->getUserFile($fileid); + if (!$file) { + continue; + } + + // Make sure max preview exists + $fileId = (string) $file->getId(); + $folder = $previewRoot->getFolder($fileId); + $hasMax = false; + foreach ($folder->getDirectoryListing() as $preview) { + $name = $preview->getName(); + if (str_contains($name, '-max')) { + $hasMax = true; + + break; + } + } + if (!$hasMax) { + continue; + } + + // Add this preview to the response + try { + $preview = $previewManager->getPreview($file, $x, $y, !$a, 'fill'); + $content = $preview->getContent(); + if (empty($content)) { + continue; + } + + echo json_encode([ + 'reqid' => $reqid, + 'Content-Length' => \strlen($content), + 'Content-Type' => $preview->getMimeType(), + ]); + echo "\n"; + echo $content; + flush(); + } catch (\Exception $e) { + continue; + } + } + + exit; + } + /** * @NoAdminRequired * diff --git a/src/service-worker-custom.ts b/src/service-worker-custom.ts new file mode 100644 index 00000000..f6f7110f --- /dev/null +++ b/src/service-worker-custom.ts @@ -0,0 +1,136 @@ +import { registerRoute } from "workbox-routing"; + +interface FetchPreviewObject { + url: URL; + fileid: number; + reqid: number; + callback: (blob: Response) => void; +} +let fetchPreviewQueue: FetchPreviewObject[] = []; + +let imageCache: Cache; +(async () => { + imageCache = await caches.open("images"); +})(); + +let fetchPreviewTimer: any; +async function flushPreviewQueue() { + if (fetchPreviewQueue.length === 0) return; + + fetchPreviewTimer = 0; + const fetchPreviewQueueCopy = fetchPreviewQueue; + fetchPreviewQueue = []; + + const files = fetchPreviewQueueCopy.map((p) => ({ + fileid: p.fileid, + x: Number(p.url.searchParams.get("x")), + y: Number(p.url.searchParams.get("y")), + a: p.url.searchParams.get("a"), + reqid: p.reqid, + })); + + try { + // infer the url from the first file + const firstUrl = fetchPreviewQueueCopy[0].url; + const url = new URL(firstUrl.toString()); + const path = url.pathname.split("/"); + const previewIndex = path.indexOf("preview"); + url.pathname = path.slice(0, previewIndex).join("/") + "/multipreview"; + url.searchParams.delete("x"); + url.searchParams.delete("y"); + url.searchParams.delete("a"); + url.searchParams.delete("c"); + + const res = await fetch(url, { + method: "POST", + body: JSON.stringify(files), + }); + + if (res.status !== 200) throw new Error("Error fetching multi-preview"); + const blob = await res.blob(); + + let idx = 0; + while (idx < blob.size) { + // Read a line of JSON from blob + const line = await blob.slice(idx, idx + 256).text(); + const newlineIndex = line?.indexOf("\n"); + const jsonParsed = JSON.parse(line?.slice(0, newlineIndex)); + const imgLen = jsonParsed["Content-Length"]; + const imgType = jsonParsed["Content-Type"]; + const reqid = jsonParsed["reqid"]; + idx += newlineIndex + 1; + + console.debug("multi-preview", jsonParsed); + + // Read the image data + const imgBlob = blob.slice(idx, idx + imgLen); + idx += imgLen; + + // Initiate callbacks + fetchPreviewQueueCopy + .filter((p) => p.reqid === reqid) + .forEach((p) => { + p.callback( + new Response(imgBlob, { + status: 200, + headers: { + "Content-Type": imgType, + "Content-Length": imgLen, + Expires: res.headers.get("Expires"), + "Cache-Control": res.headers.get("Cache-Control"), + }, + }) + ); + p.callback = null; + }); + } + } catch (e) { + console.error(e); + } + + // Initiate callbacks for failed requests + fetchPreviewQueueCopy.forEach((fetchPreviewObject) => { + fetchPreviewObject.callback?.( + new Response("Image not found", { + status: 404, + statusText: "Image not found", + }) + ); + }); +} + +registerRoute( + /^.*\/apps\/memories\/api\/image\/preview\/.*/, + async ({ url }) => { + // Check if in cache + const cache = await imageCache?.match(url); + if (cache) return cache; + + // Get file id from URL + const fileid = Number(url.pathname.split("/").pop()); + + // Aggregate requests + let res: Response = await new Promise((callback) => { + fetchPreviewQueue.push({ + url, + fileid, + reqid: Math.random(), + callback, + }); + if (!fetchPreviewTimer) { + fetchPreviewTimer = setTimeout(flushPreviewQueue, 50); + } + }); + + if (res.status !== 200) { + res = await fetch(url); + } + + // Cache response + if (res.status === 200) { + imageCache?.put(url, res.clone()); + } + + return res; + } +); diff --git a/src/service-worker.js b/src/service-worker.js index 94292c97..b17811ab 100644 --- a/src/service-worker.js +++ b/src/service-worker.js @@ -5,6 +5,8 @@ import { ExpirationPlugin } from 'workbox-expiration'; precacheAndRoute(self.__WB_MANIFEST); +import './service-worker-custom'; + registerRoute(/^.*\/apps\/memories\/api\/video\/transcode\/.*/, new NetworkOnly()); registerRoute(/^.*\/apps\/memories\/api\/image\/jpeg\/.*/, new NetworkOnly()); registerRoute(/^.*\/remote.php\/.*/, new NetworkOnly());