261 lines
8.9 KiB
TypeScript
261 lines
8.9 KiB
TypeScript
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<MediaLibrarySafety, 'unknown'>, 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,
|
|
};
|
|
}
|