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:
parent
9e5033026e
commit
8150b1f6cf
7 changed files with 111 additions and 27 deletions
18
.mcp.json
Normal file
18
.mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
43
drizzle/0012_adhoc_nullable_channel.sql
Normal file
43
drizzle/0012_adhoc_nullable_channel.sql
Normal 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'));
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<ContentItem | null> {
|
||||
// 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<ContentItem | null> {
|
||||
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<number, ContentCounts>();
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ export interface Channel {
|
|||
|
||||
export interface ContentItem {
|
||||
id: number;
|
||||
channelId: number;
|
||||
channelId: number | null;
|
||||
title: string;
|
||||
platformContentId: string;
|
||||
url: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue