refactor(photos): trek photos, memories access and cache sweeps through repositories

This commit is contained in:
jubnl
2026-09-23 17:58:20 +02:00
parent 6b4c124a38
commit db2db7f5a3
36 changed files with 942 additions and 270 deletions
@@ -1,4 +1,48 @@
import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity';
import { TrekRepository } from './_shared/trek-repository';
export class TrekPhotoCacheMetaRepository extends TrekRepository<TrekPhotoCacheMeta> {}
/** TC1's row — `content_type`/`fetched_at`, the two columns `getFresh` needs. */
export interface TrekPhotoCacheFreshness {
content_type: string;
fetched_at: number;
}
export class TrekPhotoCacheMetaRepository extends TrekRepository<TrekPhotoCacheMeta> {
/** TC1 — `SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?`. */
async findFreshness(cache_key: string): Promise<TrekPhotoCacheFreshness | null> {
const row = await this.findOne({ cache_key }, { fields: ['content_type', 'fetched_at'] });
return row ? { content_type: row.content_type, fetched_at: row.fetched_at } : null;
}
/**
* TC2/TC3/TC6 — `DELETE FROM trek_photo_cache_meta WHERE cache_key = ?`,
* the same statement text at three call sites (the TTL-expired branch, the
* object-missing branch, and `sweepExpired`'s stale pass) — one method (D4).
*/
async deleteByCacheKey(cache_key: string): Promise<number> {
return await this.nativeDelete({ cache_key });
}
/**
* TC4 (`put`) — `INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key,
* content_type, fetched_at) VALUES (?, ?, ?)`. `cache_key` is the table's
* only unique/primary column (a genuine nullable TEXT PK per R5's Task 0
* finding — not a bug, and not touched here), so it is the sole conflict
* target, same upsert shape as `AppSettingsRepository.setValue`.
*/
async upsertMeta(cache_key: string, content_type: string, fetched_at: number): Promise<void> {
await this.upsert({ cache_key, content_type, fetched_at }, { onConflictFields: ['cache_key'], onConflictAction: 'merge' });
}
/** TC5 — `SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?`, `sweepExpired`'s stale-row pass. */
async listStale(cutoff: number): Promise<string[]> {
const rows = await this.find({ fetched_at: { $lt: cutoff } }, { fields: ['cache_key'] });
return rows.map((row) => row.cache_key).filter((key): key is string => key != null);
}
/** TC7 — `SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?`, `sweepExpired`'s pass-2 row-less-object guard. */
async existsByCacheKey(cache_key: string): Promise<boolean> {
const row = await this.findOne({ cache_key }, { fields: ['cache_key'] });
return !!row;
}
}
@@ -1,4 +1,141 @@
import type { TrekPhoto } from '../../types';
import type { TrekPhotos } from '../entities/TrekPhotos.entity';
import { coalesceParam } from '../dialect/sql-functions';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class TrekPhotosRepository extends TrekRepository<TrekPhotos> {}
/**
* `SELECT * FROM trek_photos WHERE id = ?`'s row (PH6/MA4 — the same
* statement text, one method serving both `TrekPhotoRegistrationService
* .resolve` and `MemoriesAccessService.canAccessTrekPhoto`'s entry read).
* Reuses the pre-existing `TrekPhoto` type from `../../types` (the row shape
* every consumer already imports) rather than a second, parallel row
* interface — its key set matches the entity's scalar columns exactly
* (`owner` is the hidden relation, `owner_id` its persist(false) twin).
*/
const _trekPhotoRowKeys: AssertRowKeys<TrekPhoto, TrekPhotos> = true;
export class TrekPhotosRepository extends TrekRepository<TrekPhotos> {
/** PH1's SELECT half — `getOrCreate`'s existing-row lookup. */
async findByProviderAsset(provider: string, asset_id: string, owner_id: number): Promise<{ id: number } | null> {
const row = await this.findOne({ provider, asset_id, owner: owner_id }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/** PH2 — conditional passphrase re-encrypt on an already-registered remote photo. */
async setPassphrase(id: number, passphrase: string): Promise<void> {
await this.nativeUpdate({ id }, { passphrase });
}
/** PH3 — `INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type)`. */
async insertRemote(data: {
provider: string;
asset_id: string;
owner_id: number;
passphrase: string | null;
media_type: string;
}): Promise<number> {
return await this.insert({
provider: data.provider,
asset_id: data.asset_id,
owner: data.owner_id,
passphrase: data.passphrase,
media_type: data.media_type,
});
}
/** PH4's SELECT half — `getOrCreateLocal`'s existing-row lookup. */
async findLocalByPath(file_path: string): Promise<{ id: number } | null> {
const row = await this.findOne({ provider: 'local', file_path }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/** PH5 — `INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms)`, always `provider = 'local'`. */
async insertLocal(data: {
file_path: string;
thumbnail_path: string | null;
width: number | null;
height: number | null;
media_type: string;
duration_ms: number | null;
}): Promise<number> {
return await this.insert({
provider: 'local',
file_path: data.file_path,
thumbnail_path: data.thumbnail_path,
width: data.width,
height: data.height,
media_type: data.media_type,
duration_ms: data.duration_ms,
});
}
/**
* PH6 (`TrekPhotoRegistrationService.resolve`) / MA4 (`MemoriesAccessService
* .canAccessTrekPhoto`'s entry read) — byte-identical `SELECT * FROM
* trek_photos WHERE id = ?`, one method for both call sites (D4).
*/
async findById(id: number): Promise<TrekPhoto | null> {
const row = await this.findOne({ id });
return row ? (toRow(row) as TrekPhoto) : null;
}
/** PH7 — retarget a local photo onto a provider once it has been uploaded there. */
async retarget(id: number, provider: string, asset_id: string, owner_id: number): Promise<void> {
await this.nativeUpdate({ id }, { provider, asset_id, owner: owner_id });
}
/**
* PH8 — stamp a generated local thumbnail. `COALESCE(width, ?)`/
* `COALESCE(height, ?)` keep dimensions a re-generated thumbnail already
* knew (the caller-side guard for "don't blank what's known" stays on the
* service, same as legacy).
*/
async patchThumbnail(id: number, thumbnail_path: string, width: number, height: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, {
thumbnail_path,
width: coalesceParam(platform, 'width', width),
height: coalesceParam(platform, 'height', height),
});
}
/**
* PH9 — `COALESCE`d capture metadata write. The service computes the final
* `taken_at`/`lat`/`lng` (the all-empty early return, the pair-or-neither
* coordinate guard) before calling this; this method only issues the
* three-column `COALESCE(col, ?)` UPDATE unconditionally.
*/
async patchCaptureMetadata(id: number, taken_at: string | null, lat: number | null, lng: number | null): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, {
taken_at: coalesceParam(platform, 'taken_at', taken_at),
lat: coalesceParam(platform, 'lat', lat),
lng: coalesceParam(platform, 'lng', lng),
});
}
/** PH11 — `DELETE FROM trek_photos WHERE id = ? AND provider != 'local'`; local photos are never auto-reclaimed here. */
async deleteNonLocal(id: number): Promise<number> {
return await this.nativeDelete({ id, provider: { $ne: 'local' } });
}
/**
* TH1 — `SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'`.
* The caller (`ThumbnailService.sweepOrphanThumbs`) always passes the
* literal `'journey/'` prefix; `$like` binds the wildcard-appended value as
* a genuine parameter (no dialect helper needed — a plain `LIKE` with a
* hardcoded prefix, no injection surface). Rows with a NULL `file_path`
* never match `LIKE`, so every returned value is a real string.
*/
async listByFilePathPrefix(prefix: string): Promise<string[]> {
const rows = await this.find({ file_path: { $like: `${prefix}%` } }, { fields: ['file_path'] });
return rows.map((row) => row.file_path).filter((path): path is string => path != null);
}
/** TH2 — `SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'`, same shape as TH1 on the sibling column. */
async listByThumbnailPathPrefix(prefix: string): Promise<string[]> {
const rows = await this.find({ thumbnail_path: { $like: `${prefix}%` } }, { fields: ['thumbnail_path'] });
return rows.map((row) => row.thumbnail_path).filter((path): path is string => path != null);
}
}
@@ -1,4 +1,39 @@
import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {}
/** MA7/MA8's shared row shape — album_id always needed, passphrase only by the sync path (MA8). */
export interface TripAlbumLinkSyncRow {
album_id: string;
passphrase: string | null;
}
export class TripAlbumLinksRepository extends TrekRepository<TripAlbumLinks> {
/**
* MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same
* user-scoped WHERE, different SELECT list in the legacy text
* (`album_id` alone vs. `album_id, passphrase`); one method serving both,
* per the inventory's own proposal, since MA7's caller simply ignores the
* extra `passphrase` column. `SELECT album_id, passphrase FROM
* trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?` — note
* this is USER-scoped, not just trip-scoped: a link belongs to the user
* who created it, not the whole trip.
*/
async findScoped(id: number, trip_id: number, user_id: number): Promise<TripAlbumLinkSyncRow | null> {
const row = await this.findOne({ id, trip: trip_id, user: user_id }, { fields: ['album_id', 'passphrase'] });
return row ? { album_id: row.album_id, passphrase: row.passphrase ?? null } : null;
}
/**
* MA9 — `UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP
* WHERE id = ?`. Deliberately unscoped by trip/user, matching the legacy
* statement: the caller (`syncImmichAlbum`/`syncSynologyAlbum`) has
* already resolved `linkId` through `findScoped` (MA7/MA8) earlier in the
* same request, so this write relies entirely on that prior resolution —
* not a defect to fix here (see the class docstring on `MemoriesAccessService`).
*/
async touchSyncTime(id: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) });
}
}
@@ -1,4 +1,54 @@
import type { TripPhotos } from '../entities/TripPhotos.entity';
import { TrekRepository } from './_shared/trek-repository';
export class TripPhotosRepository extends TrekRepository<TripPhotos> {}
export class TripPhotosRepository extends TrekRepository<TripPhotos> {
/**
* PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ?
* LIMIT 1`, used by `TrekPhotoRegistrationService.deleteIfOrphan` alongside
* the still-raw `journey_photos` existence check (`// PH10 — Plan 3g`).
*/
async existsForPhoto(photo_id: number): Promise<boolean> {
const row = await this.findOne({ photo: photo_id }, { fields: ['id'] });
return !!row;
}
/**
* MA3 — the regular-trip branch of `MemoriesAccessService.canAccessUserPhoto`:
* is this provider asset shared by this exact user into this exact trip?
* `SELECT 1 FROM trip_photos tp JOIN trek_photos tkp ON tkp.id = tp.photo_id
* WHERE tp.user_id = ? AND tkp.asset_id = ? AND tkp.provider = ? AND
* tp.trip_id = ? AND tp.shared = 1 LIMIT 1`.
*/
async existsSharedForUser(trip_id: number, user_id: number, asset_id: string, provider: string): Promise<boolean> {
const row = await this.qb('tp')
.select(['tp.id'])
.join('tp.photo', 'tkp')
.where({ 'tp.user': user_id, 'tp.trip': trip_id, 'tp.shared': 1, 'tkp.asset_id': asset_id, 'tkp.provider': provider })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
* MA5's source data — every trip a photo is shared into (`shared = 1`),
* for `MemoriesAccessService.canAccessTrekPhoto` to check each one against
* `TripsRepository.findAccessible` (R4: reuse the existing accessibility
* builder rather than hand-translating the legacy `EXISTS(...UNION
* ALL...)` predicate into a second, independent form). Legacy statement:
* `SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1
* AND EXISTS(...trip_members/trips...) LIMIT 1` — the `EXISTS` half is
* exactly `findAccessible`'s own predicate, so this method hands back the
* candidate trip ids and the caller does the short-circuiting `OR` over
* `findAccessible`'s results instead.
*
* Full-row `find()`, not a `fields: ['trip_id']` projection: `trip_id` is a
* `persist(false)` scalar twin of the `trip` relation, and a narrowed
* `fields` selection silently drops it from the hydrated result (the same
* bare-`persist(false)`-select trap `RoadtripDayBoundariesRepository`
* documents — verified empirically here too, not assumed) — only an
* unrestricted `find()` (or `columnRef` through a `qb()`) hydrates it.
*/
async listTripIdsSharedForPhoto(photo_id: number): Promise<number[]> {
const rows = await this.find({ photo: photo_id, shared: 1 });
return rows.map((row) => row.trip_id);
}
}
@@ -7,7 +7,7 @@ import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { RealtimeService } from '../realtime/realtime.service';
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
@@ -94,7 +94,7 @@ export class JourneyDomainService {
constructor(
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly photos: TrekPhotosRepository,
private readonly photos: TrekPhotoRegistrationService,
private readonly uow: UnitOfWork,
) {}
@@ -1,7 +1,17 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import { fail, success, type ServiceResult } from './memories.helpers';
import { toRowId } from '../common/row-id';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import type { TripAlbumLinksRepository } from '../../db/repositories/TripAlbumLinks.repository';
import { Trips } from '../../db/entities/Trips.entity';
import type { TripsRepository } from '../../db/repositories/Trips.repository';
/**
* Who may see which photo, and the album-link lookups the provider syncs need.
@@ -10,10 +20,25 @@ import { fail, success, type ServiceResult } from './memories.helpers';
* access checks answer for photos that live under a trip *or* a journey — a
* provider asset is reachable through either, which is why neither check can
* live in the trips or journey domain alone.
*
* Stays a service, not a single-table repository (Plan 3e): it composes
* access logic across `trip_photos`/`trek_photos`/`trip_album_links`
* (this plan's own tables) and `journeys`/`journey_contributors`/
* `journey_photos` (Plan 3g's, not yet converted — every read against them
* below stays raw, marked `// <SITE> — Plan 3g`). `DatabaseService` stays
* injected for exactly those raw reads and for the `canAccessTrip` primitive
* (a cross-cutting primitive, not a per-domain SQL statement this plan
* converts).
*/
@Injectable()
export class MemoriesAccessService {
constructor(private readonly db: DatabaseService) {}
constructor(
private readonly db: DatabaseService,
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
@InjectRepository(TripAlbumLinks) private readonly tripAlbumLinks: TripAlbumLinksRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
) {}
async canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): Promise<boolean> {
if (requestingUserId === ownerUserId) {
@@ -22,6 +47,7 @@ export class MemoriesAccessService {
// Journey photos use tripId=0 — check journey_photos + journey_contributors
if (tripId === '0') {
// MA1 — Plan 3g (journey_photos is not yet an owned repository table).
const journeyPhoto = this.db.get<{ journey_id: number }>(`
SELECT gp.journey_id
FROM journey_photos gp
@@ -33,6 +59,7 @@ export class MemoriesAccessService {
`, assetId, provider, ownerUserId);
if (!journeyPhoto) return false;
// MA2 — Plan 3g (journeys/journey_contributors are not yet owned repository tables).
const access = this.db.get(`
SELECT 1 FROM journeys WHERE id = ? AND user_id = ?
UNION ALL
@@ -42,18 +69,11 @@ export class MemoriesAccessService {
return !!access;
}
// Regular trip photos — join through trek_photos
const sharedAsset = this.db.get(`
SELECT 1
FROM trip_photos tp
JOIN trek_photos tkp ON tkp.id = tp.photo_id
WHERE tp.user_id = ?
AND tkp.asset_id = ?
AND tkp.provider = ?
AND tp.trip_id = ?
AND tp.shared = 1
LIMIT 1
`, ownerUserId, assetId, provider, tripId);
// Regular trip photos — join through trek_photos (MA3).
const tripIdNum = toRowId(tripId);
const sharedAsset = tripIdNum != null
? await this.tripPhotos.existsSharedForUser(tripIdNum, ownerUserId, assetId, provider)
: false;
if (!sharedAsset) {
return false;
@@ -64,27 +84,23 @@ export class MemoriesAccessService {
// ── Unified photo access check (trek_photos based) ──────────────────────
async canAccessTrekPhoto(requestingUserId: number, trekPhotoId: number): Promise<boolean> {
const photo = this.db.get<{ id: number; provider: string; owner_id: number | null }>('SELECT * FROM trek_photos WHERE id = ?', trekPhotoId);
const photo = await this.trekPhotos.findById(trekPhotoId);
if (!photo) return false;
// Owner always has access
if (photo.owner_id === requestingUserId) return true;
// Check trip_photos — is this photo shared in a trip the user has access to?
const tripAccess = this.db.get(`
SELECT 1 FROM trip_photos tp
WHERE tp.photo_id = ?
AND tp.shared = 1
AND EXISTS (
SELECT 1 FROM trip_members tm WHERE tm.trip_id = tp.trip_id AND tm.user_id = ?
UNION ALL
SELECT 1 FROM trips t WHERE t.id = tp.trip_id AND t.user_id = ?
)
LIMIT 1
`, trekPhotoId, requestingUserId, requestingUserId);
if (tripAccess) return true;
// MA5: the legacy `EXISTS(...trip_members/trips UNION ALL...)` predicate
// is exactly `TripsRepository.findAccessible`'s own — reuse that builder
// (R4) instead of hand-translating it into a second, independent form.
const sharedTripIds = await this.tripPhotos.listTripIdsSharedForPhoto(trekPhotoId);
for (const tripId of sharedTripIds) {
if (await this.trips.findAccessible(tripId, requestingUserId)) return true;
}
// Check journey_photos — is this photo in a journey the user can access?
// MA6 — Plan 3g (journey_photos/journeys/journey_contributors are not yet owned repository tables).
const journeyAccess = this.db.get(`
SELECT 1 FROM journey_photos gp
WHERE gp.photo_id = ?
@@ -112,10 +128,13 @@ export class MemoriesAccessService {
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = this.db.get<{ album_id: string }>(
'SELECT album_id FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
linkId, tripId, userId,
);
// MA7: `toRowId` on `tripId`/`linkId` — both already validated as a
// reachable trip by `canAccessTrip` above; a non-canonical spelling of
// either (rule 15's accepted narrowing) answers the same "not found"
// the legacy raw-bind affinity would have for a row it couldn't match.
const id = toRowId(linkId);
const tripIdNum = toRowId(tripId);
const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null;
return row ? success(row.album_id) : fail('Album link not found', 404);
} catch {
@@ -128,10 +147,10 @@ export class MemoriesAccessService {
if (!access) return fail('Trip not found or access denied', 404);
try {
const row = this.db.get<{ album_id: string; passphrase: string | null }>(
'SELECT album_id, passphrase FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?',
linkId, tripId, userId,
);
// MA8 — same `toRowId` treatment as MA7 above.
const id = toRowId(linkId);
const tripIdNum = toRowId(tripId);
const row = id != null && tripIdNum != null ? await this.tripAlbumLinks.findScoped(id, tripIdNum, userId) : null;
if (!row) return fail('Album link not found', 404);
@@ -143,6 +162,13 @@ export class MemoriesAccessService {
}
async updateSyncTimeForAlbumLink(linkId: string): Promise<void> {
this.db.run('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?', linkId);
// MA9: unscoped by trip/user, matching the legacy statement — the caller
// has already resolved `linkId` through `getAlbumIdFromLink`/
// `getAlbumLinkForSync` (MA7/MA8) earlier in the same request. A
// non-canonical id (rule 15) is a no-op here, same as a legacy UPDATE
// that matched zero rows.
const id = toRowId(linkId);
if (id == null) return;
await this.tripAlbumLinks.touchSyncTime(id);
}
}
+17 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { MemoriesService } from './memories.service';
import { MemoriesAccessService } from './memories-access.service';
import { ImmichService } from './immich.service';
@@ -25,6 +26,11 @@ import { ImmichPhotoProvider } from './providers/immich.provider';
import { SynologyPhotoProvider } from './providers/synology.provider';
import { NotificationsModule } from '../notifications/notifications.module';
import { StorageModule } from '../storage/storage.module';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import { TripAlbumLinks } from '../../db/entities/TripAlbumLinks.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity';
/**
* Memories (photo-providers) domain — mounted at /api/integrations/memories.
@@ -45,9 +51,19 @@ import { StorageModule } from '../storage/storage.module';
* (#584): adding a photo backend means adding an adapter to this one list, not
* finding every `switch (photo.provider)`. Registered here rather than in the
* adapters themselves so the set is readable in one place.
*
* `MikroOrmModule.forFeature` registers `TripPhotosRepository`/
* `TrekPhotosRepository`/`TripAlbumLinksRepository`/`TripsRepository`
* (owned elsewhere, reached the same way every cross-domain module in this
* program reaches another domain's entity) for `MemoriesAccessService`'s
* `@InjectRepository` constructor params, and `TrekPhotoCacheMetaRepository`
* for `TrekPhotoCacheService`'s (Plan 3e Task 6).
*/
@Module({
imports: [NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule],
imports: [
NotificationsModule, AddonsModule, AuditModule, TrekPhotosModule, RealtimeModule, SchedulingModule, StorageModule,
MikroOrmModule.forFeature([TripPhotos, TrekPhotos, TripAlbumLinks, Trips, TrekPhotoCacheMeta]),
],
controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController],
providers: [
MemoriesService,
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import exifr from 'exifr';
import { PhotoResolverService } from './photo-resolver.service';
import { StorageService } from '../storage/storage.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
/**
* Ask the provider when and where a photo was taken, and record it (#1614).
@@ -21,7 +21,7 @@ import { TrekPhotosRepository } from '../photos/trek-photos.repository';
export class PhotoCaptureBackfillService {
constructor(
private readonly resolver: PhotoResolverService,
private readonly photos: TrekPhotosRepository,
private readonly photos: TrekPhotoRegistrationService,
private readonly storage: StorageService,
) {}
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Response } from 'express';
import type { TrekPhoto } from '../../types';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
import { ThumbnailService } from './thumbnail.service';
import { TrekPhotoCacheService } from './trek-photo-cache.service';
import { fail, success, type AssetInfo, type ServiceResult } from './memories.helpers';
@@ -23,7 +23,7 @@ import { StorageService } from '../storage/storage.service';
@Injectable()
export class PhotoResolverService {
constructor(
private readonly photos: TrekPhotosRepository,
private readonly photos: TrekPhotoRegistrationService,
private readonly thumbnails: ThumbnailService,
private readonly cache: TrekPhotoCacheService,
private readonly providers: PhotoProviderRegistry,
+10 -10
View File
@@ -2,10 +2,12 @@ import { Jimp } from 'jimp'
import path from 'node:path'
import crypto from 'node:crypto'
import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@mikro-orm/nestjs'
import { ADDON_IDS } from '../../addons'
import { AddonsService } from '../addons/addons.service'
import { DatabaseService } from '../database/database.service'
import { StorageService } from '../storage/storage.service'
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity'
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository'
const THUMB_MAX = 800
const THUMB_QUALITY = 80
@@ -31,7 +33,7 @@ export class ThumbnailService {
constructor(
private readonly addons: AddonsService,
private readonly storage: StorageService,
private readonly db: DatabaseService,
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
) {}
async ensureLocalThumbnail(
@@ -91,15 +93,13 @@ export class ThumbnailService {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.JOURNEY))) return 0
const live = new Set<string>()
for (const row of this.db.all<{ file_path: string }>(
"SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'",
)) {
live.add(journeyThumbName(row.file_path))
// TH1
for (const filePath of await this.trekPhotos.listByFilePathPrefix('journey/')) {
live.add(journeyThumbName(filePath))
}
for (const row of this.db.all<{ thumbnail_path: string }>(
"SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'",
)) {
live.add(row.thumbnail_path.slice('journey/'.length))
// TH2
for (const thumbnailPath of await this.trekPhotos.listByThumbnailPathPrefix('journey/thumbs/')) {
live.add(thumbnailPath.slice('journey/'.length))
}
let removed = 0
@@ -1,10 +1,12 @@
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Response } from 'express';
import { DatabaseService } from '../database/database.service';
import { StorageService } from '../storage/storage.service';
import { StorageNotFoundError } from '../storage/storage.types';
import { TrekPhotoCacheMeta } from '../../db/entities/TrekPhotoCacheMeta.entity';
import type { TrekPhotoCacheMetaRepository } from '../../db/repositories/TrekPhotoCacheMeta.repository';
export const CACHE_TTL = 60 * 60 * 1000; // 1 hour
@@ -30,7 +32,7 @@ function objectName(key: string): string {
@Injectable()
export class TrekPhotoCacheService {
constructor(
private readonly db: DatabaseService,
@InjectRepository(TrekPhotoCacheMeta) private readonly cacheMeta: TrekPhotoCacheMetaRepository,
private readonly storage: StorageService,
) {}
@@ -39,19 +41,18 @@ export class TrekPhotoCacheService {
}
async getFresh(key: string): Promise<{ contentType: string } | null> {
const row = this.db.get<{ content_type: string; fetched_at: number }>(
'SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?', key,
);
// TC1
const row = await this.cacheMeta.findFreshness(key);
if (!row) return null;
if (Date.now() - row.fetched_at >= CACHE_TTL) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
await this.cacheMeta.deleteByCacheKey(key); // TC2
return null;
}
if (!(await this.storage.exists('photos-trek', objectName(key)))) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', key);
await this.cacheMeta.deleteByCacheKey(key); // TC3
return null;
}
@@ -61,10 +62,8 @@ export class TrekPhotoCacheService {
async put(key: string, bytes: Buffer, contentType: string): Promise<void> {
await this.storage.put('photos-trek', objectName(key), Readable.from(bytes));
this.db.run(
'INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, content_type, fetched_at) VALUES (?, ?, ?)',
key, contentType, Date.now(),
);
// TC4
await this.cacheMeta.upsertMeta(key, contentType, Date.now());
}
async serveFresh(res: Response, key: string): Promise<boolean> {
@@ -99,13 +98,11 @@ export class TrekPhotoCacheService {
async sweepExpired(): Promise<void> {
const cutoff = Date.now() - CACHE_TTL * 2;
const stale = this.db.all<{ cache_key: string }>(
'SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?', cutoff,
);
const stale = await this.cacheMeta.listStale(cutoff); // TC5
for (const row of stale) {
this.db.run('DELETE FROM trek_photo_cache_meta WHERE cache_key = ?', row.cache_key);
await this.storage.delete('photos-trek', objectName(row.cache_key));
for (const cacheKey of stale) {
await this.cacheMeta.deleteByCacheKey(cacheKey); // TC6
await this.storage.delete('photos-trek', objectName(cacheKey));
}
// Pass 2 (fix #4, spec rev 3.2): getFresh's expiry path deletes the meta
@@ -116,8 +113,8 @@ export class TrekPhotoCacheService {
for await (const stat of this.storage.list('photos-trek')) {
if (stat.key.includes('/') || !stat.key.endsWith('.bin') || stat.mtimeMs >= cutoff) continue;
const key = stat.key.slice(0, -'.bin'.length);
const row = this.db.get('SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?', key);
if (!row) {
const exists = await this.cacheMeta.existsByCacheKey(key); // TC7
if (!exists) {
await this.storage.delete('photos-trek', stat.key).catch(() => { /* race */ });
}
}
@@ -4,7 +4,7 @@ import { broadcast } from '../../websocket';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { AddonsService } from '../addons/addons.service';
import { DatabaseService } from '../database/database.service';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { TrekPhotoRegistrationService } from '../photos/trek-photos.repository';
import { ImmichService } from './immich.service';
import { SynologyService } from './synology.service';
import { MemoriesAccessService } from './memories-access.service';
@@ -29,7 +29,7 @@ import { UnitOfWork } from '../database/unit-of-work';
export class UnifiedMemoriesService {
constructor(
private readonly db: DatabaseService,
private readonly photos: TrekPhotosRepository,
private readonly photos: TrekPhotoRegistrationService,
private readonly immich: ImmichService,
private readonly synology: SynologyService,
private readonly access: MemoriesAccessService,
+11 -3
View File
@@ -1,5 +1,8 @@
import { Module } from '@nestjs/common';
import { TrekPhotosRepository } from './trek-photos.repository';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { TrekPhotoRegistrationService } from './trek-photos.repository';
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
/**
* The trek_photos store on its own, so both halves can have it without
@@ -10,9 +13,14 @@ import { TrekPhotosRepository } from './trek-photos.repository';
* bytes. That is a genuine mutual need, and a module this small breaks it
* without a forwardRef: PhotosModule -> MemoriesModule -> TrekPhotosModule is
* a straight line.
*
* `MikroOrmModule.forFeature` registers `TrekPhotosRepository`/
* `TripPhotosRepository` (the ORM ones) for `TrekPhotoRegistrationService`'s
* `@InjectRepository` constructor params (Plan 3e Task 6).
*/
@Module({
providers: [TrekPhotosRepository],
exports: [TrekPhotosRepository],
imports: [MikroOrmModule.forFeature([TrekPhotos, TripPhotos])],
providers: [TrekPhotoRegistrationService],
exports: [TrekPhotoRegistrationService],
})
export class TrekPhotosModule {}
@@ -1,7 +1,12 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@mikro-orm/nestjs';
import { encrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { DatabaseService } from '../database/database.service';
import type { TrekPhoto } from '../../types';
import { TrekPhotos } from '../../db/entities/TrekPhotos.entity';
import type { TrekPhotosRepository } from '../../db/repositories/TrekPhotos.repository';
import { TripPhotos } from '../../db/entities/TripPhotos.entity';
import type { TripPhotosRepository } from '../../db/repositories/TripPhotos.repository';
/**
* The `trek_photos` table: register a photo, look one up, retarget it, drop it
@@ -16,11 +21,24 @@ import type { TrekPhoto } from '../../types';
*
* It also draws the photos/ vs memories/ line: storage lives here, provider
* dispatch stays with memories.
*
* Renamed from `TrekPhotosRepository` (Plan 3e R13): despite the name, it was
* never a MikroORM repository — a `DatabaseService`-backed service that ran
* raw SQL directly — and the old name collided with the generated ORM
* repository this class now injects (`../../db/repositories/TrekPhotos
* .repository.ts`, also `TrekPhotosRepository`, per the `EntityRepositoryType`
* convention every entity in this program follows). Same shape as the 3d
* §18.3 `ReservationsReadRepository` → `ReservationsReadService` precedent.
*/
@Injectable()
export class TrekPhotosRepository {
constructor(private readonly db: DatabaseService) {}
export class TrekPhotoRegistrationService {
constructor(
@InjectRepository(TrekPhotos) private readonly trekPhotos: TrekPhotosRepository,
@InjectRepository(TripPhotos) private readonly tripPhotos: TripPhotosRepository,
private readonly db: DatabaseService,
) {}
/** PH1 — find-or-register a remote provider asset, re-encrypting a changed album passphrase on an existing row. */
async getOrCreate(
provider: string,
assetId: string,
@@ -28,24 +46,24 @@ export class TrekPhotosRepository {
passphrase?: string,
mediaType: string = 'image',
): Promise<number> {
const existing = this.db.get<{ id: number }>(
'SELECT id FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?',
provider, assetId, ownerId,
);
const existing = await this.trekPhotos.findByProviderAsset(provider, assetId, ownerId);
if (existing) {
if (passphrase) {
this.db.run('UPDATE trek_photos SET passphrase = ? WHERE id = ?', encrypt_api_key(passphrase), existing.id);
await this.trekPhotos.setPassphrase(existing.id, encrypt_api_key(passphrase));
}
return existing.id;
}
const res = this.db.run(
'INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type) VALUES (?, ?, ?, ?, ?)',
provider, assetId, ownerId, passphrase ? encrypt_api_key(passphrase) : null, mediaType,
);
return Number(res.lastInsertRowid);
return await this.trekPhotos.insertRemote({
provider,
asset_id: assetId,
owner_id: ownerId,
passphrase: passphrase ? encrypt_api_key(passphrase) : null,
media_type: mediaType,
});
}
/** PH4/PH5 — find-or-register a locally uploaded photo, keyed on its stored path. */
async getOrCreateLocal(
filePath: string,
thumbnailPath?: string | null,
@@ -54,43 +72,40 @@ export class TrekPhotosRepository {
mediaType: string = 'image',
durationMs?: number | null,
): Promise<number> {
const existing = this.db.get<{ id: number }>(
"SELECT id FROM trek_photos WHERE provider = 'local' AND file_path = ?", filePath,
);
const existing = await this.trekPhotos.findLocalByPath(filePath);
if (existing) return existing.id;
const res = this.db.run(
'INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms) VALUES (?, ?, ?, ?, ?, ?, ?)',
'local', filePath, thumbnailPath || null, width || null, height || null, mediaType, durationMs ?? null,
);
return Number(res.lastInsertRowid);
return await this.trekPhotos.insertLocal({
file_path: filePath,
thumbnail_path: thumbnailPath || null,
width: width || null,
height: height || null,
media_type: mediaType,
duration_ms: durationMs ?? null,
});
}
/** PH6 — `SELECT * FROM trek_photos WHERE id = ?`, the same statement `MemoriesAccessService.canAccessTrekPhoto` (MA4) reads through `TrekPhotosRepository.findById`. */
async resolve(photoId: number): Promise<TrekPhoto | null> {
return this.db.get<TrekPhoto>('SELECT * FROM trek_photos WHERE id = ?', photoId) || null;
return await this.trekPhotos.findById(photoId);
}
/** Retarget an existing row — used when a local photo is uploaded to Immich. */
/** PH7 — retarget an existing row, used when a local photo is uploaded to Immich. */
async setProvider(trekPhotoId: number, provider: string, assetId: string, ownerId: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET provider = ?, asset_id = ?, owner_id = ? WHERE id = ?',
provider, assetId, ownerId, trekPhotoId,
);
await this.trekPhotos.retarget(trekPhotoId, provider, assetId, ownerId);
}
/**
* Stamp a generated local thumbnail onto the row. COALESCE keeps dimensions
* that were already known — a re-generated thumbnail must not blank them.
* PH8 — stamp a generated local thumbnail onto the row. COALESCE keeps
* dimensions that were already known — a re-generated thumbnail must not
* blank them.
*/
async recordLocalThumbnail(photoId: number, thumbnailPath: string, width: number, height: number): Promise<void> {
this.db.run(
'UPDATE trek_photos SET thumbnail_path = ?, width = COALESCE(width, ?), height = COALESCE(height, ?) WHERE id = ?',
thumbnailPath, width, height, photoId,
);
await this.trekPhotos.patchThumbnail(photoId, thumbnailPath, width, height);
}
/**
* Where and when the picture was taken (#1614).
* PH9 — where and when the picture was taken (#1614).
*
* COALESCE throughout, for the same reason recordLocalThumbnail does it: the
* three sources disagree in how much they know. A provider search answers with
@@ -98,7 +113,10 @@ export class TrekPhotosRepository {
* only gives them up once its EXIF has been read. Whichever arrives first wins,
* and a later, emptier answer must not blank what is already there.
*
* A photo with neither is the normal case, not a failure.
* A photo with neither is the normal case, not a failure. The all-empty
* early return and the pair-or-neither coordinate guard both stay here —
* the repository's `patchCaptureMetadata` issues the COALESCE write
* unconditionally once called.
*/
async recordCaptureMetadata(
photoId: number,
@@ -109,28 +127,26 @@ export class TrekPhotosRepository {
// Coordinates are stored as a pair or not at all — a lone latitude is not a
// place, and half a pair would put the photo on the null island.
const hasPair = Number.isFinite(lat) && Number.isFinite(lng);
this.db.run(
`UPDATE trek_photos
SET taken_at = COALESCE(taken_at, ?),
lat = COALESCE(lat, ?),
lng = COALESCE(lng, ?)
WHERE id = ?`,
takenAt, hasPair ? lat : null, hasPair ? lng : null, photoId,
);
await this.trekPhotos.patchCaptureMetadata(photoId, takenAt, hasPair ? lat : null, hasPair ? lng : null);
}
/**
* Drop the row once no trip and no journey references it. Local photos are
* kept: their bytes are ours, and the file would outlive the row.
* PH10/PH11 — drop the row once no trip and no journey references it.
* Local photos are kept: their bytes are ours, and the file would outlive
* the row.
*
* PH10's original statement was one `UNION ALL` existence check across
* `trip_photos` and `journey_photos`; split into its two halves here
* (`TripPhotosRepository.existsForPhoto` for the 3e-owned table, a still
* -raw `journey_photos` read for the not-yet-converted Plan 3g table) —
* logically identical (neither half needs to run if the other already
* found a row), never a transaction (both are reads).
*/
async deleteIfOrphan(photoId: number): Promise<void> {
const stillUsed = this.db.get(`
SELECT 1 FROM trip_photos WHERE photo_id = ?
UNION ALL
SELECT 1 FROM journey_photos WHERE photo_id = ?
LIMIT 1
`, photoId, photoId);
if (stillUsed) return;
this.db.run("DELETE FROM trek_photos WHERE id = ? AND provider != 'local'", photoId);
if (await this.tripPhotos.existsForPhoto(photoId)) return;
// PH10 — Plan 3g (journey_photos is not yet an owned repository table).
const stillUsedByJourney = this.db.get('SELECT 1 FROM journey_photos WHERE photo_id = ?', photoId);
if (stillUsedByJourney) return;
await this.trekPhotos.deleteNonLocal(photoId);
}
}