From 3bb6b32ee7a0d7992580b6f15e3979e5ad062534 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 13 Sep 2025 09:33:56 +0200 Subject: [PATCH] let exifr read the file insteadof the app guessing the size. #277 --- ...coped-Search-Context-and-Search-Sharing.md | 510 ------------------ docs/designs/scoped-cache-bench.ts | 347 ------------ .../model/fileaccess/MetadataLoader.ts | 107 ++-- src/common/config/private/PrivateConfig.ts | 13 - test/folder-reset.ts | 3 + 5 files changed, 49 insertions(+), 931 deletions(-) delete mode 100644 docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md delete mode 100644 docs/designs/scoped-cache-bench.ts diff --git a/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md b/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md deleted file mode 100644 index 1987bb57..00000000 --- a/docs/designs/DESIGN-Scoped-Search-Context-and-Search-Sharing.md +++ /dev/null @@ -1,510 +0,0 @@ -# PiGallery2 Design: Projection Search Context, Search Sharing, and User-level Allow/Blacklist - -Author: Junie (JetBrains autonomous programmer) -Date: 2025-08-13 -Status: Updated after maintainer clarifications - -Terminology: The project uses the term projection. Earlier drafts and filenames may still say “projected”; whenever you see “projected,” read it as “projection.” Benchmark file names keep “projected” for historical reasons. - -## Overview -This document proposes a design to support: -- Search-based sharing: a share link represents a pre-filtered view of the gallery. -- User-level allow AND blacklist via search query: a user’s session is constrained by an allow list and/or deny list expressed as search filters. - -Both features depend on a common capability: a “projection” that prefilters the whole gallery and propagates to all dependent features (directories, persons, counts, covers, etc.). The solution must be efficient on low-end devices (e.g., Raspberry Pi), support ~10 concurrent users, and up to ~100 concurrent sharings. - -Maintainer decisions incorporated: -- User supports both allow and blacklist via search query. -- Path-based sharing is removed; sharing a path must be expressed as a strict directory search query. -- Covers for a projection are selected by recursively scanning the directory and all descendants for the best matching media, and the selected cover must be persisted in the DB per projection. -- Prefer DB persistence for caching derived/aggregated values; avoid in-memory caches where possible. The DB acts as the cache; media files are the source of truth. -- The UI search query (search bar) is NOT part of the Projection Context. Apply it only to media listing/search; derived aggregates (covers, persons) ignore it. User Allow/Blacklist and Share projection always apply. -- UI filters are local-only (filter.service.ts) and never travel to the backend; do not include them in backend projection. -- SQLite and MySQL must be supported. - -## Current Architecture Summary (as observed) -- Database/ORM: TypeORM. Entities live under src/backend/model/database/enitites/ (typo in folder name). -- Core entities impacted by projection: - - MediaEntity (base), PhotoEntity, VideoEntity - - DirectoryEntity: holds derived fields mediaCount, oldestMedia, youngestMedia, validCover, cover - - PersonEntry with PersonJunctionTable: person counts and sample face derived from present media - - FileEntity: meta files (markdown/gpx/pg2conf) - - SharingEntity: currently path-based sharing with optional password and expiry - - album/SavedSearchEntity: persistent named searches -- Search logic is encapsulated in SearchManager, which builds complex TypeORM queries from a SearchQueryDTO. It implements: - - Multiple query primitives (text, people, rating, resolution, date ranges, orientation, etc.) - - Query flattening and combination helpers (AND/OR/SomeOf) - -## Requirements -1. Search sharing - - Generate a link that encapsulates a search query (or references a saved search). - - Guest session is restricted to the pre-filtered subset of media and derived aggregates (directories, persons, counts, covers). - - Path-based shares are deprecated/removed; to share a folder use a strict directory search query (see below). -2. User allow and blacklist via query - - A per-user static filter AllowQuery and/or DenyQuery expressed in the same query language. - - Effective user projection = AllowQuery AND NOT DenyQuery. -3. Common: All derived values must reflect the projection - - Directory inferred values (mediaCount, youngest/oldestMedia, cover) must be computed with respect to projection. - - Persons list, person counts, sample faces must only include media within projection. - - Prefer DB-persisted caches for derived values. -4. Scale targets: ~10 users, up to 100 sharings. - -## Entity Dependencies on Photos (Media) -- DirectoryEntity depends on MediaEntity for: - - mediaCount - - oldestMedia, youngestMedia (timestamps from contained media) - - cover, validCover -- PersonEntry depends on PersonJunctionTable which references MediaEntity (face regions are tied to specific media). PersonEntry.count and sampleRegion are derived from available media. -- SavedSearchEntity references a SearchQueryDTO (JSON). Its results are entirely dependent on the set of Media. -- SharingEntity will be extended to hold a searchQuery (JSON). Share content depends on Media and the query. -- FileEntity (meta) is not directly dependent on Photos, but its availability in a directory often tracks the presence of media. - -## Design -### 1) Projection Context (Projection-based Search Context) -Introduce a request-level object that represents the effective filter for the session, called ProjectionContext. It is computed from the following inputs: -- UserProjection: AllowQuery AND NOT DenyQuery (if configured for the user) -- ShareProjection: Share.searchQuery (if present) - -EffectiveProjection = UserProjection AND ShareProjection - -Notes: -- Local UI filters (frontend/filter.service.ts) and the UI search bar query are client-only for transient listing and must not be included in ProjectionContext or its caches. - -Representation: -- ProjectionContext.raw: the three inputs -- ProjectionContext.effective: a canonicalized, flattened SearchQueryDTO usable by SearchManager -- ProjectionContext.signature (projectionKey): a stable hash (e.g., SHA-256) of the canonicalized effective query. Used as a cache key, persisted with aggregates. - -Integration patterns (two viable implementations): -A. Filtered repositories (injection-based) -- Provide wrapper Repository/Manager for Media, Directory, Person that automatically injects base predicates into QueryBuilder calls. - -B. Projection Query Builders (composition-based) -- Provide ProjectionQB factory: - - getFilteredMediaQB(): SelectQueryBuilder - - getFilteredDirectoryQB(): SelectQueryBuilder (joins to filtered media) - - getFilteredPersonsQB(): SelectQueryBuilder (joins PersonJunctionTable -> filtered media) -- Managers receive ProjectionContext and must obtain QBs from the factory. - -Given PiGallery2’s current QueryBuilder-heavy approach in SearchManager, approach B is recommended initially (lower-risk, incremental). We can adopt A later if desired. - -Implementation detail: Query reuse -- Build the base filtered media subquery once per request: baseFMQ = SearchManager.prepareAndBuildWhereQuery(ProjectionContext.effective) -- Reuse baseFMQ as a subquery/CTE in subsequent directory/person queries to ensure consistency and minimize re-parsing. - -### 2) Derived/Inferred Values under a Projection (Persisted) -For projection views we compute per-projection values and persist them in DB as cache tables keyed by ProjectionContext.signature (projectionKey). Client DTOs MUST be composed by merging: -- Base (unprojected) entity data: directory/album name, path, ids, etc. -- Projection aggregates: item counts, oldest/youngest timestamps, and cover. - -Deprecation note: Existing unprojected derived columns on DirectoryEntity (mediaCount, oldestMedia, youngestMedia, validCover, cover) should no longer be used by readers. New code paths must consume projection caches. A follow-up migration can drop these columns once all reads are updated. - -Proposed cache entities (SQLite and MySQL compatible): -- ProjectionDirectoryCacheEntity (merged: directory aggregates + cover) - - projectionKey TEXT (hash of canonicalized effective query) - - directoryId INT (FK -> DirectoryEntity) - - mediaCount INT - - oldestMedia BIGINT (nullable) - - youngestMedia BIGINT (nullable) - - coverMediaId INT (nullable, FK -> MediaEntity) - - projectionCoverValid BOOLEAN DEFAULT 0 - - Unique(projectionKey, directoryId) -- ProjectionAlbumCacheEntity (album aggregates + cover) - - projectionKey TEXT - - albumId INT (FK -> AlbumBaseEntity) - - itemCount INT - - oldestMedia BIGINT (nullable) - - youngestMedia BIGINT (nullable) - - coverMediaId INT (nullable, FK -> MediaEntity) - - projectionCoverValid BOOLEAN DEFAULT 0 - - Unique(projectionKey, albumId) -- ProjectionPersonAggEntity - - projectionKey TEXT - - personId INT (FK -> PersonEntry) - - count INT - - sampleRegionId INT (nullable, FK -> PersonJunctionTable) - - Unique(projectionKey, personId) - -Computation strategy -- On read: try cache row(s) for the given projectionKey; if missing, compute via aggregated SQL using baseFMQ and upsert rows. -- We avoid global content-version-based invalidation. Instead, use targeted invalidation: - - When a directory’s content changes and DirectoryEntity.validCover is reset, also reset projectionCoverValid = 0 for that directory across all projection rows. - - When media is added/removed in a directory, lazily recompute that directory’s ProjectionDirectoryCacheEntity row on next access (upsert overwrites). -- Upsert mechanics: - - SQLite: INSERT INTO ... ON CONFLICT(projectionKey, directoryId) DO UPDATE SET ... - - MySQL: INSERT ... ON DUPLICATE KEY UPDATE ... (with a composite unique index) - -### 3) Cover Selection (Recursive) and Persistence -- Selection rule: For a given directory D and projection, search for best matching media in D and all of its subdirectories recursively. -- Ranking: Use Config.AlbumCover.Sorting. Prefer media from D itself over descendants where applicable (as currently implemented with CASE sorting), then apply sorting. -- Persistence: Upsert into ProjectionDirectoryCacheEntity for (projectionKey, directoryId) with coverMediaId and set projectionCoverValid = 1. This avoids recomputing on subsequent reads. -- Fallback: If no media found, persist coverMediaId = NULL and projectionCoverValid = 1 to avoid repeated scans (still keyed by projectionKey). -- Invalidation: Reuse the existing cover invalidation semantics at directory-level. When a directory (or its subtree) content changes and DirectoryEntity.validCover is reset, also reset projectionCoverValid = 0 for the affected directory rows across projections. Avoid global invalidation. - -### 4) Search Sharing (Path removed; strict directory share) -Schema changes -- SharingEntity: remove path; add non-nullable searchQuery (TEXT with JSON transformer). -- A “share a path” use-case is expressed as a strict directory search query: - - { type: SearchQueryTypes.directory, text: FullDirectoryPath, matchType: exact_match } - - FullDirectoryPath means DirectoryEntity.path + DirectoryEntity.name (the unique directory path in PiGallery2). -- Backward compatibility/migration: Existing path-based shares should be migrated by creating equivalent directory-exact searchQuery records. - -API -- POST /api/share: accepts { searchQuery, password?, valid } only. No path parameter. -- GET using sharingKey establishes ShareProjection from the stored searchQuery. - -Security -- sharingKey remains unguessable; password/expiry unchanged. - -### 5) User-level Allow AND Blacklist via Search Query -Schema extension -- UserEntity: add allowQuery (nullable TEXT JSON), denyQuery (nullable TEXT JSON). -- Effective user projection: allowQuery AND NOT denyQuery; if allowQuery is null, it means TRUE (unrestricted); if denyQuery is null, it means FALSE (no deny). - -Operational notes -- Load and attach these queries at login to construct ProjectionContext for each request. - -### 6) Combining Projections and Precedence -- User projection: Allow AND NOT Deny -- Share projection: Share.searchQuery - -EffectiveProjection = UserProjection ∧ ShareProjection - -### 7) Performance Considerations (Raspberry Pi friendly) -- Prefer single aggregated SQL queries per view over iterative per-entity fetches. -- Reuse the base filtered media subquery in all aggregations to avoid duplicative parsing and planning. -- Ensure indices exist on common predicates: - - Media: directoryId, creationDate, rating, orientation, resolution - - PersonJunctionTable: mediaId, personId - - DirectoryEntity: parentId, path, name -- Add indices/unique constraints for cache tables on (projectionKey, directoryId)/(projectionKey, personId). -- For SQLite: GLOB-based path recursion is already in use; for MySQL use LIKE prefix as implemented today. - -### 8) Invalidation Strategy -- No time-based cleanup on projection caches. Do not depend on updatedAt/TTL or content-version keys. -- Explicit invalidation only: - - When a directory’s content changes and DirectoryEntity.validCover is reset, also reset projectionCoverValid = 0 for that directory across all projection rows (ProjectionDirectoryCacheEntity). - - Directory aggregates and album aggregates are recomputed lazily on next read: perform the aggregate query and upsert the row for the given projectionKey and entity id (overwrite existing values). - - Person aggregates are recomputed lazily on demand similarly. -- The only periodic cleanup remains existing expired sharing deletion (unchanged). - -### 9) API/Code Integration Plan -- Introduce ProjectionContext builder utility that: - 1) Canonicalizes, flattens, AND-combines UserProjection and ShareProjection - 2) Builds the base filtered media subquery via SearchManager - 3) Exposes projectionKey (signature) for cache tables - 4) Note: UI searchQueryDTO is NOT part of ProjectionContext; apply it separately to media listing/search only (never included in projectionKey). -- Update managers to: - - Read from cache tables by projectionKey; if miss, compute and upsert. - - Use recursive cover selection when populating ProjectionDirectoryCacheEntity. -- Server endpoints that accept a searchQueryDTO from the UI apply it only to media listing/search; do NOT include it in ProjectionContext or projectionKey. -- Sharing routes: accept only searchQuery; remove path parameter(s). -- User sessions: load user’s allow/deny at login and use for ProjectionContext. - -### 10) Testing Strategy -- Unit tests for ProjectionContext combination and canonicalization. -- Integration tests: - - Directory listing under a projection: counts, oldest/youngest, covers reflect only filtered media and are persisted in caches. - - Persons list under a projection: set membership and counts correct; persisted. - - Search sharing: guest views match query, unauthorized media excluded; strict directory shares behave non-recursively. - - User allow/deny: ensure deny overrides, and effective results equal Allow AND NOT Deny. -- Performance tests on low-spec environment settings, validating response times under target concurrency with modest dataset. - -### 11) Migration Considerations -- Path removal in SharingEntity: - - Add searchQuery column (TEXT JSON) and drop path. - - Migrate existing rows: for each share(path=p), write searchQuery = directory-exact query for p. -- Add cache tables: ProjectionDirectoryCacheEntity, ProjectionAlbumCacheEntity, and ProjectionPersonAggEntity, and indices. Add composite unique index on PersonJunctionTable(mediaId, personId) to avoid duplicates and improve joins. -- Optional follow-up migration: drop unprojected derived columns from DirectoryEntity (mediaCount, oldestMedia, youngestMedia, validCover, cover) once all reads use projection caches; client DTOs will continue to merge base identity with projection aggregates, remaining transparent to the client. - -### 12) PersonJunctionTable and Person Queries -- Current schema: PersonJunctionTable has id (PK), and ManyToOne relations to MediaEntity (media) and PersonEntry (person), both indexed. This supports joins in both directions. -- Use cases supported: - - List all persons under a projection: join pj -> media (filtered by baseFMQ) -> group by pj.personId, count rows; optionally pick a sampleRegionId from pj linked media in-projection. - - List all photos for a given person under a projection: join pj on pj.personId = :id; join media; filter by baseFMQ; select media. -- Recommendations: - - Add a composite UNIQUE index on (mediaId, personId) to prevent accidental duplicates and improve planner efficiency. - - Ensure single-column indices on mediaId and personId (already present via @Index on relations in the entity). - - No additional schema changes are required; coordinates of faces remain in MediaEntity.metadata.faces (JSON), while PersonEntry.sampleRegion can continue referencing a pj row. -- SearchManager notes: - - Person text search currently relies on Media.metadata.persons (simple-array) and personsLength for min/max. This can remain for text search performance. Aggregations (person lists, counts) should use the junction table joined to the filtered media set for correctness under a projection. - -## Appendix: Pseudocode Snippets - -Build ProjectionContext (exclude UI search query; exclude local UI filters): -``` -function buildProjectionContext(user, sharing): ProjectionContext { - const allowQ = user.allowQuery ?? TRUE; - const denyQ = user.denyQuery ? NOT(user.denyQuery) : TRUE; - const userProj = AND(allowQ, denyQ); - const shareQ = sharing?.searchQuery ?? TRUE; - const effective = AND(userProj, shareQ); - const canonical = canonicalize(flatten(effective)); - const projectionKey = hash(canonical); - return { raw: {allowQ, denyQ, shareQ}, effective: canonical, signature: projectionKey }; -} -``` - -Recursive cover for a directory and projection (simplified TypeORM-ish): -``` -const fmSubQ = searchManager.prepareAndBuildWhereQuery(projection.effective); -const q = repo.createQueryBuilder('media') - .innerJoin('media.directory', 'directory') - .where(/* directory.id == dirId OR directory.path under dir path, DB-specific */) - .andWhere(`media.id IN (${fmSubQ.getQuery()})`) - .select(['media.id']) - .orderBy(`CASE WHEN directory.id = :dirId THEN 0 ELSE 1 END`, 'ASC') - .setParameters({ dirId }) - /* then apply Config.AlbumCover.Sorting */ - .limit(1); -const coverMedia = await q.getOne(); -UPSERT ProjectionDirectoryCacheEntity(projectionKey, dirId) SET coverMediaId = (coverMedia?.id || NULL), projectionCoverValid = 1; -``` - -Directory aggregates (persisted on miss): -``` -const fmSubQ = ...; -const agg = await repo.createQueryBuilder('directory') - .leftJoin(MediaEntity, 'fm', 'fm.directoryId = directory.id') - .andWhere(`fm.id IN (${fmSubQ.getQuery()})`) - .select('directory.id', 'directoryId') - .addSelect('COUNT(fm.id)', 'mediaCount') - .addSelect('MIN(fm.metadata.creationDate)', 'oldestMedia') - .addSelect('MAX(fm.metadata.creationDate)', 'youngestMedia') - .groupBy('directory.id') - .getRawMany(); -UPSERT all rows into ProjectionDirectoryCacheEntity with projectionKey; -``` - -Persons under a projection (persisted on miss): -``` -const fmSubQ = ...; -const rows = await repo.createQueryBuilder('p') - .leftJoin(PersonJunctionTable, 'pj', 'pj.personId = p.id') - .leftJoin(MediaEntity, 'm', 'm.id = pj.mediaId') - .andWhere(`m.id IN (${fmSubQ.getQuery()})`) - .select('p.id', 'personId') - .addSelect('COUNT(pj.id)', 'count') - .groupBy('p.id') - .getRawMany(); -UPSERT rows into ProjectionPersonAggEntity with projectionKey; -``` - ---- -End of document. - - -## Appendix: Projection Cache Schema Benchmark (Denormalized vs Normalized projectionKey) - -Purpose -- Compare two designs for projection-cache tables used by the EffectiveProjection caching approach: - - Variant A (current in doc): cache tables store projectionKey TEXT directly with composite unique keys. - - Variant B (alternative): separate ProjectionKey table (unique key hash), projection tables reference it via projectionId (FK ON DELETE CASCADE). - -Benchmark harness -- Location: benchmark\\projected-cache-bench.ts (historical filename) -- Run: npm run bench:projected-cache -- Params (env vars): BENCH_SCOPES, BENCH_DIRS, BENCH_PERSONS, BENCH_LOOKUPS. Example (Windows cmd): - - set BENCH_SCOPES=20&&set BENCH_DIRS=120&&set BENCH_PERSONS=90&&set BENCH_LOOKUPS=1000&&npm run bench:projected-cache -- DB: temporary SQLite file at db\\projected_bench.sqlite (does not touch app DB) -- Measures: upsert throughput for dir/person tables, lookup latency by (projection, directory), cascade delete performance, file size delta. - -Sample results (SQLite, N=20, D=120, P=90, lookups=1000) -- Insert/Upsert - - A.upsert ProjDirA: 188.79 ms - - A.upsert ProjPersonA: 101.38 ms - - B.upsert ProjDirB: 203.97 ms (~+8%) - - B.upsert ProjPersonB: 117.96 ms (~+16%) -- Lookup (find one by projection + directory) - - A.lookup ProjDirA: 268.46 ms - - B.lookup ProjDirB: 798.40 ms (~3x) -- Deletion - - A.delete projection rows by projectionKey (2 tables): 89.12 ms - - B.delete ProjectionKey rows (cascade): 48.90 ms (~45% faster) -- File size delta: 0 bytes (SQLite files do not shrink without VACUUM). - -Notes and interpretation -- Normalizing projection keys (Variant B) significantly improves deletion/invalidation thanks to ON DELETE CASCADE (single delete on ProjectionKey), supporting our targeted invalidation strategy. -- Writes (upserts) are modestly slower with normalization on this dataset. -- Lookups in Variant B were slower in this run; likely factors: - - ORM overhead when referencing relation columns. - - Need to ensure composite unique index on (projectionId, directoryId) is used by queries. The benchmark defines a composite UNIQUE which should create an index; however, query-path differences (QueryBuilder vs Repository) can influence timings. - - For fairness, align query shapes and add an explicit @Index(["projection", "directoryId"]) if needed; also consider prepared statements. -- MySQL not measured here; normalization tends to pay off further with larger datasets and cascading cleanups. The harness can be extended to a MySQL DataSource if desired. - -Recommendation (initial) -- If fast and simple invalidation across many projections is a priority, prefer Variant B (ProjectionKey + FK cascade) and ensure: - - Composite unique index on (projectionId, directoryId) and (projectionId, personId). - - Query by both columns to leverage the index. -- If lookup performance dominates and invalidation is infrequent, Variant A may be slightly faster in SQLite. -- Given PiGallery2’s need to invalidate many projection rows cheaply (covers and aggregates), Variant B looks favorable, provided we optimize lookups and add proper indices. The benchmark harness is now available to iterate on these optimizations. - - -## Implementation Plan (Variant A: projectionKey TEXT) - -Decision -- We will implement projection caches using Variant A: projectionKey stored as TEXT in cache tables with composite unique indices. This aligns with maintainer preference (“keeping projectionKey: text”) and provides good lookup performance and simpler code paths. The earlier benchmark remains in the appendix for reference; we may revisit normalization (Variant B) later if invalidation complexity grows. - -Audience -- This plan is written so a junior engineer can follow it step by step. Each task lists files to touch, acceptance criteria, and notes. - -High-level milestones -1) Schema: add projection cache tables (directory aggregates + cover merged, person aggregates). -2) Projection builder: construct EffectiveProjection and projectionKey. -3) Cache compute & read‑through: populate caches on demand using SearchManager. -4) Invalidation: reuse existing cover invalidation; add targeted resets for projection caches. -5) Sharing/User projection: move share to searchQuery; wire user allow/deny. -6) Managers integration: Gallery and Person readers consume caches. -7) Tests and rollout. - -Prerequisites -- Node v22 (per engines), local DB (SQLite default) available. -- Ability to run npm scripts: npm run build-backend, npm run test-backend. -- Familiarity with TypeORM entities and QueryBuilder (see SearchManager.ts for patterns). - -Task 1: Add new cache entities (schema) -- Files to create: - - src\\backend\\model\\database\\enitites\\cache\\ProjectionDirectoryCacheEntity.ts - - src\\backend\\model\\database\\enitites\\cache\\ProjectionAlbumCacheEntity.ts - - src\\backend\\model\\database\\enitites\\cache\\ProjectionPersonAggEntity.ts -- Content (fields and constraints): - - ProjectionDirectoryCacheEntity - - projectionKey: TEXT (indexed) - - directoryId: INT, FK -> DirectoryEntity (indexed) - - mediaCount: INT - - oldestMedia: BIGINT nullable - - youngestMedia: BIGINT nullable - - coverMediaId: INT nullable, FK -> MediaEntity - - projectionCoverValid: BOOLEAN DEFAULT 0 - - Unique(projectionKey, directoryId) - - ProjectionAlbumCacheEntity - - projectionKey: TEXT (indexed) - - albumId: INT, FK -> AlbumBaseEntity (indexed) - - itemCount: INT - - oldestMedia: BIGINT nullable - - youngestMedia: BIGINT nullable - - coverMediaId: INT nullable, FK -> MediaEntity - - projectionCoverValid: BOOLEAN DEFAULT 0 - - Unique(projectionKey, albumId) - - ProjectionPersonAggEntity - - projectionKey: TEXT (indexed) - - personId: INT, FK -> PersonEntry (indexed) - - count: INT - - sampleRegionId: INT nullable, FK -> PersonJunctionTable - - Unique(projectionKey, personId) -- Integration: - - Add all new entities to SQLConnection.getEntries() list so TypeORM can create tables. -- Acceptance criteria: - - Running the app with synchronize=false and schemeSync flow creates the tables in SQLite/MySQL without errors (dev DB). - -Task 2: Build ProjectionContext utility (EffectiveProjection and projectionKey) -- Files to create: - - src\\backend\\model\\ProjectionContext.ts -- Responsibilities: - - Inputs: user.allowQuery, user.denyQuery, sharing.searchQuery; exclude local UI filters and the UI search bar query. - - Combine as EffectiveProjection = AND(Allow, NOT(Deny), Share). - - Canonicalize/flatten queries (reuse SearchManager.flattenSameOfQueries where possible; keep stable ordering of AND/OR lists). - - Compute projectionKey = SHA‑256 of canonicalized EffectiveProjection JSON string. - - Expose helper to obtain base filtered media Brackets via SearchManager.prepareAndBuildWhereQuery(EffectiveProjection). -- Acceptance criteria: - - Unit tests cover: combining inputs, stability of projectionKey for semantically identical trees, exclusion of local UI filters and the UI search bar query. - -Task 3: Projection cache read‑through and upsert (Directory & Albums) -- Files to create: - - src\\backend\\model\\database\\ProjectionCacheManager.ts (new manager) -- Responsibilities (directory side): - - getDirectoryAggregates(projectionKey, dirIds[]): read rows from ProjectionDirectoryCacheEntity; for misses, compute aggregates via a single aggregated query using base filtered media subquery, then upsert rows (ON CONFLICT/ON DUPLICATE KEY). - - getAndPersistProjectionCover(projectionKey, directory): compute cover by recursively scanning directory + descendants with EffectiveProjection (reuse CoverManager sorting/rules), then upsert coverMediaId and set projectionCoverValid=1. If no media found, persist NULL with projectionCoverValid=1. -- Responsibilities (album side): - - getAlbumAggregates(projectionKey, albumIds[]): for each album, compute itemCount/oldest/youngest by querying Media with EffectiveProjection AND album.searchQuery (intersection); upsert into ProjectionAlbumCacheEntity. - - getAndPersistProjectionAlbumCover(projectionKey, album): compute best cover with EffectiveProjection AND album.searchQuery (reuse CoverManager.getCoverForAlbum logic with extra filter); upsert coverMediaId and set projectionCoverValid=1 (or NULL with projectionCoverValid=1 if no match). -- SQL patterns: - - SQLite: INSERT INTO ... ON CONFLICT(projectionKey, directoryId|albumId) DO UPDATE SET ... - - MySQL: INSERT ... ON DUPLICATE KEY UPDATE ... -- Acceptance criteria: - - Given a projection, repeated calls for the same directory/album return cached rows without recomputing; first call computes and persists. - -Task 4: Projection cache read‑through and upsert (Persons) -- Extend ProjectionCacheManager with: - - getPersonsAggregates(projectionKey): list persons and counts within projection by joining PersonJunctionTable -> MediaEntity filtered by EffectiveProjection; upsert rows into ProjectionPersonAggEntity. - - Optionally compute a sampleRegionId per person from an in‑projection face. -- Acceptance criteria: - - Listing persons under a projection yields correct counts; second call hits cache. - -Task 5: Invalidation and maintenance -- Directory cover invalidation: - - When DirectoryEntity.validCover is reset (existing logic), also execute: UPDATE ProjectionDirectoryCacheEntity SET projectionCoverValid = 0 WHERE directoryId IN (affected dirs). -- Media add/remove in a directory: - - No global invalidation. On next read, aggregates recompute and overwrite via upsert (lazy refresh). -- Acceptance criteria: - - After adding/removing media in a directory, subsequent requests recompute aggregates on demand; projection cover recomputes once projectionCoverValid=0. - -Task 6: Sharing changes (path removed; strict directory query) -- Update DTOs (already present in src\\common\\entities\\SharingDTO.ts to include searchQuery). Ensure backend aligns: - - src\\backend\\model\\database\\enitites\\SharingEntity.ts: remove path; add @Column('text', transformer: JSON<->SearchQueryDTO>) searchQuery. - - src\\backend\\middlewares\\SharingMWs.ts: accept only { searchQuery, password, valid } in create/update; remove path; validation updated. - - src\\backend\\model\\database\\SharingManager.ts: persist searchQuery; drop path logic. - - src\\backend\\middlewares\\user\\AuthenticationMWs.ts: on share login, set session user allowList = sharing.searchQuery. -- Migration: - - For existing path shares, create equivalent directory‑exact SearchQuery and store to new column; drop path column. -- Acceptance criteria: - - Creating and fetching shares works with searchQuery only; “share a path” is done by providing a strict directory search query. - -Task 7: User allow AND blacklist -- Extend UserEntity (backend): - - Add allowQuery (TEXT JSON, nullable), denyQuery (TEXT JSON, nullable). -- Session wiring: - - At login, load these fields and pass to ProjectionContext builder. -- Acceptance criteria: - - EffectiveProjection = Allow AND NOT Deny reflected in results; null means unrestricted/none as specified. - -Task 8: Managers integration -- GalleryManager: - - When listing directories, use ProjectionCacheManager.getDirectoryAggregates() with current projectionKey to populate projection counts and times for visible directories. - - For subdirectory tiles, call getAndPersistProjectionCover() if projectionCoverValid=0 or missing. -- Albums: - - Album list/detail endpoints should use ProjectionCacheManager.getAlbumAggregates() and getAndPersistProjectionAlbumCover() to populate itemCount/oldest/youngest and cover per projection. -- CoverManager: - - Expose a method to get recursive cover with an optional EffectiveProjection Brackets; used by ProjectionCacheManager. -- Person listing endpoints: - - Use ProjectionCacheManager.getPersonsAggregates() instead of ad‑hoc counts. -- Acceptance criteria: - - Directory/album/person views reflect projection aggregates; performance remains acceptable on Raspberry Pi targets. - -Task 9: PersonJunctionTable index improvement -- Add composite UNIQUE index on (mediaId, personId) in PersonJunctionTable to prevent duplicates and speed joins. -- Acceptance criteria: - - Schema contains the composite UNIQUE; queries using joins show stable performance. - -Task 10: Testing -- Unit tests: - - ProjectionContext building and hashing stability. -- Integration tests: - - Directory listing under a projection: counts/oldest/youngest/cover reflect only filtered media and persist. - - Person list under a projection: counts correct; persisted. - - Share flow: searchQuery‑only shares function; strict directory share works. - - User allow/deny: deny overrides; EffectiveProjection applies. -- Performance check: - - On sample dataset, verify latency does not regress; optional use of the existing benchmark harness. - -Task 11: Deployment and rollback -- Bump DataStructureVersion if existing schemeSync requires it to create new tables; verify no destructive resets in production. If risk exists, prepare a backup/migration plan. -- Feature flags: - - Optionally guard projection cache usage with a config flag to allow quick rollback to non‑projection behavior. -- Rollback path: - - If issues arise, disable projection caches (flag) and keep existing global behavior while investigating. - -Checklist (Definition of Done) -- [ ] New cache entities exist and are registered. -- [ ] ProjectionContext utility builds EffectiveProjection and provides projectionKey. -- [ ] ProjectionCacheManager computes and upserts directory aggregates and covers. -- [ ] Persons aggregates persisted and served from cache. -- [ ] Invalidation hooks wired to existing cover invalidation and directory changes. -- [ ] Sharing uses searchQuery only; path removed; migration path documented. -- [ ] User allow/deny supported and included in EffectiveProjection. -- [ ] Managers consume projection caches; views reflect projection results. -- [ ] Tests added and passing; basic performance verified on low‑spec device. - -Notes -- Keep paths Windows‑style (e.g., src\\backend\\model\\database\\enitites\\...). -- For MySQL, ensure the composite UNIQUE indices exist and use VARCHAR for TEXT columns if needed for index limitations. diff --git a/docs/designs/scoped-cache-bench.ts b/docs/designs/scoped-cache-bench.ts deleted file mode 100644 index b0893bce..00000000 --- a/docs/designs/scoped-cache-bench.ts +++ /dev/null @@ -1,347 +0,0 @@ -/* - Scoped cache schema performance benchmark - - Variant A: denormalized scopeKey TEXT in cache tables - - Variant B: normalized ScopeKey table with FK (ON DELETE CASCADE) - - Usage: - npx ts-node benchmark\\scoped-cache-bench.ts - - Notes: - - Uses a temporary SQLite DB at db\\scoped_bench.sqlite - - Does NOT touch the application DB -*/ - -import 'reflect-metadata'; -import {DataSource, Entity, PrimaryGeneratedColumn, Column, Index, ManyToOne, JoinColumn, Unique, Repository} from 'typeorm'; -import * as fs from 'fs'; -import * as path from 'path'; - -// -------------------- Entities (Variant A - denormalized) -------------------- - -@Entity({ name: 'scoped_dir_a' }) -@Unique(['scopeKey', 'directoryId']) -class ScopedDirA { - @PrimaryGeneratedColumn({ unsigned: true }) - id!: number; - - @Index() - @Column('text') - scopeKey!: string; - - @Index() - @Column('int', { unsigned: true }) - directoryId!: number; - - @Column('int', { unsigned: true }) - mediaCount!: number; - - @Column('bigint', { nullable: true }) - oldestMedia!: number | null; - - @Column('bigint', { nullable: true }) - youngestMedia!: number | null; - - @Column('int', { unsigned: true, nullable: true }) - coverMediaId!: number | null; - - @Column('bigint', { unsigned: true }) - updatedAt!: number; -} - -@Entity({ name: 'scoped_person_a' }) -@Unique(['scopeKey', 'personId']) -class ScopedPersonA { - @PrimaryGeneratedColumn({ unsigned: true }) - id!: number; - - @Index() - @Column('text') - scopeKey!: string; - - @Index() - @Column('int', { unsigned: true }) - personId!: number; - - @Column('int', { unsigned: true }) - count!: number; - - @Column('int', { unsigned: true, nullable: true }) - sampleRegionId!: number | null; - - @Column('bigint', { unsigned: true }) - updatedAt!: number; -} - -// -------------------- Entities (Variant B - normalized with FK) -------------------- - -@Entity({ name: 'scope_key_b' }) -@Unique(['key']) -class ScopeKeyB { - @PrimaryGeneratedColumn({ unsigned: true }) - id!: number; - - @Index() - @Column('text') - key!: string; - - @Column('bigint', { unsigned: true }) - createdAt!: number; -} - -@Entity({ name: 'scoped_dir_b' }) -@Unique(['scope', 'directoryId']) -class ScopedDirB { - @PrimaryGeneratedColumn({ unsigned: true }) - id!: number; - - @ManyToOne(() => ScopeKeyB, { onDelete: 'CASCADE', nullable: false }) - @JoinColumn({ name: 'scopeId' }) - scope!: ScopeKeyB; - - @Index() - @Column('int', { unsigned: true }) - directoryId!: number; - - @Column('int', { unsigned: true }) - mediaCount!: number; - - @Column('bigint', { nullable: true }) - oldestMedia!: number | null; - - @Column('bigint', { nullable: true }) - youngestMedia!: number | null; - - @Column('int', { unsigned: true, nullable: true }) - coverMediaId!: number | null; - - @Column('bigint', { unsigned: true }) - updatedAt!: number; -} - -@Entity({ name: 'scoped_person_b' }) -@Unique(['scope', 'personId']) -class ScopedPersonB { - @PrimaryGeneratedColumn({ unsigned: true }) - id!: number; - - @ManyToOne(() => ScopeKeyB, { onDelete: 'CASCADE', nullable: false }) - @JoinColumn({ name: 'scopeId' }) - scope!: ScopeKeyB; - - @Index() - @Column('int', { unsigned: true }) - personId!: number; - - @Column('int', { unsigned: true }) - count!: number; - - @Column('int', { unsigned: true, nullable: true }) - sampleRegionId!: number | null; - - @Column('bigint', { unsigned: true }) - updatedAt!: number; -} - -// -------------------- Bench helpers -------------------- - -function hrtimeMs(start?: bigint): number | bigint { - const now = process.hrtime.bigint(); - if (!start) return now; - return Number(now - start) / 1_000_000; // ms -} - -async function time(label: string, fn: () => Promise): Promise<{ label: string; ms: number; result: T }> { - const s = hrtimeMs() as bigint; - const result = await fn(); - const ms = hrtimeMs(s) as number; - console.log(`${label}: ${ms.toFixed(2)} ms`); - return { label, ms, result }; -} - -function randInt(maxExclusive: number): number { - return Math.floor(Math.random() * maxExclusive); -} - -async function upsertBatch(repo: Repository, items: any[], conflictPaths: string[], chunk = 1000) { - for (let i = 0; i < items.length; i += chunk) { - const slice = items.slice(i, i + chunk); - await repo.upsert(slice as any, conflictPaths as any); - } -} - -// -------------------- Main -------------------- - -(async () => { - const dbFolder = path.join(process.cwd(), 'db'); - if (!fs.existsSync(dbFolder)) { - fs.mkdirSync(dbFolder); - } - const dbPath = path.join(dbFolder, 'scoped_bench.sqlite'); - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - - const ds = new DataSource({ - type: 'better-sqlite3', - database: dbPath, - entities: [ScopeKeyB, ScopedDirA, ScopedPersonA, ScopedDirB, ScopedPersonB], - synchronize: true, - dropSchema: true, - }); - - await ds.initialize(); - - // Add FKs for cascade manually (TypeORM for better-sqlite3 sets them, but ensure PRAGMA is on) - await ds.query('PRAGMA foreign_keys = ON'); - - const scopeRepoB = ds.getRepository(ScopeKeyB); - const dirARepo = ds.getRepository(ScopedDirA); - const personARepo = ds.getRepository(ScopedPersonA); - const dirBRepo = ds.getRepository(ScopedDirB); - const personBRepo = ds.getRepository(ScopedPersonB); - - // Parameters (can be tweaked via env vars) - const N = parseInt(process.env.BENCH_SCOPES || '100', 10); // scopes - const D = parseInt(process.env.BENCH_DIRS || '500', 10); // directories - const P = parseInt(process.env.BENCH_PERSONS || '300', 10); // persons - const lookups = parseInt(process.env.BENCH_LOOKUPS || '10000', 10); // lookup ops - - console.log('Benchmark params:', { N, D, P, lookups }); - - const scopeKeys: string[] = Array.from({ length: N }, (_, i) => `scope_${i}_${Math.random().toString(36).slice(2, 10)}`); - - // Prepare ScopeKeyB rows - await time('B.insert ScopeKey rows', async () => { - const rows = scopeKeys.map((k) => { - const r = new ScopeKeyB(); - r.key = k; - r.createdAt = Date.now(); - return r; - }); - await scopeRepoB.insert(rows); - }); - - const scopeIdByKey = new Map(); - { - const rows = await scopeRepoB.find(); - rows.forEach((r) => scopeIdByKey.set(r.key, r.id)); - } - - // Create data for Variant A - await time('A.upsert ScopedDirA', async () => { - const batch: ScopedDirA[] = []; - const now = Date.now(); - for (let i = 0; i < N; i++) { - for (let d = 1; d <= D; d++) { - const row = new ScopedDirA(); - row.scopeKey = scopeKeys[i]; - row.directoryId = d; - row.mediaCount = (d * 7 + i) % 50; - row.oldestMedia = now - ((d + i) % 1000) * 86400000; - row.youngestMedia = now - ((d + i) % 100) * 86400000; - row.coverMediaId = ((d + i) % 3 === 0) ? ((d + i) % 1000) : null; - row.updatedAt = now; - batch.push(row); - } - } - await upsertBatch(dirARepo, batch, ['scopeKey', 'directoryId']); - }); - - await time('A.upsert ScopedPersonA', async () => { - const batch: ScopedPersonA[] = []; - const now = Date.now(); - for (let i = 0; i < N; i++) { - for (let p = 1; p <= P; p++) { - const row = new ScopedPersonA(); - row.scopeKey = scopeKeys[i]; - row.personId = p; - row.count = (p * 11 + i) % 30; - row.sampleRegionId = ((p + i) % 4 === 0) ? ((p + i) % 5000) : null; - row.updatedAt = now; - batch.push(row); - } - } - await upsertBatch(personARepo, batch, ['scopeKey', 'personId']); - }); - - // Create data for Variant B - await time('B.upsert ScopedDirB', async () => { - const batch: ScopedDirB[] = []; - const now = Date.now(); - for (let i = 0; i < N; i++) { - const scopeId = scopeIdByKey.get(scopeKeys[i])!; - for (let d = 1; d <= D; d++) { - const row = new ScopedDirB(); - row.scope = { id: scopeId } as any; - row.directoryId = d; - row.mediaCount = (d * 7 + i) % 50; - row.oldestMedia = now - ((d + i) % 1000) * 86400000; - row.youngestMedia = now - ((d + i) % 100) * 86400000; - row.coverMediaId = ((d + i) % 3 === 0) ? ((d + i) % 1000) : null; - row.updatedAt = now; - batch.push(row); - } - } - await upsertBatch(dirBRepo, batch, ['scope', 'directoryId']); - }); - - await time('B.upsert ScopedPersonB', async () => { - const batch: ScopedPersonB[] = []; - const now = Date.now(); - for (let i = 0; i < N; i++) { - const scopeId = scopeIdByKey.get(scopeKeys[i])!; - for (let p = 1; p <= P; p++) { - const row = new ScopedPersonB(); - row.scope = { id: scopeId } as any; - row.personId = p; - row.count = (p * 11 + i) % 30; - row.sampleRegionId = ((p + i) % 4 === 0) ? ((p + i) % 5000) : null; - row.updatedAt = now; - batch.push(row); - } - } - await upsertBatch(personBRepo, batch, ['scope', 'personId']); - }); - - // Lookup tests - await time('A.lookup ScopedDirA', async () => { - for (let i = 0; i < lookups; i++) { - const k = scopeKeys[randInt(N)]; - const d = 1 + randInt(D); - await dirARepo.findOne({ where: { scopeKey: k, directoryId: d } }); - } - }); - - await time('B.lookup ScopedDirB', async () => { - for (let i = 0; i < lookups; i++) { - const k = scopeKeys[randInt(N)]; - const scopeId = scopeIdByKey.get(k)!; - const d = 1 + randInt(D); - await dirBRepo.createQueryBuilder('b').where('b.scopeId = :sid AND b.directoryId = :d', { sid: scopeId, d }).getOne(); - } - }); - - // Cascade delete tests (delete half of scopes) - const half = Math.floor(N / 2); - const deleteKeys = scopeKeys.slice(0, half); - - const sizeBefore = fs.statSync(dbPath).size; - - await time('A.delete scope rows by scopeKey (2 tables)', async () => { - // delete in both A tables - await dirARepo.createQueryBuilder().delete().where('scopeKey IN (:...keys)', { keys: deleteKeys }).execute(); - await personARepo.createQueryBuilder().delete().where('scopeKey IN (:...keys)', { keys: deleteKeys }).execute(); - }); - - await time('B.delete ScopeKey rows (cascade)', async () => { - await scopeRepoB.createQueryBuilder().delete().where('key IN (:...keys)', { keys: deleteKeys }).execute(); - }); - - const sizeAfter = fs.statSync(dbPath).size; - - console.log('\nSQLite DB file size (bytes): before:', sizeBefore, 'after:', sizeAfter, 'delta:', sizeAfter - sizeBefore); - - await ds.destroy(); - - console.log('\nDone. You can tweak parameters via env vars: BENCH_SCOPES, BENCH_DIRS, BENCH_PERSONS, BENCH_LOOKUPS'); -})(); diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index cd83bfd3..9db46326 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -1,27 +1,33 @@ import * as fs from 'fs'; -import { Config } from '../../../common/config/private/Config'; -import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; -import { VideoMetadata } from '../../../common/entities/VideoDTO'; -import { RatingTypes } from '../../../common/entities/MediaDTO'; -import { Logger } from '../../Logger'; +import {Config} from '../../../common/config/private/Config'; +import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {VideoMetadata} from '../../../common/entities/VideoDTO'; +import {RatingTypes} from '../../../common/entities/MediaDTO'; +import {Logger} from '../../Logger'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import * as exifr from 'exifr'; -import { FfprobeData } from 'fluent-ffmpeg'; -import { FileHandle } from 'fs/promises'; +import {FfprobeData} from 'fluent-ffmpeg'; import * as util from 'node:util'; import * as path from 'path'; -import { Utils } from '../../../common/Utils'; -import { FFmpegFactory } from '../FFmpegFactory'; -import { ExtensionDecorator } from '../extension/ExtensionDecorator'; -import { DateTags } from './MetadataCreationDate'; -const { imageSizeFromFile } = require('image-size/fromFile') +import {Utils} from '../../../common/Utils'; +import {FFmpegFactory} from '../FFmpegFactory'; +import {ExtensionDecorator} from '../extension/ExtensionDecorator'; +import {DateTags} from './MetadataCreationDate'; + +const {imageSizeFromFile} = require('image-size/fromFile'); const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); export class MetadataLoader { + private static readonly EMPTY_METADATA: PhotoMetadata = { + size: {width: 0, height: 0}, + creationDate: 0, + fileSize: 0, + }; + @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata) public static async loadVideoMetadata(fullPath: string): Promise { const metadata: VideoMetadata = { @@ -161,17 +167,10 @@ export class MetadataLoader { return metadata; } - private static readonly EMPTY_METADATA: PhotoMetadata = { - size: { width: 0, height: 0 }, - creationDate: 0, - fileSize: 0, - }; - @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata) public static async loadPhotoMetadata(fullPath: string): Promise { - let fileHandle: FileHandle; const metadata: PhotoMetadata = { - size: { width: 0, height: 0 }, + size: {width: 0, height: 0}, creationDate: 0, fileSize: 0, }; @@ -189,12 +188,9 @@ export class MetadataLoader { mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged }; try { - let bufferSize = Config.Media.photoMetadataSize; try { const stat = fs.statSync(fullPath); metadata.fileSize = stat.size; - //No reason to make the buffer larger than the actual file - bufferSize = Math.min(Config.Media.photoMetadataSize, metadata.fileSize); metadata.creationDate = stat.mtime.getTime(); } catch (err) { // ignoring errors @@ -202,10 +198,10 @@ export class MetadataLoader { try { //read the actual image size, don't rely on tags for this const info = await imageSizeFromFile(fullPath); - metadata.size = { width: info.width, height: info.height }; + metadata.size = {width: info.width, height: info.height}; } catch (e) { //in case of failure, set dimensions to 0 so they may be read via tags - metadata.size = { width: 0, height: 0 }; + metadata.size = {width: 0, height: 0}; } finally { if (isNaN(metadata.size.width) || metadata.size.width == null) { metadata.size.width = 0; @@ -215,21 +211,9 @@ export class MetadataLoader { } } - - const data = Buffer.allocUnsafe(bufferSize); - fileHandle = await fs.promises.open(fullPath, 'r'); - try { - await fileHandle.read(data, 0, bufferSize, 0); - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } finally { - await fileHandle.close(); - } try { try { - const exif = await exifr.parse(data, exifrOptions); + const exif = await exifr.parse(fullPath, exifrOptions); MetadataLoader.mapMetadata(metadata, exif); } catch (err) { // ignoring errors @@ -297,6 +281,7 @@ export class MetadataLoader { } } + private static getOrientation(exif: any): number { let orientation = 1; //Orientation 1 is normal if (exif.ifd0?.Orientation != undefined) { @@ -360,7 +345,7 @@ export class MetadataLoader { } private static mapCaption(metadata: PhotoMetadata, exif: any) { - metadata.caption = exif.dc?.description?.value || Utils.asciiToUTF8(exif.iptc?.Caption) || metadata.caption || exif.ifd0?.ImageDescription || exif.exif?.UserComment?.value || exif.Iptc4xmpCore?.ExtDescrAccessibility?.value ||exif.acdsee?.notes; + metadata.caption = exif.dc?.description?.value || Utils.asciiToUTF8(exif.iptc?.Caption) || metadata.caption || exif.ifd0?.ImageDescription || exif.exif?.UserComment?.value || exif.Iptc4xmpCore?.ExtDescrAccessibility?.value || exif.acdsee?.notes; } private static mapTimestampAndOffset(metadata: PhotoMetadata, exif: any) { @@ -376,7 +361,7 @@ export class MetadataLoader { } if (!offset) { //still no offset? let's look for a timestamp with offset in the rest of the DateTags list const [tsonly, tsoffset] = Utils.splitTimestampAndOffset(ts); - for (let j = i+1; j < DateTags.length; j++) { + for (let j = i + 1; j < DateTags.length; j++) { const [exts, exOffset] = extractTSAndOffset(DateTags[j][0], DateTags[j][1], DateTags[j][2]); if (exts && exOffset && Math.abs(Utils.timestampToMS(tsonly, null) - Utils.timestampToMS(exts, null)) < 30000) { //if there is an offset and the found timestamp is within 30 seconds of the extra timestamp, we will use the offset from the found timestamp @@ -397,11 +382,11 @@ export class MetadataLoader { const pathElements = path.split('.'); let currentObject: any = exif; for (const pathElm of pathElements) { - const tmp = currentObject[pathElm]; - if (tmp === undefined) { - return undefined; - } - currentObject = tmp; + const tmp = currentObject[pathElm]; + if (tmp === undefined) { + return undefined; + } + currentObject = tmp; } return currentObject; } @@ -416,7 +401,7 @@ export class MetadataLoader { if (!extratype || extratype == 'O') { //offset can be in the timestamp itself [ts, offset] = Utils.splitTimestampAndOffset(ts); if (extratype == 'O' && !offset) { //offset in the extra tag and not already extracted from main tag - offset = getValue(exif, extrapath); + offset = getValue(exif, extrapath); } } else if (extratype == 'T') { //date only in main tag, time in the extra tag ts = Utils.toIsoTimestampString(ts, getValue(exif, extrapath)); @@ -465,20 +450,20 @@ export class MetadataLoader { private static mapGPS(metadata: PhotoMetadata, exif: any) { try { - if (exif.gps || (exif.exif && exif.exif.GPSLatitude && exif.exif.GPSLongitude)) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.GPSData = metadata.positionData.GPSData || {}; + if (exif.gps || (exif.exif && exif.exif.GPSLatitude && exif.exif.GPSLongitude)) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.GPSData = metadata.positionData.GPSData || {}; - metadata.positionData.GPSData.longitude = Utils.isFloat32(exif.gps?.longitude) ? exif.gps.longitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLongitude); - metadata.positionData.GPSData.latitude = Utils.isFloat32(exif.gps?.latitude) ? exif.gps.latitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLatitude); + metadata.positionData.GPSData.longitude = Utils.isFloat32(exif.gps?.longitude) ? exif.gps.longitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLongitude); + metadata.positionData.GPSData.latitude = Utils.isFloat32(exif.gps?.latitude) ? exif.gps.latitude : Utils.xmpExifGpsCoordinateToDecimalDegrees(exif.exif.GPSLatitude); - if (metadata.positionData.GPSData.longitude !== undefined) { - metadata.positionData.GPSData.longitude = parseFloat(metadata.positionData.GPSData.longitude.toFixed(6)) + if (metadata.positionData.GPSData.longitude !== undefined) { + metadata.positionData.GPSData.longitude = parseFloat(metadata.positionData.GPSData.longitude.toFixed(6)); + } + if (metadata.positionData.GPSData.latitude !== undefined) { + metadata.positionData.GPSData.latitude = parseFloat(metadata.positionData.GPSData.latitude.toFixed(6)); + } } - if (metadata.positionData.GPSData.latitude !== undefined) { - metadata.positionData.GPSData.latitude = parseFloat(metadata.positionData.GPSData.latitude.toFixed(6)) - } - } } catch (err) { Logger.error(LOG_TAG, 'Error during reading of GPS data: ' + err); } finally { @@ -524,10 +509,10 @@ export class MetadataLoader { private static mapFaces(metadata: PhotoMetadata, exif: any, orientation: number) { //xmp."mwg-rs" section - if (exif["mwg-rs"] && - exif["mwg-rs"].Regions) { + if (exif['mwg-rs'] && + exif['mwg-rs'].Regions) { const faces: FaceRegion[] = []; - const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; + const regionListVal = Array.isArray(exif['mwg-rs'].Regions.RegionList) ? exif['mwg-rs'].Regions.RegionList : [exif['mwg-rs'].Regions.RegionList]; if (regionListVal) { for (const regionRoot of regionListVal) { let type; @@ -613,7 +598,7 @@ export class MetadataLoader { box.top = Math.round(Math.max(0, box.top - box.height / 2)); - faces.push({ name, box }); + faces.push({name, box}); } } if (faces.length > 0) { diff --git a/src/common/config/private/PrivateConfig.ts b/src/common/config/private/PrivateConfig.ts index 833924cc..c8f330b9 100644 --- a/src/common/config/private/PrivateConfig.ts +++ b/src/common/config/private/PrivateConfig.ts @@ -904,19 +904,6 @@ export class ServerMediaConfig extends ClientMediaConfig { }) tempFolder: string = 'demo/tmp'; - @ConfigProperty({ - type: 'unsignedInt', - tags: { - name: $localize`Metadata read buffer`, - priority: ConfigPriority.underTheHood, - uiResetNeeded: {db: true, server: true}, - githubIssue: 398, - unit: 'bytes' - } as TAGS, - description: $localize`Only this many bites will be loaded when scanning photo/video for metadata. Increase this number if your photos shows up as square.`, - }) - photoMetadataSize: number = 512 * 1024; // only this many bites will be loaded when scanning photo for metadata - @ConfigProperty({ tags: { name: $localize`Video`, diff --git a/test/folder-reset.ts b/test/folder-reset.ts index 39fb70b5..2caeb735 100644 --- a/test/folder-reset.ts +++ b/test/folder-reset.ts @@ -8,6 +8,9 @@ const dir = process.argv[2]; if(dir === 'test'){ throw new Error('test folder is not allowed to be deleted'); } +if(dir === '--exclude'){ + throw new Error('--exclude folder is not allowed to be deleted/created'); +} if (fs.existsSync(dir)) { console.log('deleting folder:' + dir); fs.rmSync(dir, {recursive: true});