/**
* Media Service - Business logic for managing Postiz media
*
* This service provides high-level operations for:
* - Finding media IDs used in future posts (draft/scheduled/queued)
* - Finding orphaned media (not used in any future posts)
* - Cleaning up orphaned media with dry-run support
*/
import { PostizClient } from './postizClient.js';
import {
PostizPost,
PostizMedia,
CleanupOptions,
CleanupReport,
FUTURE_POST_STATUSES,
} from './types.js';
/**
* MediaService class handles all media-related business logic
*/
export class MediaService {
constructor(private client: PostizClient) {}
/**
* Extract media IDs from a post
* Handles different field names that Postiz might use for media references
*
* IMPORTANT: Adjust the field extraction logic based on your actual Postiz API response structure
*
* @param post - Postiz post object
* @returns Array of media IDs
*/
private extractMediaIds(post: PostizPost): string[] {
const ids: string[] = [];
// Check different possible field names for media references
// Adjust these based on your actual API response
// Option 1: mediaIds field (array of strings)
if (post.mediaIds && Array.isArray(post.mediaIds)) {
ids.push(...post.mediaIds);
}
// Option 2: files field (array of strings)
if (post.files && Array.isArray(post.files)) {
ids.push(...post.files);
}
// Option 3: attachments field (array of strings)
if (post.attachments && Array.isArray(post.attachments)) {
ids.push(...post.attachments);
}
// Option 4: media field (array of objects with id property)
if (post.media && Array.isArray(post.media)) {
const mediaIds = post.media
.filter((m: any) => m && typeof m === 'object' && m.id)
.map((m: any) => m.id);
ids.push(...mediaIds);
}
return ids;
}
/**
* Get all media IDs that are protected (used in future posts)
* Future posts are those with status: draft, scheduled, or queued
*
* @returns Set of media IDs that should not be deleted
*/
async getProtectedMediaIds(): Promise<Set<string>> {
console.log('Fetching protected media IDs from future posts...');
try {
// Fetch posts with future statuses (draft, scheduled, queued)
const futurePosts = await this.client.listPosts({
statuses: [...FUTURE_POST_STATUSES],
});
console.log(`Found ${futurePosts.length} future posts`);
// Extract all media IDs from these posts
const protectedIds = new Set<string>();
for (const post of futurePosts) {
const mediaIds = this.extractMediaIds(post);
mediaIds.forEach((id) => protectedIds.add(id));
}
console.log(`Found ${protectedIds.size} protected media IDs`);
return protectedIds;
} catch (error) {
console.error('Failed to get protected media IDs:', error);
throw new Error(`Failed to fetch protected media IDs: ${(error as Error).message}`);
}
}
/**
* Get all media items from Postiz
*
* @returns Array of all media items
*/
async getAllMedia(): Promise<PostizMedia[]> {
console.log('Fetching all media...');
try {
const allMedia = await this.client.listMedia({
includeDeleted: false, // Only get non-deleted media
});
console.log(`Found ${allMedia.length} total media items`);
return allMedia;
} catch (error) {
console.error('Failed to get all media:', error);
throw new Error(`Failed to fetch all media: ${(error as Error).message}`);
}
}
/**
* Find orphaned media (media not used in any future posts)
*
* @param limit - Optional limit on number of orphan media to return
* @returns Array of orphaned media items
*/
async findOrphanMedia(limit?: number): Promise<PostizMedia[]> {
console.log('Finding orphan media...');
try {
// Get protected media IDs and all media in parallel
const [protectedIds, allMedia] = await Promise.all([
this.getProtectedMediaIds(),
this.getAllMedia(),
]);
// Filter media that are NOT protected and NOT deleted
const orphanMedia = allMedia.filter((media) => {
// Skip if already deleted
if (media.deleted === true || media.deletedAt !== null && media.deletedAt !== undefined) {
return false;
}
// Include if NOT in protected list
return !protectedIds.has(media.id);
});
console.log(`Found ${orphanMedia.length} orphan media items`);
// Apply limit if specified
if (limit && limit > 0) {
return orphanMedia.slice(0, limit);
}
return orphanMedia;
} catch (error) {
console.error('Failed to find orphan media:', error);
throw new Error(`Failed to find orphan media: ${(error as Error).message}`);
}
}
/**
* Delete a single media item by ID
*
* @param id - Media ID to delete
* @returns True if successful, false otherwise
*/
async deleteMediaById(id: string): Promise<{ success: boolean; error?: string }> {
try {
await this.client.deleteMedia(id);
return { success: true };
} catch (error) {
const errorMessage = (error as Error).message;
console.error(`Failed to delete media ${id}:`, errorMessage);
return { success: false, error: errorMessage };
}
}
/**
* Clean up orphaned media with optional dry-run mode
*
* This is the main high-level function for cleaning up unused media.
* - In dry-run mode: only lists media that would be deleted
* - In normal mode: actually deletes the orphaned media
*
* @param options - Cleanup options (dryRun, limit)
* @returns Cleanup report with results
*/
async cleanupOrphanMedia(options: CleanupOptions = {}): Promise<CleanupReport> {
const { dryRun = false, limit } = options;
console.log(`Starting cleanup (dry-run: ${dryRun}, limit: ${limit || 'none'})...`);
try {
// Find orphan media
const orphanMedia = await this.findOrphanMedia(limit);
const report: CleanupReport = {
totalCandidates: orphanMedia.length,
deletedCount: 0,
failedCount: 0,
deletedIds: [],
failedIds: [],
};
// If dry-run, just return the list without deleting
if (dryRun) {
console.log(`DRY RUN: Would delete ${orphanMedia.length} media items`);
return report;
}
// Actually delete each orphan media
console.log(`Deleting ${orphanMedia.length} orphan media items...`);
for (const media of orphanMedia) {
const result = await this.deleteMediaById(media.id);
if (result.success) {
report.deletedCount++;
report.deletedIds.push(media.id);
} else {
report.failedCount++;
report.failedIds.push({
id: media.id,
reason: result.error || 'Unknown error',
});
}
}
console.log(
`Cleanup complete: ${report.deletedCount} deleted, ${report.failedCount} failed`
);
return report;
} catch (error) {
console.error('Failed to cleanup orphan media:', error);
throw new Error(`Failed to cleanup orphan media: ${(error as Error).message}`);
}
}
/**
* Get a summary of media usage statistics
* Useful for monitoring and reporting
*
* @returns Statistics object
*/
async getMediaStats(): Promise<{
totalMedia: number;
protectedMedia: number;
orphanMedia: number;
}> {
try {
const [protectedIds, allMedia] = await Promise.all([
this.getProtectedMediaIds(),
this.getAllMedia(),
]);
const orphanCount = allMedia.filter(
(m) =>
!protectedIds.has(m.id) &&
m.deleted !== true &&
!(m.deletedAt !== null && m.deletedAt !== undefined)
).length;
return {
totalMedia: allMedia.length,
protectedMedia: protectedIds.size,
orphanMedia: orphanCount,
};
} catch (error) {
console.error('Failed to get media stats:', error);
throw new Error(`Failed to get media statistics: ${(error as Error).message}`);
}
}
}
/**
* Create a default media service instance
*/
export function createMediaService(client: PostizClient): MediaService {
return new MediaService(client);
}