import type Database from 'better-sqlite3'; import { MEDIA_LIBRARY_TAGS, type MediaLibraryAsset, type MediaLibrarySafety, type MediaLibrarySend, type MediaLibraryTag, type MediaRequestKind, } from '../services/media-library-types.js'; interface AssetRow { asset_id: string; tags_json: string; safety: MediaLibrarySafety; classification_status: 'ready' | 'failed'; description: string | null; confidence: number | null; file_name: string; relative_path: string; mime_type: string; size_bytes: number; width: number; height: number; enabled: number; consent_confirmed_at: number; created_at: number; updated_at: number; send_count?: number; last_sent_at?: number | null; } interface SendRow { id: number; asset_id: string; account_id: string; conversation_id: string; incoming_key: string; request_kind: MediaRequestKind; status: MediaLibrarySend['status']; requested_at: number; sent_at: number | null; matrix_media_event_id: string | null; matrix_text_event_id: string | null; mxc_uri: string | null; error: string | null; initiated_by: 'automation' | 'human'; } export interface CreateMediaLibraryAsset { id: string; tags: MediaLibraryTag[]; safety: MediaLibrarySafety; classificationStatus: 'ready' | 'failed'; description: string | null; confidence: number | null; fileName: string; relativePath: string; mimeType: string; sizeBytes: number; width: number; height: number; sha256: string; enabled: boolean; consentConfirmedAt: number; createdAt: number; } /** Process-global metadata and durable outbound-use ledger for operator media. */ export class MediaLibraryRepository { constructor(private readonly db: Database.Database) {} create(input: CreateMediaLibraryAsset): MediaLibraryAsset { this.db.prepare(` INSERT INTO media_library_assets ( asset_id, tags_json, safety, classification_status, description, confidence, file_name, relative_path, mime_type, size_bytes, width, height, sha256, enabled, consent_confirmed_at, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( input.id, JSON.stringify(input.tags), input.safety, input.classificationStatus, input.description, input.confidence, input.fileName, input.relativePath, input.mimeType, input.sizeBytes, input.width, input.height, input.sha256, input.enabled ? 1 : 0, input.consentConfirmedAt, input.createdAt, input.createdAt, ); return this.get(input.id)!; } get(id: string): MediaLibraryAsset | undefined { const row = this.db.prepare(` SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at FROM media_library_assets asset LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent' WHERE asset.asset_id = ? AND asset.deleted_at IS NULL GROUP BY asset.asset_id `).get(id) as AssetRow | undefined; return row ? mapAsset(row) : undefined; } getBySha256(sha256: string): MediaLibraryAsset | undefined { const row = this.db.prepare(` SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at FROM media_library_assets asset LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent' WHERE asset.sha256 = ? AND asset.deleted_at IS NULL GROUP BY asset.asset_id `).get(sha256) as AssetRow | undefined; return row ? mapAsset(row) : undefined; } list(): MediaLibraryAsset[] { return (this.db.prepare(` SELECT asset.*, COUNT(sends.sent_at) AS send_count, MAX(sends.sent_at) AS last_sent_at FROM media_library_assets asset LEFT JOIN media_library_sends sends ON sends.asset_id = asset.asset_id AND sends.status = 'sent' WHERE asset.deleted_at IS NULL GROUP BY asset.asset_id ORDER BY asset.created_at DESC `).all() as AssetRow[]).map(mapAsset); } listEligible(): MediaLibraryAsset[] { return (this.db.prepare(` SELECT * FROM media_library_assets WHERE deleted_at IS NULL AND enabled = 1 AND classification_status = 'ready' ORDER BY created_at DESC `).all() as AssetRow[]).map(mapAsset); } update(id: string, tags: MediaLibraryTag[], safety: Exclude, enabled: boolean, now = Date.now()): MediaLibraryAsset | undefined { this.db.prepare(` UPDATE media_library_assets SET tags_json = ?, safety = ?, classification_status = 'ready', enabled = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL `).run(JSON.stringify(tags), safety, enabled ? 1 : 0, now, id); return this.get(id); } softDelete(id: string, now = Date.now()): boolean { return this.db.prepare(` UPDATE media_library_assets SET enabled = 0, deleted_at = ?, updated_at = ?, sha256 = sha256 || ':deleted:' || asset_id WHERE asset_id = ? AND deleted_at IS NULL `).run(now, now, id).changes > 0; } getSend(accountId: string, conversationId: string, incomingKey: string): MediaLibrarySend | undefined { const row = this.db.prepare(` SELECT * FROM media_library_sends WHERE account_id = ? AND conversation_id = ? AND incoming_key = ? `).get(accountId, conversationId, incomingKey) as SendRow | undefined; return row ? mapSend(row) : undefined; } listConversationSends(accountId: string, conversationId: string): MediaLibrarySend[] { return (this.db.prepare(` SELECT * FROM media_library_sends WHERE account_id = ? AND conversation_id = ? ORDER BY requested_at ASC, id ASC `).all(accountId, conversationId) as SendRow[]).map(mapSend); } tryReserve( assetId: string, accountId: string, conversationId: string, incomingKey: string, requestKind: MediaRequestKind, initiatedBy: 'automation' | 'human' = 'automation', now = Date.now(), ): MediaLibrarySend | null { try { this.db.prepare(` INSERT INTO media_library_sends ( asset_id, asset_sha256, account_id, conversation_id, incoming_key, request_kind, status, requested_at, initiated_by ) SELECT asset_id, sha256, ?, ?, ?, ?, 'pending', ?, ? FROM media_library_assets WHERE asset_id = ? AND deleted_at IS NULL AND enabled = 1 `).run(accountId, conversationId, incomingKey, requestKind, now, initiatedBy, assetId); return this.getSend(accountId, conversationId, incomingKey) ?? null; } catch (error) { if (error instanceof Error && /UNIQUE constraint failed/.test(error.message)) return null; throw error; } } markMediaSent(id: number, mxcUri: string, eventId: string): void { this.db.prepare(` UPDATE media_library_sends SET mxc_uri = ?, matrix_media_event_id = ?, error = NULL WHERE id = ? AND status = 'pending' `).run(mxcUri, eventId, id); } markSent(id: number, textEventId: string, now = Date.now()): boolean { return this.db.prepare(` UPDATE media_library_sends SET status = 'sent', matrix_text_event_id = ?, sent_at = ?, error = NULL WHERE id = ? AND status = 'pending' AND matrix_media_event_id IS NOT NULL `).run(textEventId, now, id).changes > 0; } markFailed(id: number, error: string): boolean { return this.db.prepare(` UPDATE media_library_sends SET status = 'failed', error = ? WHERE id = ? AND status = 'pending' `).run(error.slice(0, 500), id).changes > 0; } } function mapAsset(row: AssetRow): MediaLibraryAsset { let tags: MediaLibraryTag[] = []; try { const parsed = JSON.parse(row.tags_json) as unknown; if (Array.isArray(parsed)) { tags = parsed.filter((tag): tag is MediaLibraryTag => typeof tag === 'string' && (MEDIA_LIBRARY_TAGS as readonly string[]).includes(tag)); } } catch { /* invalid legacy data remains disabled by service validation */ } return { id: row.asset_id, tags, safety: row.safety, classificationStatus: row.classification_status, description: row.description, confidence: row.confidence, fileName: row.file_name, relativePath: row.relative_path, mimeType: row.mime_type, sizeBytes: row.size_bytes, width: row.width, height: row.height, enabled: Boolean(row.enabled), consentConfirmedAt: row.consent_confirmed_at, createdAt: row.created_at, updatedAt: row.updated_at, sendCount: row.send_count ?? 0, lastSentAt: row.last_sent_at ?? null, }; } function mapSend(row: SendRow): MediaLibrarySend { return { id: row.id, assetId: row.asset_id, accountId: row.account_id, conversationId: row.conversation_id, incomingKey: row.incoming_key, requestKind: row.request_kind, status: row.status, requestedAt: row.requested_at, sentAt: row.sent_at, matrixMediaEventId: row.matrix_media_event_id, matrixTextEventId: row.matrix_text_event_id, mxcUri: row.mxc_uri, error: row.error, initiatedBy: row.initiated_by, }; }