diff --git a/src/__tests__/content-api.test.ts b/src/__tests__/content-api.test.ts index a61670b..67fe6bb 100644 --- a/src/__tests__/content-api.test.ts +++ b/src/__tests__/content-api.test.ts @@ -260,6 +260,20 @@ describe('content-api', () => { expect(body.data.every((item: { channelId: number }) => item.channelId === channelA.id)).toBe(true); }); + it('filters by contentType when paginated', async () => { + const res = await server.inject({ + method: 'GET', + url: `/api/v1/channel/${channelA.id}/content?contentType=video&page=1`, + headers: { 'x-api-key': apiKey }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toHaveLength(2); + expect(body.data.every((item: { contentType: string }) => item.contentType === 'video')).toBe(true); + expect(body.pagination.totalItems).toBe(2); + }); + it('returns empty array for channel with no content', async () => { const noContentChannel = await createChannel(db, { name: 'Empty Channel', @@ -306,4 +320,89 @@ describe('content-api', () => { expect(res.statusCode).toBe(401); }); }); + + // ── GET /api/v1/channel/:id/content-counts ── + + describe('GET /api/v1/channel/:id/content-counts', () => { + it('returns per-type counts for a channel with mixed content', async () => { + const res = await server.inject({ + method: 'GET', + url: `/api/v1/channel/${channelA.id}/content-counts`, + headers: { 'x-api-key': apiKey }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.data).toEqual({ + video: 2, + audio: 0, + livestream: 1, + }); + }); + + it('returns per-type counts for audio-only channel', async () => { + const res = await server.inject({ + method: 'GET', + url: `/api/v1/channel/${channelB.id}/content-counts`, + headers: { 'x-api-key': apiKey }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toEqual({ + video: 0, + audio: 2, + livestream: 0, + }); + }); + + it('returns all zeros for channel with no content', async () => { + // Create a fresh empty channel + const emptyChannel = await createChannel(db, { + name: 'Counts Empty', + platform: 'youtube', + platformId: 'UC_counts_empty', + url: 'https://www.youtube.com/channel/UC_counts_empty', + monitoringEnabled: true, + checkInterval: 360, + imageUrl: null, + metadata: null, + formatProfileId: null, + }); + + const res = await server.inject({ + method: 'GET', + url: `/api/v1/channel/${emptyChannel.id}/content-counts`, + headers: { 'x-api-key': apiKey }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.data).toEqual({ + video: 0, + audio: 0, + livestream: 0, + }); + }); + + it('returns 400 for invalid channel ID', async () => { + const res = await server.inject({ + method: 'GET', + url: '/api/v1/channel/abc/content-counts', + headers: { 'x-api-key': apiKey }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('returns 401 without API key', async () => { + const res = await server.inject({ + method: 'GET', + url: `/api/v1/channel/${channelA.id}/content-counts`, + }); + + expect(res.statusCode).toBe(401); + }); + }); }); diff --git a/src/db/repositories/content-repository.ts b/src/db/repositories/content-repository.ts index f3220ae..339dc34 100644 --- a/src/db/repositories/content-repository.ts +++ b/src/db/repositories/content-repository.ts @@ -3,7 +3,7 @@ import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../schema/index'; import { contentItems } from '../schema/index'; import type { ContentItem, ContentType, ContentStatus, QualityInfo } from '../../types/index'; -import type { ContentCounts } from '../../types/api'; +import type { ContentCounts, ContentTypeCounts } from '../../types/api'; // ── Types ── @@ -373,6 +373,35 @@ function buildContentFilterConditions(filters?: ContentItemFilters) { return conditions; } +// ── Content Counts by Type ── + +/** + * Get content counts grouped by content type for a single channel. + * Returns { video, audio, livestream } with zero-defaults for missing types. + */ +export async function getContentCountsByType( + db: Db, + channelId: number +): Promise { + const rows = await db + .select({ + contentType: contentItems.contentType, + count: sql`count(*)`, + }) + .from(contentItems) + .where(eq(contentItems.channelId, channelId)) + .groupBy(contentItems.contentType); + + const counts: ContentTypeCounts = { video: 0, audio: 0, livestream: 0 }; + for (const row of rows) { + const ct = row.contentType as keyof ContentTypeCounts; + if (ct in counts) { + counts[ct] = Number(row.count); + } + } + return counts; +} + // ── Content Counts by Channel ── /** diff --git a/src/server/routes/content.ts b/src/server/routes/content.ts index 9689e9b..279b49c 100644 --- a/src/server/routes/content.ts +++ b/src/server/routes/content.ts @@ -4,11 +4,12 @@ import { getAllContentItems, getContentByChannelId, getChannelContentPaginated, + getContentCountsByType, setMonitored, bulkSetMonitored, updateContentItem, } from '../../db/repositories/content-repository'; -import type { PaginatedResponse, ApiResponse } from '../../types/api'; +import type { PaginatedResponse, ApiResponse, ContentTypeCounts } from '../../types/api'; import type { ContentItem, ContentStatus, ContentType } from '../../types/index'; // ── JSON Schemas for Fastify Validation ── @@ -254,6 +255,34 @@ export async function contentRoutes(fastify: FastifyInstance): Promise { } ); + // ── GET /api/v1/channel/:id/content-counts ── + + fastify.get<{ + Params: { id: string }; + }>('/api/v1/channel/:id/content-counts', async (request, reply) => { + const channelId = parseIdParam(request.params.id, reply, 'Channel ID'); + if (channelId === null) return; + + try { + const counts = await getContentCountsByType(fastify.db, channelId); + const response: ApiResponse = { + success: true, + data: counts, + }; + return response; + } catch (err) { + request.log.error( + { err, channelId }, + '[content] Failed to fetch content type counts' + ); + return reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + message: `Failed to retrieve content type counts for channel ${channelId}`, + }); + } + }); + // ── GET /api/v1/channel/:id/content (paginated) ── fastify.get<{ diff --git a/src/types/api.ts b/src/types/api.ts index afb373d..6195648 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -70,6 +70,13 @@ export interface ContentCounts { downloaded: number; } +/** Per-content-type counts for a channel. */ +export interface ContentTypeCounts { + video: number; + audio: number; + livestream: number; +} + /** App-wide settings (check interval, concurrent downloads). */ export interface AppSettingsResponse { checkInterval: number;