chore: Made content_items.channelId nullable via SQLite table recreatio…

- "src/db/schema/content.ts"
- "src/types/index.ts"
- "src/db/repositories/content-repository.ts"
- "src/services/queue.ts"
- "drizzle/0012_adhoc_nullable_channel.sql"
- "drizzle/meta/_journal.json"

GSD-Task: S01/T01
This commit is contained in:
jlightner 2026-04-04 05:03:40 +00:00
parent 9e5033026e
commit 8150b1f6cf
7 changed files with 111 additions and 27 deletions

18
.mcp.json Normal file
View file

@ -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"
}
}
}
}

View file

@ -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'));

View file

@ -85,6 +85,13 @@
"when": 1775253600000, "when": 1775253600000,
"tag": "0011_add_youtube_enhancements", "tag": "0011_add_youtube_enhancements",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1775520000000,
"tag": "0012_adhoc_nullable_channel",
"breakpoints": true
} }
] ]
} }

View file

@ -9,7 +9,7 @@ import type { ContentCounts } from '../../types/api';
/** Fields needed to create a new content item. */ /** Fields needed to create a new content item. */
export interface CreateContentItemData { export interface CreateContentItemData {
channelId: number; channelId: number | null;
title: string; title: string;
platformContentId: string; platformContentId: string;
url: string; url: string;
@ -43,16 +43,22 @@ export async function createContentItem(
db: Db, db: Db,
data: CreateContentItemData data: CreateContentItemData
): Promise<ContentItem | null> { ): Promise<ContentItem | null> {
// Check for existing item first — dedup by (channelId, platformContentId) // Check for existing item — dedup by (channelId, platformContentId) for channel items,
const existing = await db // or by platformContentId alone for ad-hoc items (channelId=null)
.select({ id: contentItems.id }) const dedupConditions = data.channelId !== null
.from(contentItems) ? and(
.where(
and(
eq(contentItems.channelId, data.channelId), eq(contentItems.channelId, data.channelId),
eq(contentItems.platformContentId, data.platformContentId) 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); .limit(1);
if (existing.length > 0) { 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( export async function getContentByPlatformContentId(
db: Db, db: Db,
channelId: number, channelId: number | null,
platformContentId: string platformContentId: string
): Promise<ContentItem | null> { ): Promise<ContentItem | null> {
const rows = await db const conditions = channelId !== null
.select() ? and(
.from(contentItems)
.where(
and(
eq(contentItems.channelId, channelId), eq(contentItems.channelId, channelId),
eq(contentItems.platformContentId, platformContentId) 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); .limit(1);
return rows.length > 0 ? mapRow(rows[0]) : null; return rows.length > 0 ? mapRow(rows[0]) : null;
@ -386,7 +397,8 @@ export async function getContentCountsByChannelIds(
const map = new Map<number, ContentCounts>(); const map = new Map<number, ContentCounts>();
for (const row of rows) { 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), total: Number(row.total),
monitored: Number(row.monitored), monitored: Number(row.monitored),
downloaded: Number(row.downloaded), downloaded: Number(row.downloaded),

View file

@ -6,7 +6,6 @@ import { channels } from './channels';
export const contentItems = sqliteTable('content_items', { export const contentItems = sqliteTable('content_items', {
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
channelId: integer('channel_id') channelId: integer('channel_id')
.notNull()
.references(() => channels.id, { onDelete: 'cascade' }), .references(() => channels.id, { onDelete: 'cascade' }),
title: text('title').notNull(), title: text('title').notNull(),
platformContentId: text('platform_content_id').notNull(), platformContentId: text('platform_content_id').notNull(),

View file

@ -304,21 +304,26 @@ export class QueueService {
throw new Error(`Content item ${queueItem.contentItemId} not found`); throw new Error(`Content item ${queueItem.contentItemId} not found`);
} }
const channel = await getChannelById(this.db, contentItem.channelId); const channel = contentItem.channelId
if (!channel) { ? await getChannelById(this.db, contentItem.channelId)
: null;
if (contentItem.channelId && !channel) {
throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`); throw new Error(`Channel ${contentItem.channelId} not found for content item ${contentItem.id}`);
} }
// Resolve format profile: channel-specific > default > undefined // Resolve format profile: channel-specific > default > undefined
let formatProfile = undefined; let formatProfile = undefined;
if (channel.formatProfileId) { if (channel?.formatProfileId) {
formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined; formatProfile = await getFormatProfileById(this.db, channel.formatProfileId) ?? undefined;
} }
if (!formatProfile) { if (!formatProfile) {
formatProfile = await getDefaultFormatProfile(this.db) ?? undefined; 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); await this.downloadService.downloadItem(contentItem, channel, formatProfile);
// Success — mark completed // Success — mark completed
@ -329,7 +334,7 @@ export class QueueService {
// Record downloaded history event // Record downloaded history event
await createHistoryEvent(this.db, { await createHistoryEvent(this.db, {
contentItemId: queueItem.contentItemId, contentItemId: queueItem.contentItemId,
channelId: channel.id, channelId: channel?.id ?? null,
eventType: 'downloaded', eventType: 'downloaded',
status: 'completed', status: 'completed',
details: { details: {
@ -346,8 +351,8 @@ export class QueueService {
try { try {
this.onDownloadComplete({ this.onDownloadComplete({
contentTitle: contentItem.title, contentTitle: contentItem.title,
channelName: channel.name, channelName: channel?.name ?? 'Ad-hoc',
platform: channel.platform, platform: channel?.platform ?? 'generic',
url: contentItem.url, url: contentItem.url,
filePath: contentItem.filePath ?? undefined, filePath: contentItem.filePath ?? undefined,
}); });

View file

@ -85,7 +85,7 @@ export interface Channel {
export interface ContentItem { export interface ContentItem {
id: number; id: number;
channelId: number; channelId: number | null;
title: string; title: string;
platformContentId: string; platformContentId: string;
url: string; url: string;