Add gitignore rules for browser session data, SQLite databases, media downloads, and build artifacts. Remove previously tracked runtime files from the repository. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
255 lines
7.4 KiB
TypeScript
255 lines
7.4 KiB
TypeScript
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import { z } from 'zod';
|
|
import type { Logger } from '../utils/logger.js';
|
|
import type { ActiveChat, ChatMedia, ChatMessage } from './chat.js';
|
|
|
|
export interface MediaDownloadResult {
|
|
buffer: Buffer;
|
|
contentType: string | null;
|
|
}
|
|
|
|
export interface MediaDownloader {
|
|
fetch(url: string): Promise<MediaDownloadResult | null>;
|
|
}
|
|
|
|
export interface MediaRecord {
|
|
id: string;
|
|
chatUsername: string | null;
|
|
chatRoomId: string | null;
|
|
sender: string;
|
|
timestamp: number;
|
|
originalUrl: string;
|
|
fileName: string;
|
|
relativePath: string;
|
|
createdAt: number;
|
|
}
|
|
|
|
const MediaIndexSchema = z.object({
|
|
version: z.number(),
|
|
savedAt: z.number(),
|
|
items: z.array(
|
|
z.object({
|
|
id: z.string(),
|
|
chatUsername: z.string().nullable(),
|
|
chatRoomId: z.string().nullable(),
|
|
sender: z.string(),
|
|
timestamp: z.number(),
|
|
originalUrl: z.string(),
|
|
fileName: z.string(),
|
|
relativePath: z.string(),
|
|
createdAt: z.number(),
|
|
})
|
|
),
|
|
});
|
|
|
|
type MediaIndex = z.infer<typeof MediaIndexSchema>;
|
|
|
|
interface ScrapedChatMessage extends ChatMessage {
|
|
mediaUrls: string[];
|
|
}
|
|
|
|
export class MediaService {
|
|
private readonly mediaRoot: string;
|
|
private readonly indexPath: string;
|
|
private index: MediaIndex | null = null;
|
|
|
|
constructor(
|
|
private downloader: MediaDownloader,
|
|
private logger: Logger
|
|
) {
|
|
this.mediaRoot = path.join(process.cwd(), 'data', 'media');
|
|
this.indexPath = path.join(this.mediaRoot, 'index.json');
|
|
}
|
|
|
|
async attachMedia(chat: ActiveChat, messages: ScrapedChatMessage[]): Promise<ChatMessage[]> {
|
|
await this.loadIndex();
|
|
const updated: ChatMessage[] = [];
|
|
|
|
for (const message of messages) {
|
|
const media = await this.downloadMessageMedia(chat, message);
|
|
updated.push({
|
|
sender: message.sender,
|
|
text: message.text,
|
|
timestamp: message.timestamp,
|
|
isSelf: message.isSelf,
|
|
media: media.length > 0 ? media : undefined,
|
|
});
|
|
}
|
|
|
|
await this.saveIndex();
|
|
return updated;
|
|
}
|
|
|
|
async getMediaItems(): Promise<MediaRecord[]> {
|
|
await this.loadIndex();
|
|
return (this.index?.items || []).slice().sort((a, b) => b.createdAt - a.createdAt);
|
|
}
|
|
|
|
private async downloadMessageMedia(chat: ActiveChat, message: ScrapedChatMessage): Promise<ChatMedia[]> {
|
|
if (!message.mediaUrls || message.mediaUrls.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const mediaItems: ChatMedia[] = [];
|
|
|
|
for (const url of message.mediaUrls) {
|
|
const record = await this.downloadMedia(chat, message, url);
|
|
if (record) {
|
|
mediaItems.push({
|
|
originalUrl: record.originalUrl,
|
|
localPath: record.relativePath,
|
|
fileName: record.fileName,
|
|
});
|
|
}
|
|
}
|
|
|
|
return mediaItems;
|
|
}
|
|
|
|
/**
|
|
* Convert Reddit Matrix blurred thumbnail URLs to full-resolution download URLs.
|
|
* Blurred: https://matrix.redditspace.com/_matrix/media/v3/thumbnail/reddit.com/blur_XXXXX?height=300
|
|
* Full: https://matrix.redditspace.com/_matrix/media/v3/download/reddit.com/XXXXX
|
|
*/
|
|
private resolveFullMediaUrl(url: string): string {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.hostname === 'matrix.redditspace.com' && parsed.pathname.includes('/thumbnail/')) {
|
|
// Strip blur_ prefix from the media ID
|
|
const fullPath = parsed.pathname.replace('/thumbnail/', '/download/').replace('/blur_', '/');
|
|
return `${parsed.origin}${fullPath}`;
|
|
}
|
|
} catch { /* keep original */ }
|
|
return url;
|
|
}
|
|
|
|
private async downloadMedia(chat: ActiveChat, message: ScrapedChatMessage, url: string): Promise<MediaRecord | null> {
|
|
if (!url || url.startsWith('data:')) {
|
|
return null;
|
|
}
|
|
|
|
// Try the full-resolution URL first, fall back to original
|
|
const fullUrl = this.resolveFullMediaUrl(url);
|
|
const id = crypto.createHash('sha1').update(fullUrl).digest('hex');
|
|
const existing = this.index?.items.find((item) => item.id === id);
|
|
if (existing) {
|
|
// Verify the file still exists on disk
|
|
const existingPath = path.join(this.mediaRoot, existing.fileName);
|
|
try {
|
|
await fs.access(existingPath);
|
|
return existing;
|
|
} catch {
|
|
// File missing — remove stale record and re-download
|
|
this.logger.debug(`Media file missing on disk, re-downloading: ${existing.fileName}`);
|
|
this.index!.items = this.index!.items.filter((item) => item.id !== id);
|
|
}
|
|
}
|
|
|
|
let download = await this.downloader.fetch(fullUrl);
|
|
if (!download && fullUrl !== url) {
|
|
this.logger.debug(`Full-res download failed, trying original URL: ${url}`);
|
|
download = await this.downloader.fetch(url);
|
|
}
|
|
if (!download) {
|
|
this.logger.warn(`Failed to download chat media from ${fullUrl}`);
|
|
return null;
|
|
}
|
|
|
|
const extension = this.pickExtension(url, download.contentType);
|
|
if (!extension) {
|
|
this.logger.debug(`Skipping non-image media from ${url}`);
|
|
return null;
|
|
}
|
|
|
|
const fileName = `${id}${extension}`;
|
|
const relativePath = fileName;
|
|
const fullPath = path.join(this.mediaRoot, fileName);
|
|
|
|
await fs.mkdir(this.mediaRoot, { recursive: true });
|
|
await fs.writeFile(fullPath, download.buffer);
|
|
|
|
const record: MediaRecord = {
|
|
id,
|
|
chatUsername: chat.username || null,
|
|
chatRoomId: chat.roomId || null,
|
|
sender: message.sender,
|
|
timestamp: message.timestamp,
|
|
originalUrl: fullUrl,
|
|
fileName,
|
|
relativePath,
|
|
createdAt: Date.now(),
|
|
};
|
|
|
|
if (!this.index) {
|
|
this.index = { version: 1, savedAt: Date.now(), items: [record] };
|
|
} else {
|
|
this.index.items.push(record);
|
|
this.index.savedAt = Date.now();
|
|
}
|
|
|
|
return record;
|
|
}
|
|
|
|
private pickExtension(url: string, contentType: string | null): string | null {
|
|
let urlExt = '';
|
|
try {
|
|
urlExt = path.extname(new URL(url).pathname || '').toLowerCase();
|
|
} catch {
|
|
urlExt = path.extname(url.split('?')[0] || '').toLowerCase();
|
|
}
|
|
if (urlExt && urlExt.length <= 5) {
|
|
return urlExt;
|
|
}
|
|
|
|
if (!contentType) {
|
|
return null;
|
|
}
|
|
|
|
if (contentType.includes('image/jpeg')) return '.jpg';
|
|
if (contentType.includes('image/png')) return '.png';
|
|
if (contentType.includes('image/gif')) return '.gif';
|
|
if (contentType.includes('image/webp')) return '.webp';
|
|
if (contentType.includes('image/avif')) return '.avif';
|
|
|
|
return null;
|
|
}
|
|
|
|
private async loadIndex(): Promise<void> {
|
|
if (this.index) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const raw = await fs.readFile(this.indexPath, 'utf-8');
|
|
const parsed = MediaIndexSchema.safeParse(JSON.parse(raw));
|
|
if (!parsed.success) {
|
|
this.logger.warn('Media index is invalid, starting fresh.');
|
|
this.index = { version: 1, savedAt: Date.now(), items: [] };
|
|
return;
|
|
}
|
|
|
|
this.index = parsed.data;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
this.logger.warn('Failed to load media index, starting fresh:', error);
|
|
}
|
|
this.index = { version: 1, savedAt: Date.now(), items: [] };
|
|
}
|
|
}
|
|
|
|
private async saveIndex(): Promise<void> {
|
|
if (!this.index) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await fs.mkdir(this.mediaRoot, { recursive: true });
|
|
await fs.writeFile(this.indexPath, JSON.stringify(this.index, null, 2), 'utf-8');
|
|
} catch (error) {
|
|
this.logger.warn('Failed to save media index:', error);
|
|
}
|
|
}
|
|
}
|