diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..a351173 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "forgejo": { + "command": "docker", + "args": [ + "run", "--rm", "-i", + "-e", "FORGEJOMCP_TOKEN", + "-e", "FORGEJOMCP_SERVER", + "ronmi/forgejo-mcp", + "stdio" + ], + "env": { + "FORGEJOMCP_SERVER": "https://git.xpltd.co", + "FORGEJOMCP_TOKEN": "d1c855d501446f8b0a97fc7e8c283cad8c94b76c" + } + } + } +} diff --git a/drizzle/0012_adhoc_nullable_channel.sql b/drizzle/0012_adhoc_nullable_channel.sql new file mode 100644 index 0000000..2af59e2 --- /dev/null +++ b/drizzle/0012_adhoc_nullable_channel.sql @@ -0,0 +1,43 @@ +-- Make content_items.channel_id nullable to support ad-hoc URL downloads without a channel. +-- SQLite cannot ALTER COLUMN to remove NOT NULL, so we recreate the table. + +PRAGMA foreign_keys=OFF;--> statement-breakpoint + +CREATE TABLE `content_items_new` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `channel_id` integer, + `title` text NOT NULL, + `platform_content_id` text NOT NULL, + `url` text NOT NULL, + `content_type` text NOT NULL, + `duration` integer, + `file_path` text, + `file_size` integer, + `format` text, + `quality_metadata` text, + `status` text DEFAULT 'monitored' NOT NULL, + `thumbnail_url` text, + `published_at` text, + `downloaded_at` text, + `monitored` integer DEFAULT true NOT NULL, + `created_at` text DEFAULT (datetime('now')) NOT NULL, + `updated_at` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON UPDATE no action ON DELETE cascade +);--> statement-breakpoint + +INSERT INTO `content_items_new` + SELECT `id`, `channel_id`, `title`, `platform_content_id`, `url`, `content_type`, + `duration`, `file_path`, `file_size`, `format`, `quality_metadata`, `status`, + `thumbnail_url`, `published_at`, `downloaded_at`, `monitored`, + `created_at`, `updated_at` + FROM `content_items`;--> statement-breakpoint + +DROP TABLE `content_items`;--> statement-breakpoint + +ALTER TABLE `content_items_new` RENAME TO `content_items`;--> statement-breakpoint + +PRAGMA foreign_keys=ON;--> statement-breakpoint + +-- Seed ad-hoc download setting (enabled by default) +INSERT OR IGNORE INTO `system_config` (`key`, `value`, `created_at`, `updated_at`) + VALUES ('adhoc.enabled', 'true', datetime('now'), datetime('now')); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9320eb7..73e2150 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1775253600000, "tag": "0011_add_youtube_enhancements", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1775520000000, + "tag": "0012_adhoc_nullable_channel", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index 101dcb2..06197b7 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -9,7 +9,7 @@ import type { ContentCounts } from '../../types/api'; /** Fields needed to create a new content item. */ export interface CreateContentItemData { - channelId: number; + channelId: number | null; title: string; platformContentId: string; url: string; @@ -43,16 +43,22 @@ export async function createContentItem( db: Db, data: CreateContentItemData ): Promise { - // Check for existing item first — dedup by (channelId, platformContentId) - const existing = await db - .select({ id: contentItems.id }) - .from(contentItems) - .where( - and( + // Check for existing item — dedup by (channelId, platformContentId) for channel items, + // or by platformContentId alone for ad-hoc items (channelId=null) + const dedupConditions = data.channelId !== null + ? and( eq(contentItems.channelId, data.channelId), eq(contentItems.platformContentId, data.platformContentId) ) - ) + : and( + sql`${contentItems.channelId} IS NULL`, + eq(contentItems.platformContentId, data.platformContentId) + ); + + const existing = await db + .select({ id: contentItems.id }) + .from(contentItems) + .where(dedupConditions) .limit(1); if (existing.length > 0) { @@ -167,21 +173,26 @@ function resolveSortColumn(sortBy?: string) { } } -/** Check if a specific content item exists for a channel. Returns the item or null. */ +/** Check if a specific content item exists for a channel (or ad-hoc if channelId is null). Returns the item or null. */ export async function getContentByPlatformContentId( db: Db, - channelId: number, + channelId: number | null, platformContentId: string ): Promise { - const rows = await db - .select() - .from(contentItems) - .where( - and( + const conditions = channelId !== null + ? and( eq(contentItems.channelId, channelId), eq(contentItems.platformContentId, platformContentId) ) - ) + : and( + sql`${contentItems.channelId} IS NULL`, + eq(contentItems.platformContentId, platformContentId) + ); + + const rows = await db + .select() + .from(contentItems) + .where(conditions) .limit(1); return rows.length > 0 ? mapRow(rows[0]) : null; @@ -386,7 +397,8 @@ export async function getContentCountsByChannelIds( const map = new Map(); for (const row of rows) { - map.set(row.channelId, { + // channelId is always non-null here — we're filtering by specific channelIds via inArray + map.set(row.channelId!, { total: Number(row.total), monitored: Number(row.monitored), downloaded: Number(row.downloaded), diff --git a/src/db/schema/content.ts b/src/db/schema/content.ts index 8fc22dd..dcfff15 100644 --- a/src/db/schema/content.ts +++ b/src/db/schema/content.ts @@ -6,7 +6,6 @@ import { channels } from './channels'; export const contentItems = sqliteTable('content_items', { id: integer('id').primaryKey({ autoIncrement: true }), channelId: integer('channel_id') - .notNull() .references(() => channels.id, { onDelete: 'cascade' }), title: text('title').notNull(), platformContentId: text('platform_content_id').notNull(), diff --git a/src/services/queue.ts b/src/services/queue.ts index 907a702..a1cabd0 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -304,21 +304,26 @@ export class QueueService { throw new Error(`Content item ${queueItem.contentItemId} not found`); } - const channel = await getChannelById(this.db, contentItem.channelId); - if (!channel) { + const channel = contentItem.channelId + ? await getChannelById(this.db, contentItem.channelId) + : null; + if (contentItem.channelId && !channel) { throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`); } // Resolve format profile: channel-specific > default > undefined let formatProfile = undefined; - if (channel.formatProfileId) { + if (channel?.formatProfileId) { formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined; } if (!formatProfile) { formatProfile = await getDefaultFormatProfile(this.db) ?? undefined; } - // Execute download + // Execute download — T03 will extend downloadItem to handle null channel for ad-hoc downloads + if (!channel) { + throw new Error(`Ad-hoc download support not yet implemented for content item ${contentItem.id}`); + } await this.downloadService.downloadItem(contentItem, channel, formatProfile); // Success — mark completed @@ -329,7 +334,7 @@ export class QueueService { // Record downloaded history event await createHistoryEvent(this.db, { contentItemId: queueItem.contentItemId, - channelId: channel.id, + channelId: channel?.id ?? null, eventType: 'downloaded', status: 'completed', details: { @@ -346,8 +351,8 @@ export class QueueService { try { this.onDownloadComplete({ contentTitle: contentItem.title, - channelName: channel.name, - platform: channel.platform, + channelName: channel?.name ?? 'Ad-hoc', + platform: channel?.platform ?? 'generic', url: contentItem.url, filePath: contentItem.filePath ?? undefined, }); diff --git a/src/types/index.ts b/src/types/index.ts index e6150ba..c8866bb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -85,7 +85,7 @@ export interface Channel { export interface ContentItem { id: number; - channelId: number; + channelId: number | null; title: string; platformContentId: string; url: string;