Files
RedditBot/src/services/media.ts
T
jjbrunton 633bb0ec04 Restructure chat service, web routes, and add test suite
Split monolithic chat service into modular structure with Matrix client
support, extract web server into per-resource route modules, introduce
bootstrap/main-loop separation, and add comprehensive vitest coverage
across services, repositories, and utilities. Adds project docs and
CLAUDE.md agent instructions.
2026-05-21 01:32:10 +01:00

256 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,
mediaRoot?: string,
) {
this.mediaRoot = 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.timestamp - a.timestamp);
}
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);
}
}
}