diff --git a/.env.example b/.env.example index 158e9d82..2445bb11 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,14 @@ R2_BUCKET_NAME=videosphere-uploads # Optional: restrict allowed browser origin for R2 CORS behavior in your app logic # R2_ALLOWED_ORIGIN=https://your-domain.com +# ----------------------------------------------------------------------------- +# YouTube import (download + trim from connected YouTube account) +# ----------------------------------------------------------------------------- +# Scratch directory for in-progress downloads and trims (default: /tmp/yt-import) +# YT_IMPORT_WORKDIR=/tmp/yt-import +# Hard cap on source video length in seconds (default: 14400 / 4h) +# YT_IMPORT_MAX_DURATION_SECONDS=14400 + # ----------------------------------------------------------------------------- # OpenRouter (AI metadata generation) # ----------------------------------------------------------------------------- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1286b155..48bb49d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,8 @@ docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile . Omit `--load` and `--push` to discard images after a successful build. Cross-arch builds on amd64 require QEMU (`qemu-user-static` and binfmt). +To **build and run** the image locally (amd64 smoke test, OAuth, production server), see [docs/local-docker-testing.md](docs/local-docker-testing.md). + ## Pull Request Process 1. **Fill out the PR template** — it's provided automatically when you open a PR diff --git a/Dockerfile b/Dockerfile index e3424321..d156eb4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 RUN pnpm build FROM node:${NODE_VERSION}-alpine AS runner -RUN apk add --no-cache ffmpeg +ARG YT_DLP_VERSION=2026.2.21 +RUN apk add --no-cache ffmpeg python3 py3-pip \ + && pip3 install --break-system-packages "yt-dlp[default]==${YT_DLP_VERSION}" \ + && yt-dlp --version && ffmpeg -version WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/SETUP.md b/SETUP.md index 88223b2d..b29776bd 100644 --- a/SETUP.md +++ b/SETUP.md @@ -92,10 +92,10 @@ an explicit Compose env file, so this command must include `--env-file .env.loca ### Option B: Docker run (also valid for local development) -If you prefer running Mongo directly, this is supported: +If you prefer running Mongo directly, this is supported. Use the **fully qualified** image name — Podman does not resolve short names like `mongo:8` unless your registry config defines search registries (Docker Desktop does this automatically). ```bash -docker pull docker.io/mongo:8 +docker pull docker.io/library/mongo:8 docker run -d \ --name videosphere-mongo \ @@ -103,7 +103,7 @@ docker run -d \ -e MONGO_INITDB_ROOT_USERNAME=admin \ -e MONGO_INITDB_ROOT_PASSWORD=localdevpassword \ -v videosphere-mongo-data:/data/db \ - mongo:8 + docker.io/library/mongo:8 ``` When using this option, ensure your `.env.local` uses matching credentials, for example: @@ -183,6 +183,14 @@ With Docker Compose, uncomment `network_mode: host` on the `app` service (see `d `pnpm dev` on the host (without Docker) can reach the LAN directly; host networking is only required when the app runs inside a container. +## Testing a local Docker build + +To build the production image and run it on your machine (instead of `pnpm dev`), see [docs/local-docker-testing.md](docs/local-docker-testing.md). Typical amd64 build: + +```bash +./scripts/docker-build-platform.sh linux/amd64 videosphere:amd64-test +``` + ## Notes - This repository uses MongoDB for auth/session-related data and application persistence. diff --git a/__tests__/api/drafts/drafts-id.test.ts b/__tests__/api/drafts/drafts-id.test.ts index 95e5d5c0..791274ec 100644 --- a/__tests__/api/drafts/drafts-id.test.ts +++ b/__tests__/api/drafts/drafts-id.test.ts @@ -35,9 +35,14 @@ vi.mock('@/lib/r2', async (importOriginal) => { }; }); +vi.mock('@/lib/repositories/users', () => ({ + upsertDraftLabelsInLibrary: vi.fn(), +})); + import { GET, PATCH, DELETE } from '@/app/api/drafts/[id]/route'; import { getAuthenticatedUserId } from '@/lib/api/auth'; import { getDraftById, updateDraft, deleteDraft } from '@/lib/repositories/drafts'; +import { upsertDraftLabelsInLibrary } from '@/lib/repositories/users'; import { deleteObject, buildDraftThumbnailFinalKey } from '@/lib/r2'; import { DraftDocumentTooLargeError, MAX_DRAFT_TITLE_LENGTH } from '@/lib/draft-upload-metadata'; @@ -472,6 +477,23 @@ describe('PATCH /api/drafts/[id]', () => { expect(updateDraft).toHaveBeenCalledWith(DRAFT_ID, { tags: ['a', 'b'] }); }); + it('returns 200 when label library upsert fails after the draft is updated', async () => { + const updated = { ...baseDraft, labels: ['Sunday'] }; + vi.mocked(updateDraft).mockResolvedValueOnce(updated); + vi.mocked(upsertDraftLabelsInLibrary).mockRejectedValueOnce(new Error('db down')); + + const res = await PATCH( + makeRequest('PATCH', { labels: ['Sunday'] }, { [SESSION_COOKIE]: 'tok' }), + makeParams() + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toBe('Draft updated'); + expect(body.data.labels).toEqual(['Sunday']); + expect(upsertDraftLabelsInLibrary).toHaveBeenCalledWith('user-123', ['Sunday']); + }); + it('updates multiple fields at once', async () => { const updated = { ...baseDraft, diff --git a/__tests__/api/drafts/drafts.test.ts b/__tests__/api/drafts/drafts.test.ts index cc20c547..e7dbb37c 100644 --- a/__tests__/api/drafts/drafts.test.ts +++ b/__tests__/api/drafts/drafts.test.ts @@ -35,10 +35,15 @@ vi.mock('@/lib/repositories/upload-jobs', () => ({ listUploadJobsByUserForDraftIds: vi.fn(async () => []), })); +vi.mock('@/lib/repositories/users', () => ({ + upsertDraftLabelsInLibrary: vi.fn(), +})); + import { POST, GET } from '@/app/api/drafts/route'; import { getAuthenticatedUserId } from '@/lib/api/auth'; import { createDraft, listDraftsByUser, markDraftUsedInUpload } from '@/lib/repositories/drafts'; import { listUploadJobsByUserForDraftIds } from '@/lib/repositories/upload-jobs'; +import { upsertDraftLabelsInLibrary } from '@/lib/repositories/users'; import { DraftDocumentTooLargeError, MAX_DRAFT_TITLE_LENGTH } from '@/lib/draft-upload-metadata'; // --------------------------------------------------------------------------- @@ -305,6 +310,7 @@ describe('POST /api/drafts', () => { title: 'My Video', description: 'Great video', tags: [], + labels: [], targets: ['youtube', 'vimeo'], platforms: {}, backupNaming: normalizeBackupFileNameSettings(undefined), @@ -353,6 +359,24 @@ describe('POST /api/drafts', () => { expect(createDraft).toHaveBeenCalledWith(expect.objectContaining({ userId: 'user-123' })); }); + + it('returns 201 when label library upsert fails after the draft is created', async () => { + vi.mocked(createDraft).mockResolvedValueOnce({ ...baseDraft, labels: ['Sunday'] }); + vi.mocked(upsertDraftLabelsInLibrary).mockRejectedValueOnce(new Error('db down')); + + const req = makeRequest( + 'POST', + { title: 'My Video', targets: ['youtube'], labels: ['Sunday'] }, + { [SESSION_COOKIE]: 'tok' } + ); + const res = await POST(req); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.message).toBe('Draft created'); + expect(body.data.labels).toEqual(['Sunday']); + expect(upsertDraftLabelsInLibrary).toHaveBeenCalledWith('user-123', ['Sunday']); + }); }); describe('Repository errors', () => { diff --git a/__tests__/api/drafts/labels.test.ts b/__tests__/api/drafts/labels.test.ts new file mode 100644 index 00000000..7efb67ac --- /dev/null +++ b/__tests__/api/drafts/labels.test.ts @@ -0,0 +1,330 @@ +/** + * Tests for GET/POST/PUT /api/drafts/labels + * + * Covers authentication, validation, merge semantics, and cascading draft updates on PUT. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { MAX_DRAFT_LABEL_LENGTH } from '@/lib/draft-labels'; +import type { DraftLabelDefinition } from '@/types'; + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: vi.fn(), +})); + +vi.mock('@/lib/repositories/users', () => ({ + getDraftLabelLibrary: vi.fn(), + mergeDraftLabelsInLibrary: vi.fn(), + setDraftLabelLibrary: vi.fn(), + upsertDraftLabelsInLibrary: vi.fn(), +})); + +vi.mock('@/lib/repositories/drafts', () => ({ + removeLabelsFromAllDraftsForUser: vi.fn(), +})); + +import { GET, POST, PUT } from '@/app/api/drafts/labels/route'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { removeLabelsFromAllDraftsForUser } from '@/lib/repositories/drafts'; +import { + getDraftLabelLibrary, + mergeDraftLabelsInLibrary, + setDraftLabelLibrary, + upsertDraftLabelsInLibrary, +} from '@/lib/repositories/users'; + +const USER_ID = 'user-123'; + +const sampleLibrary: DraftLabelDefinition[] = [ + { name: 'Sunday', color: '#6366f1' }, + { name: 'Easter', color: '#22c55e' }, + { name: 'Sunday Morning', color: '#ef4444' }, +]; + +function makeRequest( + method: 'GET' | 'POST' | 'PUT', + options: { + body?: unknown; + searchParams?: Record; + invalidJson?: boolean; + } = {} +): NextRequest { + const url = new URL('http://localhost:3000/api/drafts/labels'); + if (options.searchParams) { + for (const [key, value] of Object.entries(options.searchParams)) { + url.searchParams.set(key, value); + } + } + + const init: RequestInit = { method }; + if (options.invalidJson) { + init.body = '{not-json'; + init.headers = { 'Content-Type': 'application/json' }; + } else if (options.body !== undefined) { + init.body = JSON.stringify(options.body); + init.headers = { 'Content-Type': 'application/json' }; + } + + return new NextRequest(url, init); +} + +describe('GET /api/drafts/labels', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAuthenticatedUserId).mockResolvedValue(USER_ID); + vi.mocked(getDraftLabelLibrary).mockResolvedValue(sampleLibrary); + }); + + it('returns 401 when unauthenticated', async () => { + vi.mocked(getAuthenticatedUserId).mockResolvedValueOnce(null); + + const res = await GET(makeRequest('GET')); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('Unauthorized'); + expect(getDraftLabelLibrary).not.toHaveBeenCalled(); + }); + + it('returns the full label library when no query is provided', async () => { + const res = await GET(makeRequest('GET')); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual(sampleLibrary); + expect(getDraftLabelLibrary).toHaveBeenCalledWith(USER_ID); + }); + + it('filters suggestions when q is provided', async () => { + const res = await GET(makeRequest('GET', { searchParams: { q: 'sun' } })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual([ + { name: 'Sunday', color: '#6366f1' }, + { name: 'Sunday Morning', color: '#ef4444' }, + ]); + }); + + it('returns 404 when the user profile is missing', async () => { + vi.mocked(getDraftLabelLibrary).mockRejectedValueOnce( + Object.assign(new Error('User profile not found'), { code: 404 }) + ); + + const res = await GET(makeRequest('GET')); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('Not Found'); + expect(body.message).toBe('User profile not found'); + expect(body.statusCode).toBe(404); + }); + + it('returns 500 when loading the library fails', async () => { + vi.mocked(getDraftLabelLibrary).mockRejectedValueOnce(new Error('db down')); + + const res = await GET(makeRequest('GET')); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.message).toBe('Failed to load draft labels'); + }); +}); + +describe('POST /api/drafts/labels', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAuthenticatedUserId).mockResolvedValue(USER_ID); + vi.mocked(upsertDraftLabelsInLibrary).mockResolvedValue(sampleLibrary); + vi.mocked(mergeDraftLabelsInLibrary).mockResolvedValue(sampleLibrary); + }); + + it('returns 401 when unauthenticated', async () => { + vi.mocked(getAuthenticatedUserId).mockResolvedValueOnce(null); + + const res = await POST(makeRequest('POST', { body: { labels: ['Sunday'] } })); + + expect(res.status).toBe(401); + expect(upsertDraftLabelsInLibrary).not.toHaveBeenCalled(); + expect(mergeDraftLabelsInLibrary).not.toHaveBeenCalled(); + }); + + it('returns 400 for invalid JSON', async () => { + const res = await POST(makeRequest('POST', { invalidJson: true })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe('Invalid JSON body'); + }); + + it('returns 400 when the body is not a JSON object', async () => { + const res = await POST(makeRequest('POST', { body: ['Sunday'] })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe('Request body must be a JSON object'); + }); + + it('returns 400 when labels is not an array', async () => { + const res = await POST(makeRequest('POST', { body: { labels: 'Sunday' } })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe('labels must be an array'); + }); + + it('returns 400 when string labels exceed the per-label length limit', async () => { + const res = await POST( + makeRequest('POST', { body: { labels: ['a'.repeat(MAX_DRAFT_LABEL_LENGTH + 1)] } }) + ); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toContain(String(MAX_DRAFT_LABEL_LENGTH)); + expect(upsertDraftLabelsInLibrary).not.toHaveBeenCalled(); + }); + + it('upserts name-only labels when labels is a string array', async () => { + const updatedLibrary = [...sampleLibrary, { name: 'Christmas', color: '#64748b' }]; + vi.mocked(upsertDraftLabelsInLibrary).mockResolvedValueOnce(updatedLibrary); + + const res = await POST(makeRequest('POST', { body: { labels: ['Christmas'] } })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toBe('Draft labels updated'); + expect(body.data).toEqual(updatedLibrary); + expect(upsertDraftLabelsInLibrary).toHaveBeenCalledWith(USER_ID, ['Christmas']); + expect(mergeDraftLabelsInLibrary).not.toHaveBeenCalled(); + }); + + it('merges label definitions when labels includes objects', async () => { + const entries = [{ name: 'Sunday', color: '#111111' }]; + const mergedLibrary = [ + { name: 'Sunday', color: '#111111' }, + { name: 'Easter', color: '#22c55e' }, + ]; + vi.mocked(mergeDraftLabelsInLibrary).mockResolvedValueOnce(mergedLibrary); + + const res = await POST(makeRequest('POST', { body: { labels: entries } })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual(mergedLibrary); + expect(mergeDraftLabelsInLibrary).toHaveBeenCalledWith(USER_ID, entries); + expect(upsertDraftLabelsInLibrary).not.toHaveBeenCalled(); + }); + + it('returns 500 when upserting labels fails', async () => { + vi.mocked(upsertDraftLabelsInLibrary).mockRejectedValueOnce(new Error('db down')); + + const res = await POST(makeRequest('POST', { body: { labels: ['Sunday'] } })); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.message).toBe('Failed to update draft labels'); + }); + + it('returns 404 when the user profile is missing', async () => { + vi.mocked(upsertDraftLabelsInLibrary).mockRejectedValueOnce( + Object.assign(new Error('User profile not found'), { code: 404 }) + ); + + const res = await POST(makeRequest('POST', { body: { labels: ['Sunday'] } })); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('Not Found'); + expect(body.message).toBe('User profile not found'); + expect(body.statusCode).toBe(404); + }); +}); + +describe('PUT /api/drafts/labels', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAuthenticatedUserId).mockResolvedValue(USER_ID); + vi.mocked(getDraftLabelLibrary).mockResolvedValue(sampleLibrary); + vi.mocked(setDraftLabelLibrary).mockImplementation(async (_userId, labels) => [...labels]); + vi.mocked(removeLabelsFromAllDraftsForUser).mockResolvedValue(undefined); + }); + + it('returns 401 when unauthenticated', async () => { + vi.mocked(getAuthenticatedUserId).mockResolvedValueOnce(null); + + const res = await PUT(makeRequest('PUT', { body: { labels: [] } })); + + expect(res.status).toBe(401); + expect(setDraftLabelLibrary).not.toHaveBeenCalled(); + }); + + it('returns 400 when labels is missing', async () => { + const res = await PUT(makeRequest('PUT', { body: {} })); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe('labels must be provided'); + }); + + it('replaces the library and removes deleted labels from drafts', async () => { + const nextLibrary = [{ name: 'Sunday', color: '#6366f1' }]; + + const res = await PUT(makeRequest('PUT', { body: { labels: nextLibrary } })); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.message).toBe('Draft labels saved'); + expect(body.data).toEqual(nextLibrary); + expect(setDraftLabelLibrary).toHaveBeenCalledWith(USER_ID, nextLibrary); + expect(removeLabelsFromAllDraftsForUser).toHaveBeenCalledTimes(1); + expect(removeLabelsFromAllDraftsForUser).toHaveBeenCalledWith(USER_ID, [ + 'Easter', + 'Sunday Morning', + ]); + }); + + it('does not cascade draft updates when no labels were removed', async () => { + const res = await PUT(makeRequest('PUT', { body: { labels: sampleLibrary } })); + + expect(res.status).toBe(200); + expect(removeLabelsFromAllDraftsForUser).not.toHaveBeenCalled(); + }); + + it('returns 500 without persisting the library when draft removal fails', async () => { + const nextLibrary = [{ name: 'Sunday', color: '#6366f1' }]; + vi.mocked(removeLabelsFromAllDraftsForUser).mockRejectedValueOnce(new Error('db down')); + + const res = await PUT(makeRequest('PUT', { body: { labels: nextLibrary } })); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.message).toBe('Failed to save draft labels'); + expect(removeLabelsFromAllDraftsForUser).toHaveBeenCalled(); + expect(setDraftLabelLibrary).not.toHaveBeenCalled(); + }); + + it('returns 500 when saving the library fails', async () => { + vi.mocked(setDraftLabelLibrary).mockRejectedValueOnce(new Error('db down')); + + const res = await PUT(makeRequest('PUT', { body: { labels: sampleLibrary } })); + + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.message).toBe('Failed to save draft labels'); + }); + + it('returns 404 when the user profile is missing', async () => { + vi.mocked(setDraftLabelLibrary).mockRejectedValueOnce( + Object.assign(new Error('User profile not found'), { code: 404 }) + ); + + const res = await PUT(makeRequest('PUT', { body: { labels: sampleLibrary } })); + + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe('Not Found'); + expect(body.message).toBe('User profile not found'); + expect(body.statusCode).toBe(404); + }); +}); diff --git a/__tests__/api/livestreams/list.test.ts b/__tests__/api/livestreams/list.test.ts new file mode 100644 index 00000000..69ec5a72 --- /dev/null +++ b/__tests__/api/livestreams/list.test.ts @@ -0,0 +1,239 @@ +/** + * Tests for GET /api/livestreams + * + * Streamed history pagination: limit/offset parsing, meta.total, and conditional reconcile. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: vi.fn(), +})); + +vi.mock('@/lib/livestreams/reconcile-user-lifecycle', () => ({ + reconcileLivestreamsFromYouTubeForUser: vi.fn(), +})); + +vi.mock('@/lib/repositories/livestreams', () => ({ + getStreamedLivestreamsPage: vi.fn(), + getYoutubeImportLivestreamsPage: vi.fn(), + listLivestreamsByUser: vi.fn(), +})); + +import { GET } from '@/app/api/livestreams/route'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { reconcileLivestreamsFromYouTubeForUser } from '@/lib/livestreams/reconcile-user-lifecycle'; +import { + getStreamedLivestreamsPage, + getYoutubeImportLivestreamsPage, + listLivestreamsByUser, +} from '@/lib/repositories/livestreams'; +import type { Livestream } from '@/types'; + +const USER_ID = 'user-123'; + +function makeLivestream(id: string, overrides: Partial = {}): Livestream { + return { + id, + userId: USER_ID, + status: 'ended', + title: `Stream ${id}`, + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + platforms: {}, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-02T00:00:00.000Z', + ...overrides, + }; +} + +function createRequest(search = ''): NextRequest { + const url = new URL(`http://localhost:3000/api/livestreams${search}`); + return new NextRequest(url, { + method: 'GET', + headers: { Cookie: 'videosphere_session=tok' }, + }); +} + +describe('GET /api/livestreams', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAuthenticatedUserId).mockResolvedValue(USER_ID); + vi.mocked(reconcileLivestreamsFromYouTubeForUser).mockResolvedValue(0); + }); + + it('returns 401 when unauthenticated', async () => { + vi.mocked(getAuthenticatedUserId).mockResolvedValueOnce(null); + + const res = await GET(createRequest('?status=streamed')); + + expect(res.status).toBe(401); + expect(reconcileLivestreamsFromYouTubeForUser).not.toHaveBeenCalled(); + expect(getStreamedLivestreamsPage).not.toHaveBeenCalled(); + }); + + describe('status=streamed', () => { + it('returns data, meta.total, and default limit/offset on the first page', async () => { + const page = [makeLivestream('ls-1')]; + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 42, + livestreams: page, + }); + + const res = await GET(createRequest('?status=streamed')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Livestream[]; + meta: { total: number; limit: number; offset: number }; + }; + expect(body.data).toEqual(page); + expect(body.meta.total).toBe(42); + expect(body.meta.limit).toBe(20); + expect(body.meta.offset).toBe(0); + expect(reconcileLivestreamsFromYouTubeForUser).toHaveBeenCalledWith(USER_ID); + expect(getStreamedLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 20, + offset: 0, + }); + }); + + it('applies limit and offset; meta.total reflects the full streamed count', async () => { + const page = [makeLivestream('ls-2'), makeLivestream('ls-3')]; + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 5, + livestreams: page, + }); + + const res = await GET(createRequest('?status=streamed&limit=2&offset=2')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Livestream[]; + meta: { total: number; limit: number; offset: number }; + }; + expect(body.data.map((item) => item.id)).toEqual(['ls-2', 'ls-3']); + expect(body.meta.total).toBe(5); + expect(body.meta.limit).toBe(2); + expect(body.meta.offset).toBe(2); + expect(getStreamedLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 2, + offset: 2, + }); + }); + + it('reconciles YouTube livestreams only when offset is 0', async () => { + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ total: 0, livestreams: [] }); + + const res = await GET(createRequest('?status=streamed&offset=20')); + + expect(res.status).toBe(200); + expect(reconcileLivestreamsFromYouTubeForUser).not.toHaveBeenCalled(); + }); + + it('caps limit at 100', async () => { + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 3, + livestreams: [makeLivestream('ls-a')], + }); + + const res = await GET(createRequest('?status=streamed&limit=500')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { meta: { limit: number } }; + expect(body.meta.limit).toBe(100); + expect(getStreamedLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 100, + offset: 0, + }); + }); + + it('uses default limit when limit param is not a number', async () => { + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 1, + livestreams: [makeLivestream('ls-b')], + }); + + const res = await GET(createRequest('?status=streamed&limit=not-a-number')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { meta: { limit: number } }; + expect(body.meta.limit).toBe(20); + }); + + it('clamps limit below 1 up to 1', async () => { + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 1, + livestreams: [makeLivestream('ls-c')], + }); + + const res = await GET(createRequest('?status=streamed&limit=0')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { meta: { limit: number } }; + expect(body.meta.limit).toBe(1); + expect(getStreamedLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 1, + offset: 0, + }); + }); + + it('clamps negative offset to 0', async () => { + vi.mocked(getStreamedLivestreamsPage).mockResolvedValueOnce({ + total: 1, + livestreams: [makeLivestream('ls-d')], + }); + + const res = await GET(createRequest('?status=streamed&offset=-5')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { meta: { offset: number } }; + expect(body.meta.offset).toBe(0); + expect(reconcileLivestreamsFromYouTubeForUser).toHaveBeenCalledWith(USER_ID); + expect(getStreamedLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 20, + offset: 0, + }); + }); + + it('uses the YouTube import filter when for=youtube-import is requested', async () => { + const page = [makeLivestream('importable-1')]; + vi.mocked(getYoutubeImportLivestreamsPage).mockResolvedValueOnce({ + total: 1, + livestreams: page, + }); + + const res = await GET(createRequest('?status=streamed&for=youtube-import')); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Livestream[]; meta: { total: number } }; + expect(body.data).toEqual(page); + expect(body.meta.total).toBe(1); + expect(getYoutubeImportLivestreamsPage).toHaveBeenCalledWith(USER_ID, { + limit: 20, + offset: 0, + }); + expect(getStreamedLivestreamsPage).not.toHaveBeenCalled(); + }); + }); + + describe('unfiltered list', () => { + it('reconciles and returns all livestreams without pagination meta', async () => { + const livestreams = [makeLivestream('ls-live', { status: 'live' })]; + vi.mocked(listLivestreamsByUser).mockResolvedValueOnce(livestreams); + + const res = await GET(createRequest()); + + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Livestream[]; meta?: unknown }; + expect(body.data).toEqual(livestreams); + expect(body.meta).toBeUndefined(); + expect(reconcileLivestreamsFromYouTubeForUser).toHaveBeenCalledWith(USER_ID); + expect(listLivestreamsByUser).toHaveBeenCalledWith(USER_ID); + expect(getStreamedLivestreamsPage).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/api/platforms/connections-get.test.ts b/__tests__/api/platforms/connections-get.test.ts index 9e17b7b1..927db94a 100644 --- a/__tests__/api/platforms/connections-get.test.ts +++ b/__tests__/api/platforms/connections-get.test.ts @@ -12,8 +12,8 @@ import { NextRequest } from 'next/server'; // Mock connected-accounts repository // --------------------------------------------------------------------------- -vi.mock('@/lib/repositories/connected-accounts', () => ({ - getConnectedAccountsByUser: vi.fn(), +vi.mock('@/lib/platforms/connected-accounts-health', () => ({ + getConnectedAccountsWithHealth: vi.fn(), })); const mockGetAuthenticatedUserId = vi.fn(); @@ -23,7 +23,7 @@ vi.mock('@/lib/api/auth', () => ({ })); import { GET } from '@/app/api/platforms/connections/route'; -import { getConnectedAccountsByUser } from '@/lib/repositories/connected-accounts'; +import { getConnectedAccountsWithHealth } from '@/lib/platforms/connected-accounts-health'; // --------------------------------------------------------------------------- // Helpers @@ -53,6 +53,7 @@ const MOCK_ACCOUNT = { hasRefreshToken: true, hasYoutubeMainStreamKey: false, hasYoutubeTempStreamKey: false, + connectionStatus: 'connected' as const, }; // --------------------------------------------------------------------------- @@ -91,13 +92,13 @@ describe('GET /api/platforms/connections', () => { it('does not call repository when unauthenticated', async () => { mockGetAuthenticatedUserId.mockResolvedValueOnce(null); await GET(makeRequest()); - expect(getConnectedAccountsByUser).not.toHaveBeenCalled(); + expect(getConnectedAccountsWithHealth).not.toHaveBeenCalled(); }); }); describe('success path', () => { it('returns 200 with account list', async () => { - vi.mocked(getConnectedAccountsByUser).mockResolvedValueOnce([MOCK_ACCOUNT as never]); + vi.mocked(getConnectedAccountsWithHealth).mockResolvedValueOnce([MOCK_ACCOUNT as never]); const res = await GET(makeRequest({ [SESSION_COOKIE]: 'valid-session' })); expect(res.status).toBe(200); const body = await res.json(); @@ -106,13 +107,13 @@ describe('GET /api/platforms/connections', () => { }); it('calls repository with the authenticated userId', async () => { - vi.mocked(getConnectedAccountsByUser).mockResolvedValueOnce([]); + vi.mocked(getConnectedAccountsWithHealth).mockResolvedValueOnce([]); await GET(makeRequest({ [SESSION_COOKIE]: 'valid-session' })); - expect(getConnectedAccountsByUser).toHaveBeenCalledWith(USER_ID); + expect(getConnectedAccountsWithHealth).toHaveBeenCalledWith(USER_ID); }); it('returns 200 with empty array when user has no connections', async () => { - vi.mocked(getConnectedAccountsByUser).mockResolvedValueOnce([]); + vi.mocked(getConnectedAccountsWithHealth).mockResolvedValueOnce([]); const res = await GET(makeRequest({ [SESSION_COOKIE]: 'valid-session' })); expect(res.status).toBe(200); const body = await res.json(); @@ -121,7 +122,7 @@ describe('GET /api/platforms/connections', () => { it('returns 200 with multiple accounts', async () => { const second = { ...MOCK_ACCOUNT, id: 'conn-2', platform: 'vimeo' }; - vi.mocked(getConnectedAccountsByUser).mockResolvedValueOnce([ + vi.mocked(getConnectedAccountsWithHealth).mockResolvedValueOnce([ MOCK_ACCOUNT as never, second as never, ]); @@ -141,7 +142,9 @@ describe('GET /api/platforms/connections', () => { hasYoutubeMainStreamKey: false, hasYoutubeTempStreamKey: false, }; - vi.mocked(getConnectedAccountsByUser).mockResolvedValueOnce([sermonAudioAccount as never]); + vi.mocked(getConnectedAccountsWithHealth).mockResolvedValueOnce([ + sermonAudioAccount as never, + ]); const res = await GET(makeRequest({ [SESSION_COOKIE]: 'valid-session' })); expect(res.status).toBe(200); const body = await res.json(); @@ -158,7 +161,7 @@ describe('GET /api/platforms/connections', () => { describe('error handling', () => { it('returns 500 when the repository throws', async () => { - vi.mocked(getConnectedAccountsByUser).mockRejectedValueOnce(new Error('DB error')); + vi.mocked(getConnectedAccountsWithHealth).mockRejectedValueOnce(new Error('DB error')); const res = await GET(makeRequest({ [SESSION_COOKIE]: 'valid-session' })); expect(res.status).toBe(500); const body = await res.json(); diff --git a/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts b/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts new file mode 100644 index 00000000..afbf7613 --- /dev/null +++ b/__tests__/api/platforms/sermon-audio-social-connections-get.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetConnectedAccountWithTokens = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/connected-accounts', () => ({ + getConnectedAccountWithTokens: (...args: unknown[]) => mockGetConnectedAccountWithTokens(...args), +})); + +import { GET } from '@/app/api/platforms/sermon-audio/social-connections/route'; + +describe('GET /api/platforms/sermon-audio/social-connections', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue('user-123'); + mockGetConnectedAccountWithTokens.mockResolvedValue({ + accessToken: 'sa-api-key', + platformUserId: 'crpc', + }); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('returns Cross Publish connection flags from refresh_social', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response( + JSON.stringify({ + google: { hasOAUTH: false }, + facebook: { hasOAUTH: true, pageName: 'CRPC Facebook' }, + twitter: { hasOAUTH: true, name: 'CRPCHalifax' }, + }), + { status: 200 } + ) + ); + + const res = await GET( + new NextRequest('http://localhost/api/platforms/sermon-audio/social-connections') + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + youtube: { connected: false }, + facebook: { connected: true, displayName: 'CRPC Facebook' }, + x: { connected: true, displayName: 'CRPCHalifax' }, + instagram: { connected: false }, + }); + + const [url, init] = vi.mocked(global.fetch).mock.calls[0] ?? []; + expect(String(url)).toBe( + 'https://api.sermonaudio.com/v2/node/broadcasters/crpc/refresh_social?cacheLanguage=en&cacheMax=181&cacheDomain=www.sermonaudio.com' + ); + expect(init).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ 'X-Api-Key': 'sa-api-key' }), + }); + }); + + it('returns 400 when upstream rejects the stored API key', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })); + + const res = await GET( + new NextRequest('http://localhost/api/platforms/sermon-audio/social-connections') + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.statusCode).toBe(400); + expect(body.message).toContain('invalid or revoked'); + }); +}); diff --git a/__tests__/api/uploads/presign.test.ts b/__tests__/api/uploads/presign.test.ts index 54620cc4..1c109ec6 100644 --- a/__tests__/api/uploads/presign.test.ts +++ b/__tests__/api/uploads/presign.test.ts @@ -8,7 +8,7 @@ * - Internal failures (500) */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { NextRequest } from 'next/server'; vi.mock('@/lib/api/auth', () => ({ @@ -81,6 +81,7 @@ function createRequest(body: unknown, cookies: Record = {}): Nex describe('POST /api/uploads/presign', () => { beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv('R2_BUCKET_NAME', ''); vi.mocked(getAuthenticatedUserId).mockResolvedValue('user-123'); vi.mocked(getDraftById).mockResolvedValue(baseDraft); @@ -102,6 +103,10 @@ describe('POST /api/uploads/presign', () => { vi.mocked(markDraftUsedInUpload).mockResolvedValue(undefined); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('returns 401 when unauthenticated', async () => { vi.mocked(getAuthenticatedUserId).mockResolvedValueOnce(null); diff --git a/__tests__/api/youtube-import/active.test.ts b/__tests__/api/youtube-import/active.test.ts new file mode 100644 index 00000000..c46832bb --- /dev/null +++ b/__tests__/api/youtube-import/active.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetActiveYoutubeImportJobForUser = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getActiveYoutubeImportJobForUser: (...args: unknown[]) => + mockGetActiveYoutubeImportJobForUser(...args), +})); + +const mockScheduleYoutubeImportJob = vi.fn(); + +vi.mock('@/lib/youtube-import/schedule-import-job', () => ({ + scheduleYoutubeImportJob: (...args: unknown[]) => mockScheduleYoutubeImportJob(...args), +})); + +import { GET } from '@/app/api/youtube-import/active/route'; + +const USER_ID = 'user-123'; + +const activeJob = { + id: 'import-job-1', + userId: USER_ID, + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 0, + endSeconds: 120, + status: 'trimming' as const, + progressPercent: 72, + errorMessage: null, + r2Key: null, + uploadJobId: null, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:05:00.000Z', +}; + +function createRequest(): NextRequest { + return new NextRequest('http://localhost:9624/api/youtube-import/active', { + method: 'GET', + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetActiveYoutubeImportJobForUser.mockResolvedValue(null); +}); + +describe('GET /api/youtube-import/active', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await GET(createRequest()); + + expect(response.status).toBe(401); + expect(mockGetActiveYoutubeImportJobForUser).not.toHaveBeenCalled(); + }); + + it('returns { job: null } when the user has no active import', async () => { + const response = await GET(createRequest()); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ job: null }); + expect(mockGetActiveYoutubeImportJobForUser).toHaveBeenCalledWith(USER_ID); + }); + + it('returns the active import job when one exists', async () => { + mockGetActiveYoutubeImportJobForUser.mockResolvedValueOnce(activeJob); + + const response = await GET(createRequest()); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ job: activeJob }); + expect(mockScheduleYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('schedules server-side execution when the active job is still pending', async () => { + mockGetActiveYoutubeImportJobForUser.mockResolvedValueOnce({ + ...activeJob, + status: 'pending', + progressPercent: 0, + }); + + const response = await GET(createRequest()); + + expect(response.status).toBe(200); + expect(mockScheduleYoutubeImportJob).toHaveBeenCalledWith('import-job-1', USER_ID); + }); +}); diff --git a/__tests__/api/youtube-import/cancel.test.ts b/__tests__/api/youtube-import/cancel.test.ts new file mode 100644 index 00000000..1614132f --- /dev/null +++ b/__tests__/api/youtube-import/cancel.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetYoutubeImportJobById = vi.fn(); +const mockUpdateYoutubeImportJobStatus = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getYoutubeImportJobById: (...args: unknown[]) => mockGetYoutubeImportJobById(...args), + updateYoutubeImportJobStatus: (...args: unknown[]) => mockUpdateYoutubeImportJobStatus(...args), +})); + +import { POST } from '@/app/api/youtube-import/[jobId]/cancel/route'; + +const USER_ID = 'user-123'; +const JOB_ID = 'import-job-1'; + +const activeJob = { + id: JOB_ID, + userId: USER_ID, + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 0, + endSeconds: 120, + status: 'downloading' as const, + progressPercent: 20, + errorMessage: null, + r2Key: null, + uploadJobId: null, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:05:00.000Z', +}; + +function createRequest(jobId: string): NextRequest { + return new NextRequest(`http://localhost:9624/api/youtube-import/${jobId}/cancel`, { + method: 'POST', + }); +} + +function makeParams(jobId: string) { + return { params: Promise.resolve({ jobId }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetYoutubeImportJobById.mockResolvedValue(activeJob); + mockUpdateYoutubeImportJobStatus.mockResolvedValue(undefined); +}); + +describe('POST /api/youtube-import/[jobId]/cancel', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await POST(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(401); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalled(); + }); + + it('returns 404 when the job does not exist', async () => { + mockGetYoutubeImportJobById.mockResolvedValueOnce(null); + + const response = await POST(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(404); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalled(); + }); + + it('returns 403 when the job belongs to another user', async () => { + mockGetYoutubeImportJobById.mockResolvedValueOnce({ ...activeJob, userId: 'other-user' }); + + const response = await POST(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(403); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalled(); + }); + + it.each(['completed', 'failed', 'cancelled'] as const)( + 'returns 409 when the job is already %s', + async (status) => { + mockGetYoutubeImportJobById.mockResolvedValueOnce({ ...activeJob, status }); + + const response = await POST(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.message).toContain(status); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalled(); + } + ); + + it('marks an in-progress job as cancelled', async () => { + const response = await POST(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ success: true }); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith(JOB_ID, { + status: 'cancelled', + errorMessage: null, + }); + }); +}); diff --git a/__tests__/api/youtube-import/job-id.test.ts b/__tests__/api/youtube-import/job-id.test.ts new file mode 100644 index 00000000..5698eddd --- /dev/null +++ b/__tests__/api/youtube-import/job-id.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetYoutubeImportJobById = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getYoutubeImportJobById: (...args: unknown[]) => mockGetYoutubeImportJobById(...args), +})); + +import { GET } from '@/app/api/youtube-import/[jobId]/route'; + +const USER_ID = 'user-123'; +const JOB_ID = 'import-job-1'; + +const baseJob = { + id: JOB_ID, + userId: USER_ID, + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 0, + endSeconds: 120, + status: 'downloading' as const, + progressPercent: 35, + errorMessage: null, + r2Key: null, + uploadJobId: null, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:05:00.000Z', +}; + +function createRequest(jobId: string): NextRequest { + return new NextRequest(`http://localhost:9624/api/youtube-import/${jobId}`, { + method: 'GET', + }); +} + +function makeParams(jobId: string) { + return { params: Promise.resolve({ jobId }) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetYoutubeImportJobById.mockResolvedValue(baseJob); +}); + +describe('GET /api/youtube-import/[jobId]', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await GET(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(401); + expect(mockGetYoutubeImportJobById).not.toHaveBeenCalled(); + }); + + it('returns 404 when the job does not exist', async () => { + mockGetYoutubeImportJobById.mockResolvedValueOnce(null); + + const response = await GET(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.message).toContain('not found'); + }); + + it('returns 403 when the job belongs to another user', async () => { + mockGetYoutubeImportJobById.mockResolvedValueOnce({ ...baseJob, userId: 'other-user' }); + + const response = await GET(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error).toBe('Forbidden'); + }); + + it('returns the full job record for the owner', async () => { + const response = await GET(createRequest(JOB_ID), makeParams(JOB_ID)); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toEqual(baseJob); + expect(mockGetYoutubeImportJobById).toHaveBeenCalledWith(JOB_ID); + }); +}); diff --git a/__tests__/api/youtube-import/keyframes.test.ts b/__tests__/api/youtube-import/keyframes.test.ts new file mode 100644 index 00000000..9acb5658 --- /dev/null +++ b/__tests__/api/youtube-import/keyframes.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetDirectMediaUrl = vi.fn(); +const mockProbeNearbyKeyframes = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/youtube-import/probe-keyframes', () => ({ + getDirectMediaUrl: (...args: unknown[]) => mockGetDirectMediaUrl(...args), + probeNearbyKeyframes: (...args: unknown[]) => mockProbeNearbyKeyframes(...args), +})); + +import { GET } from '@/app/api/youtube-import/keyframes/route'; + +const USER_ID = 'user-123'; + +function createRequest(query: Record): NextRequest { + const url = new URL('http://localhost:9624/api/youtube-import/keyframes'); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + return new NextRequest(url, { method: 'GET' }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetDirectMediaUrl.mockResolvedValue({ + url: 'https://example.com/video.mp4', + expiresAt: Date.now() + 60_000, + }); + mockProbeNearbyKeyframes.mockResolvedValue([10, 12]); +}); + +describe('GET /api/youtube-import/keyframes', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await GET(createRequest({ youtubeVideoId: 'dQw4w9WgXcQ', near: '12.5' })); + + expect(response.status).toBe(401); + expect(mockGetDirectMediaUrl).not.toHaveBeenCalled(); + }); + + it('returns 400 for a malformed youtubeVideoId', async () => { + const response = await GET(createRequest({ youtubeVideoId: 'not-valid', near: '5' })); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('youtubeVideoId'); + expect(mockGetDirectMediaUrl).not.toHaveBeenCalled(); + }); + + it('returns keyframe timestamps on the happy path', async () => { + const response = await GET(createRequest({ youtubeVideoId: 'dQw4w9WgXcQ', near: '12.5' })); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toEqual({ keyframeSeconds: [10, 12] }); + expect(mockGetDirectMediaUrl).toHaveBeenCalledWith('dQw4w9WgXcQ'); + expect(mockProbeNearbyKeyframes).toHaveBeenCalledWith('https://example.com/video.mp4', 12.5); + }); + + it('returns 502 when upstream probing fails', async () => { + mockGetDirectMediaUrl.mockRejectedValueOnce(new Error('yt-dlp metadata lookup failed')); + + const response = await GET(createRequest({ youtubeVideoId: 'dQw4w9WgXcQ', near: '3' })); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.message).toContain('yt-dlp metadata lookup failed'); + }); +}); diff --git a/__tests__/api/youtube-import/preview.test.ts b/__tests__/api/youtube-import/preview.test.ts new file mode 100644 index 00000000..0e6432a0 --- /dev/null +++ b/__tests__/api/youtube-import/preview.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockResolvePreviewDirectMediaUrl = vi.fn(); +const mockFetchProxiedPreviewMedia = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/youtube-import/preview-media-url', () => ({ + buildYoutubeImportPreviewStreamPath: (youtubeVideoId: string) => + `/api/youtube-import/preview/stream?youtubeVideoId=${youtubeVideoId}`, + resolvePreviewDirectMediaUrl: (...args: unknown[]) => mockResolvePreviewDirectMediaUrl(...args), +})); + +vi.mock('@/lib/youtube-import/proxy-preview-media', () => ({ + fetchProxiedPreviewMedia: (...args: unknown[]) => mockFetchProxiedPreviewMedia(...args), +})); + +import { GET as getPreviewMetadata } from '@/app/api/youtube-import/preview/route'; +import { GET as getPreviewStream } from '@/app/api/youtube-import/preview/stream/route'; + +const USER_ID = 'user-123'; + +function createMetadataRequest(query: Record): NextRequest { + const url = new URL('http://localhost:9624/api/youtube-import/preview'); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + return new NextRequest(url, { method: 'GET' }); +} + +function createStreamRequest( + query: Record, + headers?: Record +): NextRequest { + const url = new URL('http://localhost:9624/api/youtube-import/preview/stream'); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + return new NextRequest(url, { + method: 'GET', + headers, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockResolvePreviewDirectMediaUrl.mockResolvedValue({ + url: 'https://r1---sn.example.googlevideo.com/videoplayback', + expiresAt: Date.now() + 60_000, + }); + mockFetchProxiedPreviewMedia.mockResolvedValue( + new Response('video-bytes', { + status: 206, + headers: { + 'Content-Type': 'video/mp4', + 'Content-Range': 'bytes 0-99/1000', + }, + }) + ); +}); + +describe('GET /api/youtube-import/preview', () => { + it('returns preview stream metadata', async () => { + const response = await getPreviewMetadata( + createMetadataRequest({ youtubeVideoId: 'dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data.streamUrl).toBe( + '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ' + ); + expect(body.data.expiresAt).toEqual(expect.any(Number)); + }); + + it('passes refresh=1 through to preview media resolution', async () => { + await getPreviewMetadata( + createMetadataRequest({ youtubeVideoId: 'dQw4w9WgXcQ', refresh: '1' }) + ); + + expect(mockResolvePreviewDirectMediaUrl).toHaveBeenCalledWith(USER_ID, 'dQw4w9WgXcQ', { + forceRefresh: true, + }); + }); +}); + +describe('GET /api/youtube-import/preview/stream', () => { + it('proxies range requests to the upstream media URL', async () => { + const response = await getPreviewStream( + createStreamRequest({ youtubeVideoId: 'dQw4w9WgXcQ' }, { Range: 'bytes=0-99' }) + ); + + expect(response.status).toBe(206); + expect(mockResolvePreviewDirectMediaUrl).toHaveBeenCalledWith(USER_ID, 'dQw4w9WgXcQ', { + forceRefresh: false, + }); + expect(mockFetchProxiedPreviewMedia).toHaveBeenCalledWith( + 'https://r1---sn.example.googlevideo.com/videoplayback', + 'bytes=0-99' + ); + }); +}); diff --git a/__tests__/api/youtube-import/resolve.test.ts b/__tests__/api/youtube-import/resolve.test.ts new file mode 100644 index 00000000..f6485a1a --- /dev/null +++ b/__tests__/api/youtube-import/resolve.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockRequireYouTubeConnection = vi.fn(); +const mockGetLivestreamById = vi.fn(); +const mockFetchYouTubeVideoForImport = vi.fn(); +const mockMapYouTubeImportResolvedSource = vi.fn(); +const mockResolvePreviewDirectMediaUrl = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/platforms/youtube-api', () => ({ + requireYouTubeConnection: (...args: unknown[]) => mockRequireYouTubeConnection(...args), + youtubeUpstreamErrorResponse: vi.fn((details: string) => + NextResponse.json({ error: 'Bad Gateway', message: details, statusCode: 502 }, { status: 502 }) + ), +})); + +vi.mock('@/lib/repositories/livestreams', () => ({ + getLivestreamById: (...args: unknown[]) => mockGetLivestreamById(...args), +})); + +vi.mock('@/lib/youtube-import/resolve-source', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchYouTubeVideoForImport: (...args: unknown[]) => mockFetchYouTubeVideoForImport(...args), + mapYouTubeImportResolvedSource: (...args: unknown[]) => + mockMapYouTubeImportResolvedSource(...args), + }; +}); + +vi.mock('@/lib/youtube-import/preview-media-url', () => ({ + buildYoutubeImportPreviewStreamPath: (youtubeVideoId: string) => + `/api/youtube-import/preview/stream?youtubeVideoId=${youtubeVideoId}`, + resolvePreviewDirectMediaUrl: (...args: unknown[]) => mockResolvePreviewDirectMediaUrl(...args), +})); + +import { POST } from '@/app/api/youtube-import/resolve/route'; + +const USER_ID = 'user-123'; +const ACCESS_TOKEN = 'yt-access-token'; + +function createRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:9624/api/youtube-import/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const resolvedMetadata = { + youtubeVideoId: 'dQw4w9WgXcQ', + title: 'Sunday Service', + durationSeconds: 3600, + thumbnailUrl: 'https://img.youtube.com/high.jpg', +}; + +const youtubeItem = { + id: 'dQw4w9WgXcQ', + snippet: { title: 'Sunday Service' }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockRequireYouTubeConnection.mockResolvedValue({ ok: true, accessToken: ACCESS_TOKEN }); + mockFetchYouTubeVideoForImport.mockResolvedValue({ ok: true, item: youtubeItem }); + mockMapYouTubeImportResolvedSource.mockReturnValue({ ok: true, data: resolvedMetadata }); + mockResolvePreviewDirectMediaUrl.mockResolvedValue({ + url: 'https://r1---sn.example.googlevideo.com/videoplayback?id=abc', + expiresAt: Date.now() + 3_600_000, + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST /api/youtube-import/resolve', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await POST(createRequest({ sourceUrl: 'https://youtu.be/dQw4w9WgXcQ' })); + + expect(response.status).toBe(401); + expect(mockRequireYouTubeConnection).not.toHaveBeenCalled(); + }); + + it('propagates requireYouTubeConnection failures', async () => { + mockRequireYouTubeConnection.mockResolvedValueOnce({ + ok: false, + response: NextResponse.json( + { error: 'Unauthorized', message: 'YouTube is not connected', statusCode: 401 }, + { status: 401 } + ), + }); + + const response = await POST(createRequest({ sourceUrl: 'https://youtu.be/dQw4w9WgXcQ' })); + + expect(response.status).toBe(401); + const body = await response.json(); + expect(body.message).toContain('YouTube is not connected'); + }); + + it('returns 400 when neither source selector is provided', async () => { + const response = await POST(createRequest({})); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('exactly one'); + }); + + it('returns 400 when both source selectors are provided', async () => { + const response = await POST( + createRequest({ + sourceUrl: 'https://youtu.be/dQw4w9WgXcQ', + livestreamId: 'livestream-1', + }) + ); + + expect(response.status).toBe(400); + }); + + it('resolves a pasted sourceUrl through the YouTube API', async () => { + const response = await POST( + createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.data).toEqual({ + ...resolvedMetadata, + previewStreamUrl: '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ', + previewExpiresAt: expect.any(Number), + }); + expect(mockResolvePreviewDirectMediaUrl).toHaveBeenCalledWith(USER_ID, 'dQw4w9WgXcQ'); + expect(mockFetchYouTubeVideoForImport).toHaveBeenCalledWith( + ACCESS_TOKEN, + 'dQw4w9WgXcQ', + expect.any(AbortSignal) + ); + expect(mockGetLivestreamById).not.toHaveBeenCalled(); + }); + + it('returns 400 when sourceUrl does not parse', async () => { + const response = await POST(createRequest({ sourceUrl: 'not-a-youtube-url' })); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('Could not parse'); + expect(mockFetchYouTubeVideoForImport).not.toHaveBeenCalled(); + }); + + it('resolves a livestreamId using youtubeBroadcastId', async () => { + mockGetLivestreamById.mockResolvedValueOnce({ + id: 'livestream-1', + userId: USER_ID, + youtubeBroadcastId: 'dQw4w9WgXcQ', + }); + + const response = await POST(createRequest({ livestreamId: 'livestream-1' })); + + expect(response.status).toBe(200); + expect(mockFetchYouTubeVideoForImport).toHaveBeenCalledWith( + ACCESS_TOKEN, + 'dQw4w9WgXcQ', + expect.any(AbortSignal) + ); + }); + + it('returns 403 when the livestream belongs to another user', async () => { + mockGetLivestreamById.mockResolvedValueOnce({ + id: 'livestream-1', + userId: 'other-user', + youtubeBroadcastId: 'dQw4w9WgXcQ', + }); + + const response = await POST(createRequest({ livestreamId: 'livestream-1' })); + + expect(response.status).toBe(403); + expect(mockFetchYouTubeVideoForImport).not.toHaveBeenCalled(); + }); + + it('returns 502 when the YouTube API fetch fails upstream', async () => { + mockFetchYouTubeVideoForImport.mockResolvedValueOnce({ + ok: false, + details: 'quotaExceeded', + }); + + const response = await POST( + createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.message).toBe('quotaExceeded'); + }); + + it('returns 400 when mapYouTubeImportResolvedSource rejects the video', async () => { + mockMapYouTubeImportResolvedSource.mockReturnValueOnce({ + ok: false, + message: 'Only completed YouTube live broadcasts can be imported.', + }); + + const response = await POST( + createRequest({ sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }) + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('completed YouTube live broadcasts'); + }); +}); diff --git a/__tests__/api/youtube-import/run.test.ts b/__tests__/api/youtube-import/run.test.ts new file mode 100644 index 00000000..991dfe4b --- /dev/null +++ b/__tests__/api/youtube-import/run.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetYoutubeImportJobById = vi.fn(); +const mockExecuteYoutubeImportJobWorker = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getYoutubeImportJobById: (...args: unknown[]) => mockGetYoutubeImportJobById(...args), +})); + +vi.mock('@/lib/youtube-import/execute-import-job', () => ({ + executeYoutubeImportJobWorker: (...args: unknown[]) => mockExecuteYoutubeImportJobWorker(...args), +})); + +import { POST } from '@/app/api/youtube-import/[jobId]/run/route'; + +const USER_ID = 'user-123'; +const JOB_ID = 'import-job-1'; + +const pendingJob = { + id: JOB_ID, + userId: USER_ID, + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 10, + endSeconds: 100, + status: 'pending' as const, + progressPercent: 0, + errorMessage: null, + r2Key: null, + uploadJobId: null, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:00:00.000Z', +}; + +function createRequest(jobId = JOB_ID): NextRequest { + return new NextRequest(`http://localhost:9624/api/youtube-import/${jobId}/run`, { + method: 'POST', + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetYoutubeImportJobById.mockResolvedValue(pendingJob); + mockExecuteYoutubeImportJobWorker.mockResolvedValue({ outcome: 'ran' }); + mockGetYoutubeImportJobById.mockResolvedValueOnce(pendingJob).mockResolvedValueOnce({ + ...pendingJob, + status: 'completed', + progressPercent: 100, + }); +}); + +describe('POST /api/youtube-import/[jobId]/run', () => { + it('runs a pending job for the authenticated owner', async () => { + const response = await POST(createRequest(), { params: Promise.resolve({ jobId: JOB_ID }) }); + + expect(response.status).toBe(200); + expect(mockExecuteYoutubeImportJobWorker).toHaveBeenCalledWith(JOB_ID, USER_ID); + }); + + it('returns 409 when the job is no longer pending', async () => { + mockExecuteYoutubeImportJobWorker.mockResolvedValueOnce({ + outcome: 'already_running', + status: 'downloading', + }); + + const response = await POST(createRequest(), { params: Promise.resolve({ jobId: JOB_ID }) }); + + expect(response.status).toBe(409); + }); + + it('returns 403 without a user session', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await POST(createRequest(), { params: Promise.resolve({ jobId: JOB_ID }) }); + + expect(response.status).toBe(403); + expect(mockExecuteYoutubeImportJobWorker).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/youtube-import/start.test.ts b/__tests__/api/youtube-import/start.test.ts new file mode 100644 index 00000000..461efaf2 --- /dev/null +++ b/__tests__/api/youtube-import/start.test.ts @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mockGetAuthenticatedUserId = vi.fn(); +const mockGetDraftById = vi.fn(); +const mockCreateYoutubeImportJob = vi.fn(); +const mockGetActiveYoutubeImportJobForUser = vi.fn(); +const mockDiscardBlockingDraftYoutubeImport = vi.fn(); + +vi.mock('@/lib/api/auth', () => ({ + getAuthenticatedUserId: (...args: unknown[]) => mockGetAuthenticatedUserId(...args), +})); + +vi.mock('@/lib/repositories/drafts', () => ({ + getDraftById: (...args: unknown[]) => mockGetDraftById(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createYoutubeImportJob: (...args: unknown[]) => mockCreateYoutubeImportJob(...args), + getActiveYoutubeImportJobForUser: (...args: unknown[]) => + mockGetActiveYoutubeImportJobForUser(...args), + }; +}); + +vi.mock('@/lib/youtube-import/discard-draft-import', () => ({ + discardBlockingDraftYoutubeImport: (...args: unknown[]) => + mockDiscardBlockingDraftYoutubeImport(...args), +})); + +const mockScheduleYoutubeImportJob = vi.fn(); + +vi.mock('@/lib/youtube-import/schedule-import-job', () => ({ + scheduleYoutubeImportJob: (...args: unknown[]) => mockScheduleYoutubeImportJob(...args), +})); + +import { POST } from '@/app/api/youtube-import/start/route'; +import { YoutubeImportJobAlreadyActiveError } from '@/lib/repositories/youtube-import-jobs'; + +const USER_ID = 'user-123'; +const DRAFT_ID = 'draft-1'; +const VIDEO_ID = 'dQw4w9WgXcQ'; + +const validBody = { + draftId: DRAFT_ID, + youtubeVideoId: VIDEO_ID, + startSeconds: 10, + endSeconds: 100, +}; + +const baseJob = { + id: 'import-job-1', + userId: USER_ID, + draftId: DRAFT_ID, + sourceUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + youtubeVideoId: VIDEO_ID, + livestreamId: null, + startSeconds: 10, + endSeconds: 100, + status: 'pending' as const, + progressPercent: 0, + errorMessage: null, + r2Key: null, + uploadJobId: null, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:00:00.000Z', +}; + +function createRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost:9624/api/youtube-import/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('YT_IMPORT_MAX_DURATION_SECONDS', '3600'); + mockGetAuthenticatedUserId.mockResolvedValue(USER_ID); + mockGetDraftById.mockResolvedValue({ id: DRAFT_ID, userId: USER_ID }); + mockCreateYoutubeImportJob.mockResolvedValue(baseJob); + mockDiscardBlockingDraftYoutubeImport.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('POST /api/youtube-import/start', () => { + it('returns 401 when not authenticated', async () => { + mockGetAuthenticatedUserId.mockResolvedValueOnce(null); + + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(401); + expect(mockGetDraftById).not.toHaveBeenCalled(); + }); + + it('returns 400 when endSeconds is not greater than startSeconds', async () => { + const response = await POST(createRequest({ ...validBody, startSeconds: 50, endSeconds: 50 })); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('endSeconds must be greater than startSeconds'); + expect(mockCreateYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('returns 400 when clip length exceeds YT_IMPORT_MAX_DURATION_SECONDS', async () => { + const response = await POST(createRequest({ ...validBody, startSeconds: 0, endSeconds: 3601 })); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toContain('maximum of 3600 seconds'); + expect(mockCreateYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('returns 404 when the draft does not exist', async () => { + mockGetDraftById.mockResolvedValueOnce(null); + + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(404); + expect(mockCreateYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('returns 403 when the draft belongs to another user', async () => { + mockGetDraftById.mockResolvedValueOnce({ id: DRAFT_ID, userId: 'other-user' }); + + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(403); + expect(mockCreateYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('returns 409 with activeJobId when the user already has an active import', async () => { + mockCreateYoutubeImportJob.mockRejectedValueOnce( + new YoutubeImportJobAlreadyActiveError(USER_ID) + ); + mockGetActiveYoutubeImportJobForUser.mockResolvedValueOnce({ + ...baseJob, + id: 'existing-active-job', + status: 'downloading', + progressPercent: 25, + }); + + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(409); + const body = await response.json(); + expect(body.message).toBe('You already have an import in progress'); + expect(body.activeJobId).toBe('existing-active-job'); + }); + + it('creates a job, schedules the server worker, and returns 201', async () => { + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(201); + const body = await response.json(); + expect(body).toEqual({ jobId: 'import-job-1' }); + + expect(mockCreateYoutubeImportJob).toHaveBeenCalledWith({ + userId: USER_ID, + draftId: DRAFT_ID, + sourceUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + youtubeVideoId: VIDEO_ID, + livestreamId: undefined, + startSeconds: 10, + endSeconds: 100, + }); + expect(mockDiscardBlockingDraftYoutubeImport).toHaveBeenCalledWith(DRAFT_ID, USER_ID); + expect(mockScheduleYoutubeImportJob).toHaveBeenCalledWith('import-job-1', USER_ID); + }); + + it('passes livestreamId and sourceUrl when provided', async () => { + await POST( + createRequest({ + ...validBody, + livestreamId: 'livestream-1', + sourceUrl: 'https://youtu.be/dQw4w9WgXcQ', + }) + ); + + expect(mockCreateYoutubeImportJob).toHaveBeenCalledWith( + expect.objectContaining({ + livestreamId: 'livestream-1', + sourceUrl: 'https://youtu.be/dQw4w9WgXcQ', + }) + ); + }); + + it('returns 400 when sourceUrl does not match youtubeVideoId', async () => { + const response = await POST( + createRequest({ + ...validBody, + sourceUrl: 'https://www.youtube.com/watch?v=aaaaaaaaaaa', + }) + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.message).toBe('sourceUrl does not match youtubeVideoId'); + expect(mockCreateYoutubeImportJob).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/DashboardQuickActions.test.tsx b/__tests__/components/DashboardQuickActions.test.tsx index d8e6d952..9527f9fb 100644 --- a/__tests__/components/DashboardQuickActions.test.tsx +++ b/__tests__/components/DashboardQuickActions.test.tsx @@ -69,7 +69,7 @@ describe('DashboardQuickActions', () => { }); }); - expect(pushMock).toHaveBeenCalledWith('/dashboard/drafts?createDraftId=draft-new-123'); + expect(pushMock).toHaveBeenCalledWith('/dashboard/uploads?createDraftId=draft-new-123'); }); it('shows an error toast when draft creation fails', async () => { diff --git a/__tests__/components/DraftMetadataModal.test.tsx b/__tests__/components/DraftMetadataModal.test.tsx index 2a3ac236..1bdd0326 100644 --- a/__tests__/components/DraftMetadataModal.test.tsx +++ b/__tests__/components/DraftMetadataModal.test.tsx @@ -544,7 +544,7 @@ describe('DraftMetadataModal SermonAudio short title', () => { }); it('prevents typing and pasting more than 30 characters into the short title field', async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render( { await screen.findByRole('dialog'); const shortTitleInput = screen.getByLabelText(/Short Title/i); - await user.type(shortTitleInput, 'B'.repeat(35)); + await user.click(shortTitleInput); + await user.paste('B'.repeat(35)); expect(shortTitleInput).toHaveValue('B'.repeat(30)); await user.clear(shortTitleInput); - await user.click(shortTitleInput); - await user.paste('C'.repeat(40)); + await user.type(shortTitleInput, 'C'.repeat(35)); expect(shortTitleInput).toHaveValue('C'.repeat(30)); - }); + }, 10000); it('shows the SermonAudio language field with help text and defaults to English', async () => { render( diff --git a/__tests__/components/DraftMetadataModal.video.test.tsx b/__tests__/components/DraftMetadataModal.video.test.tsx index 30e347f5..fc3861a7 100644 --- a/__tests__/components/DraftMetadataModal.video.test.tsx +++ b/__tests__/components/DraftMetadataModal.video.test.tsx @@ -3,7 +3,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor, within } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DraftMetadataModal, type DraftEditorValues } from '@/components/drafts/DraftMetadataModal'; @@ -32,6 +33,25 @@ vi.mock('sonner', () => ({ }, })); +vi.mock('@/components/youtube-import/YouTubeImportModal', () => ({ + YouTubeImportModal: ({ + open, + draftId, + onImportComplete, + }: { + open: boolean; + draftId: string; + onImportComplete: () => void | Promise; + }) => + open ? ( +
+ +
+ ) : null, +})); + import { toast } from 'sonner'; type XhrListener = (...args: unknown[]) => void; @@ -157,7 +177,17 @@ const draftValue: DraftEditorValues = { platforms: {}, }; -function renderVideoModal() { +function renderVideoModal( + overrides: Partial> = {} +) { + const onSave = + overrides.onSave ?? + vi.fn().mockResolvedValue({ + saved: true, + draftId: draftValue.id, + message: 'Draft updated', + }); + render( ); + + return { onSave }; } function getThumbnailChooseFileButton() { @@ -193,7 +222,7 @@ describe('DraftMetadataModal video upload regressions', () => { }); it('disables Choose video file after upload completes while thumbnail Choose file stays enabled', async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); renderVideoModal(); await screen.findByRole('dialog'); @@ -227,7 +256,7 @@ describe('DraftMetadataModal video upload regressions', () => { expect( within(draftDialog).getAllByRole('button', { name: /^Choose file$/i, hidden: true })[0]! ).toBeEnabled(); - }); + }, 10000); it('calls cancel with uploadId when multipart completion fails', async () => { const user = userEvent.setup(); @@ -258,3 +287,192 @@ describe('DraftMetadataModal video upload regressions', () => { expect(onCancel).toHaveBeenCalledWith({ uploadId: VIDEO_UPLOAD_ID }); }); }); + +describe('DraftMetadataModal YouTube import entry point', () => { + beforeEach(() => { + MockXMLHttpRequest.instances.length = 0; + vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest as unknown as typeof XMLHttpRequest); + mockVideoUploadFetch(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('hides Import from YouTube when YouTube is not connected', async () => { + renderVideoModal({ initialConnectedPlatforms: ['vimeo'] }); + + await screen.findByRole('dialog'); + expect(screen.queryByRole('button', { name: /Import from YouTube/i })).not.toBeInTheDocument(); + }); + + it('disables Import from YouTube while a normal upload is in progress', async () => { + const user = userEvent.setup({ delay: null }); + renderVideoModal(); + + await screen.findByRole('dialog'); + + const videoInput = document.getElementById('draft-video-file') as HTMLInputElement; + await user.upload( + videoInput, + new File([new Uint8Array([0, 1, 2])], 'sermon.mp4', { type: 'video/mp4' }) + ); + + await user.click(screen.getByRole('button', { name: /Upload & Save/i })); + await user.click(screen.getByRole('button', { name: /Yes, upload/i })); + + await waitFor(() => { + const draftDialog = screen.getByRole('dialog', { name: /Edit draft/i, hidden: true }); + expect( + within(draftDialog).getByRole('button', { name: /Import from YouTube/i, hidden: true }) + ).toBeDisabled(); + expect( + within(draftDialog).getByRole('button', { name: /Choose video file/i, hidden: true }) + ).toBeDisabled(); + }); + }); + + it('saves the draft and opens the import modal with the draft id', async () => { + const user = userEvent.setup({ delay: null }); + const onSave = vi.fn().mockResolvedValue({ + saved: true, + draftId: 'saved-draft-id', + message: 'Draft updated', + }); + renderVideoModal({ onSave }); + + await screen.findByRole('dialog'); + await user.click(screen.getByRole('button', { name: /Import from YouTube/i })); + + await waitFor(() => { + expect(onSave).toHaveBeenCalledWith({ closeAfterSave: false }); + expect(screen.getByTestId('youtube-import-modal')).toHaveAttribute( + 'data-draft-id', + 'saved-draft-id' + ); + }); + }); + + it('refreshes draft YouTube import state after staging without triggering upload', async () => { + const user = userEvent.setup({ delay: null }); + const onUploadComplete = vi.fn().mockResolvedValue(undefined); + const fetchMock = vi.mocked(global.fetch); + renderVideoModal({ onUploadComplete }); + + await screen.findByRole('dialog'); + await user.click(screen.getByRole('button', { name: /Import from YouTube/i })); + + await waitFor(() => { + expect(screen.getByTestId('youtube-import-modal')).toBeInTheDocument(); + }); + + const importCallsBefore = fetchMock.mock.calls.filter(([url]) => + String(url).includes('/api/drafts/draft-video-regression/youtube-import') + ).length; + + fireEvent.click(screen.getByRole('button', { name: /Complete YouTube import/i, hidden: true })); + + await waitFor(() => { + expect(onUploadComplete).not.toHaveBeenCalled(); + const importCallsAfter = fetchMock.mock.calls.filter(([url]) => + String(url).includes('/api/drafts/draft-video-regression/youtube-import') + ).length; + expect(importCallsAfter).toBeGreaterThan(importCallsBefore); + expect(screen.getByRole('button', { name: /Upload history/i })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + }); + }); + + it('cancels an active YouTube import from the draft editor after confirmation', async () => { + const user = userEvent.setup({ delay: null }); + const cancelSpy = vi.fn(); + let importJobActive = true; + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString(); + + if ( + url.includes('/api/drafts/draft-video-regression/youtube-import') && + init?.method !== 'POST' + ) { + return { + ok: true, + json: async () => ({ + data: importJobActive + ? { + id: 'import-job-active', + userId: 'user-1', + draftId: 'draft-video-regression', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 0, + endSeconds: 3600, + status: 'downloading', + progressPercent: 42, + errorMessage: null, + r2Key: null, + uploadJobId: null, + distributeQueued: false, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:05:00.000Z', + } + : null, + }), + } as Response; + } + + if ( + url.includes('/api/youtube-import/import-job-active/cancel') && + init?.method === 'POST' + ) { + cancelSpy(); + importJobActive = false; + return { ok: true, json: async () => ({ success: true }) } as Response; + } + + if (url.includes('/api/drafts/') && url.includes('/used-platforms')) { + return { ok: true, json: async () => ({ data: [] }) } as Response; + } + + if (url.includes('/api/uploads/history')) { + return { ok: true, json: async () => ({ data: [] }) } as Response; + } + + return { + ok: true, + json: async () => ({ data: [] }), + } as Response; + }) + ); + + renderVideoModal(); + + await screen.findByRole('dialog'); + + await waitFor(() => { + expect(screen.getByText(/downloading/i)).toBeInTheDocument(); + expect(screen.getByText('42%')).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^cancel import$/i })); + + const confirmDialog = await screen.findByRole('alertdialog', { + name: /cancel youtube import/i, + }); + expect(cancelSpy).not.toHaveBeenCalled(); + + await user.click(within(confirmDialog).getByRole('button', { name: /^cancel import$/i })); + + await waitFor(() => { + expect(cancelSpy).toHaveBeenCalledTimes(1); + expect(toast.success).toHaveBeenCalledWith('YouTube import cancelled'); + expect(screen.queryByText('42%')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/components/Navbar.test.tsx b/__tests__/components/Navbar.test.tsx index 38cd73e9..7b1dc9d5 100644 --- a/__tests__/components/Navbar.test.tsx +++ b/__tests__/components/Navbar.test.tsx @@ -81,7 +81,7 @@ describe('Navbar admin link visibility', () => { render(); await waitFor(() => { - expect(screen.getByText('Regular User')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Profile' })).toBeInTheDocument(); }); expect(screen.queryByRole('link', { name: 'Invites' })).not.toBeInTheDocument(); @@ -93,7 +93,7 @@ describe('Navbar admin link visibility', () => { render(); await waitFor(() => { - expect(screen.getByText('Admin User')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument(); }); const user = userEvent.setup(); diff --git a/__tests__/components/youtube-import/TrimRangeSlider.test.tsx b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx new file mode 100644 index 00000000..739149d5 --- /dev/null +++ b/__tests__/components/youtube-import/TrimRangeSlider.test.tsx @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useState, type ComponentProps } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { TrimRangeSlider, pickClosestKeyframe } from '@/components/youtube-import/TrimRangeSlider'; + +const VIDEO_ID = 'dQw4w9WgXcQ'; + +function TrimRangeSliderHarness( + props: Omit, 'value' | 'onChange'> & { + initialValue: { startSeconds: number; endSeconds: number }; + onChange?: (value: { startSeconds: number; endSeconds: number }) => void; + } +) { + const { initialValue, onChange, ...rest } = props; + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +function renderSlider( + props: Partial> & { + onChange?: (value: { startSeconds: number; endSeconds: number }) => void; + } = {} +) { + const onChange = props.onChange ?? vi.fn(); + const initialValue = props.value ?? { startSeconds: 10, endSeconds: 100 }; + const view = render( + + ); + return { onChange, ...view }; +} + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +describe('pickClosestKeyframe', () => { + it('returns the closest candidate timestamp', () => { + expect(pickClosestKeyframe(11, [5, 12, 20])).toBe(12); + expect(pickClosestKeyframe(19, [5, 12, 20])).toBe(20); + }); + + it('returns the raw value when no candidates are returned', () => { + expect(pickClosestKeyframe(42.5, [])).toBe(42.5); + }); +}); + +describe('TrimRangeSlider', () => { + beforeEach(() => { + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + vi.stubGlobal('fetch', vi.fn()); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('does not request keyframes until the handle stops moving', async () => { + const onChange = vi.fn(); + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 }) + ); + renderSlider({ onChange }); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); + }); + + it('debounces rapid handle moves into a single keyframe request', async () => { + const onChange = vi.fn(); + vi.mocked(global.fetch).mockResolvedValue( + new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 }) + ); + renderSlider({ onChange }); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + expect(global.fetch).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(250); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + const [url] = vi.mocked(global.fetch).mock.calls[0] ?? []; + expect(String(url)).toContain('/api/youtube-import/keyframes'); + expect(String(url)).toContain(`youtubeVideoId=${VIDEO_ID}`); + }); + + it('snaps the moved handle to the closest returned keyframe', async () => { + const onChange = vi.fn(); + renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } }); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ data: { keyframeSeconds: [5, 12, 20] } }), { status: 200 }) + ); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + await vi.advanceTimersByTimeAsync(250); + + await waitFor(() => { + const lastCall = onChange.mock.calls.at(-1)?.[0]; + expect(lastCall?.startSeconds).toBe(12); + }); + }); + + it('leaves the handle at the raw dragged value when keyframes are empty', async () => { + const onChange = vi.fn(); + renderSlider({ onChange, value: { startSeconds: 10, endSeconds: 100 } }); + + vi.mocked(global.fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ data: { keyframeSeconds: [] } }), { status: 200 }) + ); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + const rawValue = onChange.mock.calls.at(-1)?.[0]; + expect(rawValue).toBeDefined(); + + await vi.advanceTimersByTimeAsync(250); + + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + const finalValue = onChange.mock.calls.at(-1)?.[0]; + expect(finalValue?.startSeconds).toBe(rawValue?.startSeconds); + expect(screen.queryByTestId('trim-start-loading')).not.toBeInTheDocument(); + }); + + it('seeks the preview player while dragging when a player handle is provided', async () => { + const playerHandle = { + previewAt: vi.fn(), + getCurrentTime: vi.fn().mockReturnValue(0), + }; + const onChange = vi.fn(); + + renderSlider({ onChange, playerHandle }); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + await vi.advanceTimersByTimeAsync(200); + + expect(playerHandle.previewAt).toHaveBeenCalled(); + expect(onChange).toHaveBeenCalled(); + }); + + it('shows a loading indicator on the handle being snapped', async () => { + let resolveFetch: (value: Response) => void = () => {}; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + vi.mocked(global.fetch).mockReturnValueOnce(fetchPromise); + + renderSlider({ value: { startSeconds: 10, endSeconds: 100 } }); + + const [startThumb] = screen.getAllByRole('slider'); + fireEvent.keyDown(startThumb, { key: 'ArrowRight' }); + + await vi.advanceTimersByTimeAsync(250); + + await waitFor(() => { + expect(screen.getByTestId('trim-start-loading')).toBeInTheDocument(); + }); + + resolveFetch( + new Response(JSON.stringify({ data: { keyframeSeconds: [12] } }), { status: 200 }) + ); + + await waitFor(() => { + expect(screen.queryByTestId('trim-start-loading')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/components/youtube-import/YouTubeImportModal.test.tsx b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx new file mode 100644 index 00000000..12a49178 --- /dev/null +++ b/__tests__/components/youtube-import/YouTubeImportModal.test.tsx @@ -0,0 +1,528 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ComponentProps } from 'react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { YouTubeImportModal } from '@/components/youtube-import/YouTubeImportModal'; +import type { Livestream, YoutubeImportJob } from '@/types'; + +vi.mock('next/image', () => ({ + default: ({ alt, priority: _priority, ...rest }: { alt: string; priority?: boolean }) => ( + + ), +})); + +vi.mock('@/components/youtube-import/YouTubePreviewPlayer', () => ({ + YouTubePreviewPlayer: () =>
, +})); + +vi.mock('@/components/youtube-import/TrimRangeSlider', () => ({ + TrimRangeSlider: ({ + onChange, + }: { + onChange: (value: { startSeconds: number; endSeconds: number }) => void; + }) => ( + + ), +})); + +const DRAFT_ID = 'draft-1'; +const VIDEO_ID = 'dQw4w9WgXcQ'; + +const resolvedSource = { + youtubeVideoId: VIDEO_ID, + title: 'Sunday Service', + durationSeconds: 3600, + thumbnailUrl: 'https://img.youtube.com/high.jpg', + previewStreamUrl: `/api/youtube-import/preview/stream?youtubeVideoId=${VIDEO_ID}`, + previewExpiresAt: Date.now() + 3_600_000, +}; + +const livestreamRow: Livestream = { + id: 'livestream-1', + userId: 'user-1', + status: 'ended', + title: 'Sunday Morning Service', + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + youtubeBroadcastId: VIDEO_ID, + platforms: { + youtube: { + thumbnailUrl: 'https://img.youtube.com/live.jpg', + }, + }, + scheduledStartTime: '2026-01-15T15:00:00.000Z', + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-16T00:00:00.000Z', +}; + +const livestreamRow2: Livestream = { + ...livestreamRow, + id: 'livestream-2', + title: 'Wednesday Night Service', + scheduledStartTime: '2026-01-08T23:00:00.000Z', +}; + +function makeImportJob(overrides: Partial = {}): YoutubeImportJob { + return { + id: 'import-job-1', + userId: 'user-1', + draftId: DRAFT_ID, + sourceUrl: `https://www.youtube.com/watch?v=${VIDEO_ID}`, + youtubeVideoId: VIDEO_ID, + livestreamId: null, + startSeconds: 0, + endSeconds: 3600, + status: 'downloading', + progressPercent: 42, + errorMessage: null, + r2Key: null, + uploadJobId: null, + distributeQueued: false, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:05:00.000Z', + ...overrides, + }; +} + +type FetchHandler = (url: string, init?: RequestInit) => Response | Promise; + +function installFetchMock(handlers: { + active?: FetchHandler; + livestreams?: FetchHandler; + resolve?: FetchHandler; + start?: FetchHandler; + run?: FetchHandler; + job?: FetchHandler; + cancel?: FetchHandler; +}) { + const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const urlStr = String(url); + + if (urlStr.includes('/api/youtube-import/active') && method === 'GET') { + return ( + handlers.active?.(urlStr, init) ?? + new Response(JSON.stringify({ job: null }), { status: 200 }) + ); + } + + if (urlStr.includes('/api/livestreams?') && method === 'GET') { + return ( + handlers.livestreams?.(urlStr, init) ?? + new Response( + JSON.stringify({ + data: [livestreamRow], + meta: { total: 1, limit: 2, offset: 0 }, + }), + { status: 200 } + ) + ); + } + + if (urlStr.endsWith('/api/youtube-import/resolve') && method === 'POST') { + return ( + handlers.resolve?.(urlStr, init) ?? + new Response(JSON.stringify({ data: resolvedSource }), { status: 200 }) + ); + } + + if (urlStr.endsWith('/api/youtube-import/start') && method === 'POST') { + return ( + handlers.start?.(urlStr, init) ?? + new Response(JSON.stringify({ jobId: 'import-job-1' }), { status: 201 }) + ); + } + + if (urlStr.includes('/api/youtube-import/') && urlStr.endsWith('/run') && method === 'POST') { + return ( + handlers.run?.(urlStr, init) ?? + new Response(JSON.stringify({ data: makeImportJob({ status: 'downloading' }) }), { + status: 200, + }) + ); + } + + if ( + urlStr.includes('/api/youtube-import/') && + !urlStr.endsWith('/api/youtube-import/active') && + !urlStr.endsWith('/api/youtube-import/resolve') && + !urlStr.endsWith('/api/youtube-import/start') && + !urlStr.endsWith('/run') && + !urlStr.includes('/cancel') && + method === 'GET' + ) { + return ( + handlers.job?.(urlStr, init) ?? + new Response(JSON.stringify({ data: makeImportJob() }), { status: 200 }) + ); + } + + if ( + urlStr.includes('/api/youtube-import/') && + urlStr.endsWith('/cancel') && + method === 'POST' + ) { + return ( + handlers.cancel?.(urlStr, init) ?? + new Response(JSON.stringify({ success: true }), { status: 200 }) + ); + } + + return new Response(JSON.stringify({ message: `Unhandled fetch: ${method} ${urlStr}` }), { + status: 500, + }); + }); + + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function renderModal(props: Partial> = {}) { + const onOpenChange = props.onOpenChange ?? vi.fn(); + const onImportComplete = props.onImportComplete ?? vi.fn(); + + const view = render( + + ); + + return { onOpenChange, onImportComplete, ...view }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('YouTubeImportModal', () => { + it('transitions from source picker to editor after resolve succeeds', async () => { + installFetchMock({}); + const user = userEvent.setup(); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + await user.type( + screen.getByLabelText(/youtube link/i), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ); + await user.click(screen.getByRole('button', { name: /use this link/i })); + + await waitFor(() => { + expect(screen.getByTestId('youtube-preview-player')).toBeInTheDocument(); + expect(screen.getByTestId('trim-range-slider')).toBeInTheDocument(); + expect(screen.getByText('Sunday Service')).toBeInTheDocument(); + expect( + screen.getByText( + /Preview uses the same yt-dlp media source as import, so scrubbing should match the trimmed result/i + ) + ).toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/youtube-import/resolve', + expect.objectContaining({ + method: 'POST', + body: expect.any(String), + }) + ); + }); + + it('selects a livestream row to resolve and open the trim editor', async () => { + installFetchMock({}); + const user = userEvent.setup(); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('youtube-preview-player')).not.toBeInTheDocument(); + expect(global.fetch).not.toHaveBeenCalledWith( + '/api/youtube-import/resolve', + expect.objectContaining({ method: 'POST' }) + ); + + await user.click(screen.getByRole('button', { name: /sunday morning service/i })); + + await waitFor(() => { + expect(screen.getByTestId('trim-range-slider')).toBeInTheDocument(); + expect(screen.getByText('Sunday Service')).toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/youtube-import/resolve', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ livestreamId: 'livestream-1' }), + }) + ); + }); + + it('loads two livestreams initially and appends two more when Show more is clicked', async () => { + const user = userEvent.setup(); + const fetchMock = installFetchMock({ + livestreams: (urlStr) => { + const url = new URL(urlStr, 'http://localhost'); + const offset = Number(url.searchParams.get('offset') ?? '0'); + const page = + offset === 0 + ? [livestreamRow, livestreamRow2] + : [ + { ...livestreamRow, id: 'livestream-3', title: 'Older Service 3' }, + { ...livestreamRow, id: 'livestream-4', title: 'Older Service 4' }, + ]; + + return new Response( + JSON.stringify({ + data: page, + meta: { total: 4, limit: 2, offset }, + }), + { status: 200 } + ); + }, + }); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + expect(screen.getByText('Wednesday Night Service')).toBeInTheDocument(); + }); + + const initialLivestreamRequest = fetchMock.mock.calls.find(([url]) => + String(url).includes('/api/livestreams?') + ); + expect(initialLivestreamRequest?.[0]).toEqual(expect.stringContaining('limit=2')); + expect(initialLivestreamRequest?.[0]).toEqual(expect.stringContaining('offset=0')); + + await user.click(screen.getByRole('button', { name: /show more/i })); + + await waitFor(() => { + expect(screen.getByText('Older Service 3')).toBeInTheDocument(); + expect(screen.getByText('Older Service 4')).toBeInTheDocument(); + }); + + const showMoreRequest = fetchMock.mock.calls + .map(([url]) => String(url)) + .find((url) => url.includes('/api/livestreams?') && url.includes('offset=2')); + expect(showMoreRequest).toEqual(expect.stringContaining('limit=2')); + }); + + it('polls job status after start and calls onImportComplete when finished', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTimeAsync }); + + let pollCount = 0; + installFetchMock({ + job: () => { + pollCount += 1; + const status = pollCount >= 2 ? 'completed' : 'downloading'; + const progressPercent = pollCount >= 2 ? 100 : 55; + return new Response( + JSON.stringify({ + data: makeImportJob({ status, progressPercent }), + }), + { status: 200 } + ); + }, + }); + + const onImportComplete = vi.fn(); + const onOpenChange = vi.fn(); + renderModal({ onImportComplete, onOpenChange }); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + await user.type( + screen.getByLabelText(/youtube link/i), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ); + await user.click(screen.getByRole('button', { name: /use this link/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /start import/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /start import/i })); + + await waitFor(() => { + expect(screen.getByText(/downloading/i)).toBeInTheDocument(); + }); + + await vi.advanceTimersByTimeAsync(3000); + + await waitFor(() => { + expect(onImportComplete).toHaveBeenCalledTimes(1); + expect(screen.getByText(/video staged for this draft/i)).toBeInTheDocument(); + }); + }); + + it('offers to watch an existing import when start returns 409', async () => { + installFetchMock({ + start: () => + new Response( + JSON.stringify({ + error: 'Conflict', + message: 'You already have an import in progress', + statusCode: 409, + activeJobId: 'existing-job-99', + }), + { status: 409 } + ), + job: (_url, init) => { + if ((init?.method ?? 'GET') !== 'GET') { + return new Response(null, { status: 405 }); + } + return new Response( + JSON.stringify({ + data: makeImportJob({ id: 'existing-job-99', progressPercent: 70 }), + }), + { status: 200 } + ); + }, + }); + + const fetchMock = vi.mocked(global.fetch); + const user = userEvent.setup(); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + await user.type( + screen.getByLabelText(/youtube link/i), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ); + await user.click(screen.getByRole('button', { name: /use this link/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /start import/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /start import/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /watch existing import/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /watch existing import/i })); + + await waitFor(() => { + expect(screen.getByText('70%')).toBeInTheDocument(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/youtube-import/existing-job-99', + expect.objectContaining({ cache: 'no-store' }) + ); + }); + + it('resumes an active import on reopen via the active endpoint', async () => { + installFetchMock({ + active: () => + new Response( + JSON.stringify({ + job: makeImportJob({ id: 'active-job-77', progressPercent: 33 }), + }), + { status: 200 } + ), + job: () => + new Response( + JSON.stringify({ + data: makeImportJob({ id: 'active-job-77', progressPercent: 33 }), + }), + { status: 200 } + ), + }); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('33%')).toBeInTheDocument(); + expect(screen.queryByText('Pick a past livestream')).not.toBeInTheDocument(); + }); + + expect(global.fetch).toHaveBeenCalledWith('/api/youtube-import/active', { + cache: 'no-store', + }); + }); + + it('asks for confirmation before cancelling an in-flight import', async () => { + const user = userEvent.setup({ delay: null }); + const cancelSpy = vi.fn(); + installFetchMock({ + cancel: () => { + cancelSpy(); + return new Response(JSON.stringify({ success: true }), { status: 200 }); + }, + job: () => + new Response( + JSON.stringify({ + data: makeImportJob({ status: 'downloading', progressPercent: 40 }), + }), + { status: 200 } + ), + }); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('Sunday Morning Service')).toBeInTheDocument(); + }); + + await user.type( + screen.getByLabelText(/youtube link/i), + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + ); + await user.click(screen.getByRole('button', { name: /use this link/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /start import/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /start import/i })); + + await waitFor(() => { + expect(screen.getByText(/downloading/i)).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^cancel import$/i })); + + const confirmDialog = await screen.findByRole('alertdialog', { + name: /cancel youtube import/i, + }); + expect(cancelSpy).not.toHaveBeenCalled(); + + await user.click(within(confirmDialog).getByRole('button', { name: /^cancel import$/i })); + + await waitFor(() => { + expect(cancelSpy).toHaveBeenCalledTimes(1); + expect(screen.getByText('Pick a past livestream')).toBeInTheDocument(); + }); + }); +}); diff --git a/__tests__/components/youtube-import/YouTubePreviewPlayer.test.tsx b/__tests__/components/youtube-import/YouTubePreviewPlayer.test.tsx new file mode 100644 index 00000000..b828bd6b --- /dev/null +++ b/__tests__/components/youtube-import/YouTubePreviewPlayer.test.tsx @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { YouTubePreviewPlayer } from '@/components/youtube-import/YouTubePreviewPlayer'; + +const VIDEO_ID = 'dQw4w9WgXcQ'; +const STREAM_URL = `/api/youtube-import/preview/stream?youtubeVideoId=${VIDEO_ID}`; +const PREVIEW_EXPIRES_AT = Date.now() + 3_600_000; + +describe('YouTubePreviewPlayer', () => { + beforeEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(HTMLMediaElement.prototype, 'duration', { + configurable: true, + get() { + return 600; + }, + }); + }); + + it('renders a video element pointed at the proxied stream URL', () => { + const { container } = render( + + ); + + const video = container.querySelector('video'); + expect(video).not.toBeNull(); + expect(video).toHaveAttribute('src', STREAM_URL); + expect(video).toHaveAttribute('controls'); + }); + + it('calls onDurationKnown when metadata loads', async () => { + const onDurationKnown = vi.fn(); + + const { container } = render( + + ); + + const video = container.querySelector('video'); + expect(video).not.toBeNull(); + fireEvent.loadedMetadata(video!); + + await waitFor(() => { + expect(onDurationKnown).toHaveBeenCalledWith(600); + }); + }); + + it('exposes previewAt and getCurrentTime through playerRef', async () => { + const playerRef = { + current: null as + | import('@/components/youtube-import/YouTubePreviewPlayer').YouTubePlayerHandle + | null, + }; + + const { container } = render( + + ); + + const video = container.querySelector('video') as HTMLVideoElement; + Object.defineProperty(video, 'readyState', { + configurable: true, + get() { + return HTMLMediaElement.HAVE_METADATA; + }, + }); + Object.defineProperty(video, 'currentTime', { + configurable: true, + writable: true, + value: 0, + }); + + fireEvent.loadedMetadata(video); + + playerRef.current?.previewAt(42); + expect(video.currentTime).toBe(42); + expect(playerRef.current?.getCurrentTime()).toBe(42); + }); + + it('refreshes immediately when preview URL is within the refresh buffer', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + streamUrl: STREAM_URL, + expiresAt: Date.now() + 3_600_000, + }, + }), + { status: 200 } + ) + ); + + render( + + ); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining(`/api/youtube-import/preview?youtubeVideoId=${VIDEO_ID}`), + expect.objectContaining({ cache: 'no-store' }) + ); + }); + + expect(fetchMock.mock.calls[0]?.[0]).toContain('refresh=1'); + }); + + it('resets refresh state when preview source props change', () => { + const otherVideoId = 'abc123xyz90'; + const otherStreamUrl = `/api/youtube-import/preview/stream?youtubeVideoId=${otherVideoId}`; + + const { container, rerender } = render( + + ); + + const video = container.querySelector('video'); + expect(video).not.toBeNull(); + fireEvent.error(video!); + + rerender( + + ); + + const nextVideo = container.querySelector('video'); + expect(nextVideo).not.toHaveAttribute('src', expect.stringContaining('refresh=1')); + expect(nextVideo).toHaveAttribute('src', otherStreamUrl); + }); +}); diff --git a/__tests__/layout/dashboard-layout.test.tsx b/__tests__/layout/dashboard-layout.test.tsx index 3558f6d7..2f362db5 100644 --- a/__tests__/layout/dashboard-layout.test.tsx +++ b/__tests__/layout/dashboard-layout.test.tsx @@ -1,19 +1,22 @@ // ============================================================================= // DASHBOARD SHELL COMPONENT TESTS // ============================================================================= -// Tests for the responsive dashboard shell (sidebar + mobile nav). +// Tests for the responsive dashboard shell (sidebar + mobile drawer). // Verifies all nav links are present, active states are correct, and // the isActive helper function works as expected. // ============================================================================= import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; import DashboardShell from '@/components/dashboard/DashboardShell'; +import { DashboardNavProvider } from '@/components/dashboard/DashboardNavProvider'; +import Navbar from '@/components/layout/Navbar'; // Mock next/navigation for usePathname hook vi.mock('next/navigation', () => ({ usePathname: vi.fn(), + useRouter: () => ({ push: vi.fn() }), })); // Mock next/link to avoid routing complexity in tests @@ -25,6 +28,24 @@ vi.mock('next/link', () => ({ ), })); +vi.mock('next/image', () => ({ + default: ({ alt, priority: _priority, ...rest }: any) => ( + + ), +})); + +vi.mock('next-themes', () => ({ + useTheme: () => ({ + theme: 'system', + setTheme: vi.fn(), + resolvedTheme: 'light', + }), +})); + +vi.mock('@/lib/auth-client', () => ({ + logout: vi.fn(), +})); + // Mock Sonner Toaster to avoid window.matchMedia dependency in tests vi.mock('@/components/ui/sonner', () => ({ Toaster: () => null })); @@ -32,72 +53,116 @@ import { usePathname } from 'next/navigation'; const NAV_ITEMS = [ { label: 'Dashboard', href: '/dashboard' }, - { label: 'Drafts', href: '/dashboard/drafts' }, - { label: 'History', href: '/dashboard/history' }, + { label: 'Uploads', href: '/dashboard/uploads' }, + { label: 'History', href: '/dashboard/uploads/history' }, + { label: 'Livestreams', href: '/dashboard/livestreams' }, + { label: 'History', href: '/dashboard/livestreams/history' }, ] as const; +function mockSessionFetch() { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ $id: 'user-1', name: 'Test User', email: 'test@example.com' }), + } as Response) + ); +} + +function renderDashboardLayout(children: React.ReactNode, { isAdmin = false } = {}) { + return render( + + + {children} + + ); +} + +function openMobileDrawer() { + fireEvent.click(screen.getByRole('button', { name: 'Open dashboard sections' })); +} + describe('DashboardShell', () => { beforeEach(() => { vi.clearAllMocks(); + mockSessionFetch(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); }); describe('Navigation Items Rendering', () => { - it('should render all 3 navigation links', () => { + it('should render top-level navigation links on desktop', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test Content
-
- ); + renderDashboardLayout(
Test Content
); - NAV_ITEMS.forEach(({ label }) => { - expect(screen.getAllByRole('link', { name: label }).length).toBeGreaterThan(0); - }); + expect(screen.getAllByRole('link', { name: 'Dashboard' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('link', { name: 'Uploads' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('link', { name: 'Livestreams' }).length).toBeGreaterThan(0); }); - it('should have correct href attributes for all nav items', () => { + it('should hide nested History links in the mobile drawer until expanded', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test Content
-
- ); + renderDashboardLayout(
Test Content
); + + openMobileDrawer(); + + const drawer = screen.getByRole('dialog'); + expect(within(drawer).queryByRole('link', { name: 'History' })).not.toBeInTheDocument(); + + fireEvent.click(within(drawer).getByRole('button', { name: 'Show Uploads submenu' })); + expect( + within(drawer) + .getAllByRole('link', { name: 'History' }) + .some((link) => link.getAttribute('href') === '/dashboard/uploads/history') + ).toBe(true); + }); + + it('should have correct href attributes for all nav items when the mobile drawer is expanded', () => { + (usePathname as any).mockReturnValue('/dashboard'); + + renderDashboardLayout(
Test Content
); + + openMobileDrawer(); + const drawer = screen.getByRole('dialog'); + + fireEvent.click(within(drawer).getByRole('button', { name: 'Show Uploads submenu' })); + fireEvent.click(within(drawer).getByRole('button', { name: 'Show Livestreams submenu' })); - // Verify each navigation label points to the expected route. NAV_ITEMS.forEach(({ label, href }) => { - const links = screen.getAllByRole('link', { name: label }); - expect(links.length).toBeGreaterThan(0); - links.forEach((link) => { - expect(link).toHaveAttribute('href', href); - }); + const links = within(drawer).getAllByRole('link', { name: label }); + expect(links.some((link) => link.getAttribute('href') === href)).toBe(true); }); }); it('should render children content', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Dashboard Page Content
-
- ); + renderDashboardLayout(
Dashboard Page Content
); expect(screen.getByTestId('page-content')).toBeInTheDocument(); expect(screen.getByText('Dashboard Page Content')).toBeInTheDocument(); }); + + it('should not render a mobile sections bar above page content', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads'); + + renderDashboardLayout(
Test Content
); + + expect(screen.queryByText('Uploads · History')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Sections' })).not.toBeInTheDocument(); + }); }); describe('Active Link Highlighting', () => { it('should mark Dashboard as active when pathname is exactly /dashboard', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); const dashboardLinks = screen.getAllByText('Dashboard'); const activeLink = dashboardLinks.find((el) => { @@ -108,35 +173,27 @@ describe('DashboardShell', () => { expect(activeLink).toBeDefined(); }); - it('should NOT mark Dashboard as active when pathname is /dashboard/drafts', () => { - (usePathname as any).mockReturnValue('/dashboard/drafts'); + it('should NOT mark Dashboard sidebar link as active when pathname is /dashboard/uploads', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - const dashboardLinks = screen.getAllByText('Dashboard'); - const activeLink = dashboardLinks.find((el) => { - const link = el.closest('a'); - return link && link.getAttribute('aria-current') === 'page'; - }); + const sidebarNav = screen.getAllByLabelText('Dashboard navigation')[0]!; + const dashboardSidebarLinks = within(sidebarNav).getAllByRole('link', { name: 'Dashboard' }); + const activeSidebarLink = dashboardSidebarLinks.find( + (link) => link.getAttribute('aria-current') === 'page' + ); - expect(activeLink).toBeUndefined(); + expect(activeSidebarLink).toBeUndefined(); }); - it('should mark Drafts as active when pathname is /dashboard/drafts', () => { - (usePathname as any).mockReturnValue('/dashboard/drafts'); + it('should mark Uploads as active when pathname is /dashboard/uploads', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - const draftLinks = screen.getAllByText('Drafts'); - const activeLink = draftLinks.find((el) => { + const uploadsLinks = screen.getAllByText('Uploads'); + const activeLink = uploadsLinks.find((el) => { const link = el.closest('a'); return link && link.getAttribute('aria-current') === 'page'; }); @@ -144,17 +201,13 @@ describe('DashboardShell', () => { expect(activeLink).toBeDefined(); }); - it('should mark Drafts as active when pathname is /dashboard/drafts/[id] (startsWith match)', () => { - (usePathname as any).mockReturnValue('/dashboard/drafts/video-123'); + it('should mark Uploads as active when pathname is /dashboard/uploads/[id]/upload', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads/video-123/upload'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - const draftLinks = screen.getAllByText('Drafts'); - const activeLink = draftLinks.find((el) => { + const uploadsLinks = screen.getAllByText('Uploads'); + const activeLink = uploadsLinks.find((el) => { const link = el.closest('a'); return link && link.getAttribute('aria-current') === 'page'; }); @@ -162,77 +215,70 @@ describe('DashboardShell', () => { expect(activeLink).toBeDefined(); }); - it('should mark History as active when pathname is /dashboard/history', () => { - (usePathname as any).mockReturnValue('/dashboard/history'); - - render( - -
Test
-
- ); - - const historyLinks = screen.getAllByText('History'); - const activeLink = historyLinks.find((el) => { - const link = el.closest('a'); - return link && link.getAttribute('aria-current') === 'page'; - }); - - expect(activeLink).toBeDefined(); + it('should auto-expand the parent section when a nested history route is active', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads/history'); + + renderDashboardLayout(
Test
); + + openMobileDrawer(); + const drawer = screen.getByRole('dialog'); + const historyLinks = within(drawer).getAllByRole('link', { name: 'History' }); + expect( + historyLinks.some( + (link) => + link.getAttribute('href') === '/dashboard/uploads/history' && + link.getAttribute('aria-current') === 'page' + ) + ).toBe(true); }); - it('should only mark Drafts links as active for /dashboard/drafts', () => { - (usePathname as any).mockReturnValue('/dashboard/drafts'); + it('should only mark Uploads sidebar links as active for /dashboard/uploads', () => { + (usePathname as any).mockReturnValue('/dashboard/uploads'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - const allLinks = screen.getAllByRole('link'); - const activeLinks = allLinks.filter((link) => link.getAttribute('aria-current') === 'page'); + const sidebarNav = screen.getAllByLabelText('Dashboard navigation')[0]!; + const activeSidebarLinks = within(sidebarNav) + .getAllByRole('link') + .filter((link) => link.getAttribute('aria-current') === 'page'); - expect(activeLinks.length).toBeGreaterThan(0); - activeLinks.forEach((link) => { - expect(link).toHaveTextContent('Drafts'); + expect(activeSidebarLinks.length).toBeGreaterThan(0); + activeSidebarLinks.forEach((link) => { + expect(link).toHaveTextContent('Uploads'); }); }); }); describe('Responsive Layout Structure', () => { - it('should render sidebar', () => { + it('should render the dashboard drawer trigger on profile routes', () => { + (usePathname as any).mockReturnValue('/profile/connections'); + + renderDashboardLayout(
Test
); + + expect(screen.getByRole('button', { name: 'Open dashboard sections' })).toBeInTheDocument(); + }); + + it('should render the dashboard drawer trigger in the navbar on mobile routes', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - const navs = screen.getAllByLabelText('Dashboard navigation'); - expect(navs.length).toBeGreaterThanOrEqual(2); + expect(screen.getByRole('button', { name: 'Open dashboard sections' })).toBeInTheDocument(); + expect(screen.getAllByLabelText('Dashboard navigation').length).toBeGreaterThanOrEqual(1); }); - it('should render both dashboard navigation regions (sidebar + mobile tabs)', () => { + it('should render desktop sidebar navigation', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); - expect(screen.getAllByRole('navigation', { name: 'Dashboard navigation' })).toHaveLength(2); + expect(screen.getAllByLabelText('Dashboard navigation').length).toBeGreaterThanOrEqual(1); }); it('should render child content inside the layout shell', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); expect(screen.getByTestId('dashboard-content')).toBeInTheDocument(); }); @@ -243,9 +289,11 @@ describe('DashboardShell', () => { (usePathname as any).mockReturnValue('/profile'); render( - -
Test
-
+ + +
Test
+
+
); const allLinks = screen.getAllByRole('link'); @@ -257,11 +305,7 @@ describe('DashboardShell', () => { it('should handle root dashboard route correctly', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); const allLinks = screen.getAllByRole('link'); const activeLinks = allLinks.filter((link) => link.getAttribute('aria-current') === 'page'); @@ -272,29 +316,10 @@ describe('DashboardShell', () => { }); }); - it('should render each nav label as a link', () => { - (usePathname as any).mockReturnValue('/dashboard'); - - render( - -
Test
-
- ); - - NAV_ITEMS.forEach(({ label }) => { - const elements = screen.getAllByRole('link', { name: label }); - expect(elements.length).toBeGreaterThan(0); - }); - }); - it('shows Users nav link for admin users', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
, { isAdmin: true }); expect(screen.getAllByRole('link', { name: 'Users' }).length).toBeGreaterThan(0); }); @@ -302,11 +327,7 @@ describe('DashboardShell', () => { it('hides Users nav link for non-admin users', () => { (usePathname as any).mockReturnValue('/dashboard'); - render( - -
Test
-
- ); + renderDashboardLayout(
Test
); expect(screen.queryByRole('link', { name: 'Users' })).not.toBeInTheDocument(); }); diff --git a/__tests__/lib/api/finalize-upload-job.test.ts b/__tests__/lib/api/finalize-upload-job.test.ts new file mode 100644 index 00000000..d0c4e98f --- /dev/null +++ b/__tests__/lib/api/finalize-upload-job.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockAfter = vi.hoisted(() => vi.fn()); + +vi.mock('next/server', () => ({ + after: (...args: unknown[]) => mockAfter(...args), +})); + +vi.mock('@/lib/repositories/upload-jobs', () => ({ + getUploadJobById: vi.fn(), + updateUploadJobStatus: vi.fn(), +})); + +vi.mock('@/lib/repositories/drafts', () => ({ + getDraftById: vi.fn(), +})); + +vi.mock('@/lib/repositories/platform-uploads', () => ({ + ensurePlatformUploadsForJobTargets: vi.fn(), +})); + +vi.mock('@/lib/api/distribute', () => ({ + distributeCreatePlatformUploadInput: vi.fn(() => ({ + uploadJobId: 'job-123', + platform: 'youtube', + title: 'Test Video', + description: 'desc', + tags: ['tag1'], + visibility: 'public', + })), + runDistributionInBackground: vi.fn(async () => undefined), +})); + +vi.mock('@/lib/draft-upload-metadata', () => ({ + buildMetadataForPlatform: vi.fn(() => ({ + title: 'Test Video', + description: 'desc', + tags: ['tag1'], + visibility: 'public', + })), +})); + +import { runDistributionInBackground } from '@/lib/api/distribute'; +import { + finalizeUploadJobAndDistribute, + UploadJobFinalizeNotFoundError, + UploadJobMissingR2KeyError, +} from '@/lib/api/finalize-upload-job'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { ensurePlatformUploadsForJobTargets } from '@/lib/repositories/platform-uploads'; +import { getUploadJobById, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; + +const baseJob = { + id: 'job-123', + userId: 'user-123', + draftId: 'draft-abc', + r2Key: 'temp/uploads/user-123/video.mp4', + status: 'pending' as const, + errorMessage: null, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', +}; + +const baseDraft = { + id: 'draft-abc', + userId: 'user-123', + targets: ['youtube'] as const, + title: 'Test Video', + description: 'desc', + tags: ['tag1'], + visibility: 'public' as const, + platforms: {}, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', +}; + +const platformUpload = { + id: 'pu-1', + uploadJobId: 'job-123', + platform: 'youtube' as const, + status: 'pending' as const, + platformVideoId: '', + platformUrl: '', + title: 'Test Video', + description: 'desc', + tags: ['tag1'], + visibility: 'public' as const, + scheduledAt: null, + errorMessage: null, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getUploadJobById).mockResolvedValue(baseJob); + vi.mocked(updateUploadJobStatus).mockResolvedValue({ + ...baseJob, + status: 'distributing', + }); + vi.mocked(getDraftById).mockResolvedValue(baseDraft); + vi.mocked(ensurePlatformUploadsForJobTargets).mockResolvedValue([platformUpload]); +}); + +describe('finalizeUploadJobAndDistribute', () => { + it('marks the job uploading and returns distributing false when no draft is linked', async () => { + vi.mocked(getUploadJobById).mockResolvedValueOnce({ ...baseJob, draftId: null }); + + const result = await finalizeUploadJobAndDistribute('job-123', 'user-123'); + + expect(result).toEqual({ distributing: false }); + expect(updateUploadJobStatus).toHaveBeenCalledWith('job-123', 'uploading'); + expect(getDraftById).not.toHaveBeenCalled(); + expect(ensurePlatformUploadsForJobTargets).not.toHaveBeenCalled(); + expect(mockAfter).not.toHaveBeenCalled(); + }); + + it('marks the job uploading when the draft has no targets', async () => { + vi.mocked(getDraftById).mockResolvedValueOnce({ ...baseDraft, targets: [] }); + + const result = await finalizeUploadJobAndDistribute('job-123', 'user-123'); + + expect(result).toEqual({ distributing: false }); + expect(updateUploadJobStatus).toHaveBeenCalledWith('job-123', 'uploading'); + expect(ensurePlatformUploadsForJobTargets).not.toHaveBeenCalled(); + expect(mockAfter).not.toHaveBeenCalled(); + }); + + it('creates platform uploads, advances to distributing, and schedules background distribution', async () => { + const result = await finalizeUploadJobAndDistribute('job-123', 'user-123'); + + expect(result).toEqual({ distributing: true }); + expect(ensurePlatformUploadsForJobTargets).toHaveBeenCalled(); + expect(updateUploadJobStatus).toHaveBeenCalledWith('job-123', 'distributing', null); + expect(mockAfter).toHaveBeenCalledTimes(1); + + const scheduled = mockAfter.mock.calls[0]?.[0] as () => Promise; + await scheduled(); + expect(runDistributionInBackground).toHaveBeenCalledWith( + 'job-123', + 'user-123', + 'temp/uploads/user-123/video.mp4', + [platformUpload], + expect.any(Map) + ); + }); + + it('throws UploadJobFinalizeNotFoundError when the job row is missing', async () => { + vi.mocked(getUploadJobById).mockResolvedValueOnce(null); + + await expect(finalizeUploadJobAndDistribute('job-123', 'user-123')).rejects.toBeInstanceOf( + UploadJobFinalizeNotFoundError + ); + }); + + it('throws UploadJobFinalizeNotFoundError when distributing status update returns null', async () => { + vi.mocked(updateUploadJobStatus).mockResolvedValueOnce(null); + + await expect(finalizeUploadJobAndDistribute('job-123', 'user-123')).rejects.toBeInstanceOf( + UploadJobFinalizeNotFoundError + ); + }); + + it('throws UploadJobMissingR2KeyError when the job has no staged R2 key', async () => { + vi.mocked(getUploadJobById).mockResolvedValueOnce({ ...baseJob, r2Key: null }); + + await expect(finalizeUploadJobAndDistribute('job-123', 'user-123')).rejects.toBeInstanceOf( + UploadJobMissingR2KeyError + ); + expect(ensurePlatformUploadsForJobTargets).not.toHaveBeenCalled(); + expect(updateUploadJobStatus).not.toHaveBeenCalled(); + expect(mockAfter).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/lib/distribute.test.ts b/__tests__/lib/distribute.test.ts index 332e281e..96a2f94e 100644 --- a/__tests__/lib/distribute.test.ts +++ b/__tests__/lib/distribute.test.ts @@ -655,7 +655,6 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () expect(mockPollSermonAudioProcessing).toHaveBeenCalledWith( expect.objectContaining({ sermonID: 'sermon-123', - customThumbnailUploaded: false, }) ); }); @@ -680,10 +679,8 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () ).toBe(false); }); - it('passes customThumbnailUploaded true to poll when thumbnail upload succeeded', async () => { - mockUploadToSermonAudio.mockResolvedValue( - sermonAudioUploadSuccess({ sermonAudioCustomThumbnailUploaded: true }) - ); + it('passes crossPublish settings to publish when configured on the draft', async () => { + mockUploadToSermonAudio.mockResolvedValue(sermonAudioUploadSuccess()); mockPollSermonAudioProcessing.mockResolvedValue(undefined); mockPublishSermonAudio.mockResolvedValue(undefined); @@ -694,7 +691,10 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () { ...baseMetadata, autoPublishOnProcessed: true, - thumbnailR2Key: 'draft-thumbnails/u1/thumb.jpg', + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'New sermon' }, + }, }, ], ]); @@ -702,15 +702,50 @@ describe('runDistributionInBackground — SermonAudio auto-publish failure', () await runDistributionInBackground('job-1', 'u1', 'temp/uploads/u1/v.mp4', [pu], meta); await vi.waitFor(() => { - expect(mockPollSermonAudioProcessing).toHaveBeenCalledWith( + expect(mockPublishSermonAudio).toHaveBeenCalledWith( expect.objectContaining({ sermonID: 'sermon-123', - customThumbnailUploaded: true, + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'New sermon' }, + }, }) ); }); }); + it('persists scheduled status when publishTimestamp is in the future', async () => { + mockUploadToSermonAudio.mockResolvedValue(sermonAudioUploadSuccess()); + mockPollSermonAudioProcessing.mockResolvedValue(undefined); + mockPublishSermonAudio.mockResolvedValue(undefined); + + const futurePublishTimestamp = Math.floor(Date.now() / 1000) + 86_400; + const pu = basePlatformUpload({ id: 'pu-sa', platform: 'sermon_audio' }); + const meta = new Map([ + [ + 'pu-sa', + { + ...baseMetadata, + autoPublishOnProcessed: false, + publishTimestamp: futurePublishTimestamp, + }, + ], + ]); + + await runDistributionInBackground('job-1', 'u1', 'temp/uploads/u1/v.mp4', [pu], meta); + + await vi.waitFor(() => { + expect(mockUpdatePlatformUploadStatus).toHaveBeenCalledWith( + 'pu-sa', + 'scheduled', + 'sermon-123', + sermonUrl, + null, + new Date(futurePublishTimestamp * 1000).toISOString() + ); + }); + }); + it('persists failed status when auto-publish cannot run without a captured API key', async () => { mockRefreshTokenIfNeeded.mockResolvedValue({ accessToken: '', diff --git a/__tests__/lib/draft-labels.test.ts b/__tests__/lib/draft-labels.test.ts new file mode 100644 index 00000000..71ea3b7c --- /dev/null +++ b/__tests__/lib/draft-labels.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from 'vitest'; +import { + draftLabelListIncludesEquivalent, + draftLabelsRemovedFromLibrary, + filterDraftLabelSuggestions, + lookupDraftLabelColor, + MAX_DRAFT_LABEL_LIBRARY_SIZE, + MAX_DRAFT_LABEL_LENGTH, + mergeDraftLabelLibraryEntries, + mergeUniqueDraftLabels, + normalizeDraftLabelColor, + normalizeDraftLabelDefinition, + normalizeDraftLabelLibrary, + normalizeDraftLabelList, + parseDraftLabelInput, + parseDraftLabelLibraryFromRequestBody, + parseDraftLabelsFromRequestBody, + upsertDraftLabelNamesInLibrary, +} from '@/lib/draft-labels'; + +describe('parseDraftLabelInput', () => { + it('splits on commas and normalizes whitespace', () => { + expect(parseDraftLabelInput('Sunday, Morning Service')).toEqual(['Sunday', 'Morning Service']); + }); +}); + +describe('mergeUniqueDraftLabels', () => { + it('deduplicates case-insensitively', () => { + expect(mergeUniqueDraftLabels(['Sunday'], ['sunday', 'Easter'])).toEqual(['Sunday', 'Easter']); + }); +}); + +describe('filterDraftLabelSuggestions', () => { + it('filters library entries by query substring', () => { + expect( + filterDraftLabelSuggestions( + [ + { name: 'Easter', color: '#6366f1' }, + { name: 'Sunday Morning', color: '#22c55e' }, + { name: 'Christmas', color: '#ef4444' }, + ], + 'sun' + ) + ).toEqual([{ name: 'Sunday Morning', color: '#22c55e' }]); + }); +}); + +describe('parseDraftLabelsFromRequestBody', () => { + it('rejects non-array values', () => { + const result = parseDraftLabelsFromRequestBody('Sunday'); + expect(result).toEqual({ ok: false, error: 'labels must be an array of strings' }); + }); + + it('rejects arrays containing non-string elements', () => { + expect(parseDraftLabelsFromRequestBody(['Sunday', 42])).toEqual({ + ok: false, + error: 'labels must be an array of strings', + }); + expect(parseDraftLabelsFromRequestBody(['Sunday', { name: 'Easter' }])).toEqual({ + ok: false, + error: 'labels must be an array of strings', + }); + }); + + it('accepts valid string arrays', () => { + expect(parseDraftLabelsFromRequestBody([' Easter ', 'Sunday'])).toEqual({ + ok: true, + value: ['Easter', 'Sunday'], + }); + }); + + it('rejects arrays that exceed per-draft limit', () => { + const labels = Array.from({ length: 21 }, (_, index) => `Label ${index}`); + const result = parseDraftLabelsFromRequestBody(labels); + expect(result.ok).toBe(false); + }); +}); + +describe('parseDraftLabelLibraryFromRequestBody', () => { + it('rejects arrays containing non-string, non-object entries', () => { + expect(parseDraftLabelLibraryFromRequestBody([42])).toEqual({ + ok: false, + error: 'labels must contain only strings or label objects', + }); + expect( + parseDraftLabelLibraryFromRequestBody([{ name: 'Sunday', color: '#6366f1' }, null]) + ).toEqual({ + ok: false, + error: 'labels must contain only strings or label objects', + }); + expect(parseDraftLabelLibraryFromRequestBody([['Sunday']])).toEqual({ + ok: false, + error: 'labels must contain only strings or label objects', + }); + }); + + it('accepts string entries and label objects', () => { + expect( + parseDraftLabelLibraryFromRequestBody(['Sunday', { name: 'Easter', color: '#6366f1' }]) + ).toEqual({ + ok: true, + value: [ + { name: 'Sunday', color: expect.any(String) }, + { name: 'Easter', color: '#6366f1' }, + ], + }); + }); + + it('rejects raw arrays that exceed the library size cap', () => { + const labels = Array.from({ length: MAX_DRAFT_LABEL_LIBRARY_SIZE + 1 }, (_, index) => + String(index) + ); + const result = parseDraftLabelLibraryFromRequestBody(labels); + expect(result.ok).toBe(false); + }); + + it('rejects string entries longer than the per-label limit', () => { + const result = parseDraftLabelLibraryFromRequestBody(['a'.repeat(MAX_DRAFT_LABEL_LENGTH + 1)]); + expect(result.ok).toBe(false); + }); +}); + +describe('draftLabelsRemovedFromLibrary', () => { + it('returns labels removed during a settings update', () => { + expect( + draftLabelsRemovedFromLibrary( + [ + { name: 'Easter', color: '#6366f1' }, + { name: 'Sunday', color: '#22c55e' }, + ], + [{ name: 'Sunday', color: '#22c55e' }] + ) + ).toEqual(['Easter']); + }); +}); + +describe('normalizeDraftLabelList', () => { + it('normalizes unknown input to an empty list', () => { + expect(normalizeDraftLabelList(null)).toEqual([]); + expect(normalizeDraftLabelList([' Easter ', 'easter', 'Christmas'])).toEqual([ + 'Easter', + 'Christmas', + ]); + }); +}); + +describe('normalizeDraftLabelDefinition', () => { + it('returns null when object name is not a string', () => { + expect(normalizeDraftLabelDefinition({ name: { nested: true } })).toBeNull(); + expect(normalizeDraftLabelDefinition({ name: 42 })).toBeNull(); + expect(normalizeDraftLabelDefinition({ name: null })).toBeNull(); + }); + + it('accepts string names on objects', () => { + expect(normalizeDraftLabelDefinition({ name: 'Sunday', color: '#6366f1' })).toEqual({ + name: 'Sunday', + color: '#6366f1', + }); + }); + + it('accepts legacy string entries', () => { + expect(normalizeDraftLabelDefinition(' Easter ')).toEqual({ + name: 'Easter', + color: expect.any(String), + }); + }); +}); + +describe('normalizeDraftLabelLibrary', () => { + it('migrates legacy string entries and accepts color objects', () => { + expect(normalizeDraftLabelLibrary([' Easter ', { name: 'Sunday', color: '#6366f1' }])).toEqual([ + { name: 'Easter', color: expect.any(String) }, + { name: 'Sunday', color: '#6366f1' }, + ]); + }); + + it('skips object entries with non-string names', () => { + expect( + normalizeDraftLabelLibrary([ + { name: 'Sunday', color: '#6366f1' }, + { name: { nested: true }, color: '#ef4444' }, + ]) + ).toEqual([{ name: 'Sunday', color: '#6366f1' }]); + }); + + it('deduplicates by name case-insensitively', () => { + expect( + normalizeDraftLabelLibrary([ + { name: 'Easter', color: '#6366f1' }, + { name: 'easter', color: '#ef4444' }, + ]) + ).toEqual([{ name: 'Easter', color: '#ef4444' }]); + }); +}); + +describe('normalizeDraftLabelColor', () => { + it('falls back to the default for invalid values', () => { + expect(normalizeDraftLabelColor('not-a-color')).toBe('#64748b'); + expect(normalizeDraftLabelColor('#AABBCC')).toBe('#aabbcc'); + }); +}); + +describe('mergeDraftLabelLibraryEntries', () => { + it('updates color for an existing label and appends new names', () => { + expect( + mergeDraftLabelLibraryEntries( + [{ name: 'Sunday', color: '#6366f1' }], + [ + { name: 'Sunday', color: '#ef4444' }, + { name: 'Easter', color: '#22c55e' }, + ] + ) + ).toEqual([ + { name: 'Sunday', color: '#ef4444' }, + { name: 'Easter', color: '#22c55e' }, + ]); + }); +}); + +describe('upsertDraftLabelNamesInLibrary', () => { + it('preserves existing colors when adding names only', () => { + expect( + upsertDraftLabelNamesInLibrary([{ name: 'Sunday', color: '#6366f1' }], ['Sunday', 'Easter']) + ).toEqual([ + { name: 'Sunday', color: '#6366f1' }, + { name: 'Easter', color: expect.any(String) }, + ]); + }); + + it('does not add new names when the library is at capacity', () => { + const fullLibrary = Array.from({ length: MAX_DRAFT_LABEL_LIBRARY_SIZE }, (_, index) => ({ + name: `Label ${index}`, + color: '#6366f1', + })); + + expect(upsertDraftLabelNamesInLibrary(fullLibrary, ['Label 0', 'Brand new'])).toEqual( + fullLibrary + ); + }); +}); + +describe('lookupDraftLabelColor', () => { + it('returns stored color or a stable default', () => { + expect(lookupDraftLabelColor([{ name: 'Sunday', color: '#6366f1' }], 'sunday')).toBe('#6366f1'); + expect(lookupDraftLabelColor([], 'New Label')).toMatch(/^#[0-9a-f]{6}$/); + }); +}); + +describe('draftLabelListIncludesEquivalent', () => { + it('matches labels case-insensitively', () => { + expect(draftLabelListIncludesEquivalent(['Sunday Service'], 'sunday service')).toBe(true); + }); + + it('does not treat leading # as equivalent to the bare label', () => { + expect(draftLabelListIncludesEquivalent(['foo'], '#foo')).toBe(false); + expect(draftLabelListIncludesEquivalent(['#foo'], 'foo')).toBe(false); + }); +}); diff --git a/__tests__/lib/draft-upload-metadata.test.ts b/__tests__/lib/draft-upload-metadata.test.ts index 8e44222f..f6fe6c76 100644 --- a/__tests__/lib/draft-upload-metadata.test.ts +++ b/__tests__/lib/draft-upload-metadata.test.ts @@ -83,6 +83,7 @@ describe('draft-upload-metadata', () => { description: 'D', visibility: 'unlisted', tags: ['a', 'b'], + labels: [], platforms: { vimeo: { categoryUris: ['/categories/1'] } }, }); const row = { document: doc }; @@ -92,6 +93,7 @@ describe('draft-upload-metadata', () => { description: 'D', visibility: 'unlisted', tags: ['a', 'b'], + labels: [], platforms: { vimeo: { categoryUris: ['/categories/1'] } }, backupNaming: defaultBackupNaming, usedInUploadAt: undefined, @@ -105,6 +107,7 @@ describe('draft-upload-metadata', () => { description: '', visibility: 'private', tags: [], + labels: [], platforms: {}, }); expect( @@ -140,6 +143,7 @@ describe('draft-upload-metadata', () => { description: '', visibility: 'public', tags: [], + labels: [], platforms: {}, backupNaming: defaultBackupNaming, }); @@ -149,6 +153,7 @@ describe('draft-upload-metadata', () => { description: '', visibility: 'public', tags: [], + labels: [], platforms: {}, backupNaming: defaultBackupNaming, }); @@ -843,7 +848,7 @@ describe('draft-upload-metadata', () => { displayTitle: 'Short', languageCode: 'en', autoPublishOnProcessed: false, - publishDate: '2026-07-01T09:00:00-04:00', + publishTimestamp: Math.floor(Date.parse('2026-07-01T09:00:00-04:00') / 1000), titleOverride: 'SA Title', descriptionOverride: 'SA Desc', tagsOverride: ['holy', 'day'], @@ -1005,6 +1010,32 @@ describe('draft-upload-metadata', () => { expect(buildMetadataForPlatform(draft, 'sermon_audio').autoPublishOnProcessed).toBe(false); }); + it('buildMetadataForPlatform sermon_audio includes publishTimestamp when set', () => { + const publishTimestamp = 1_782_772_962; + const draft: Draft = { + id: 'd1', + userId: 'u1', + targets: ['sermon_audio'], + title: 'Title', + description: 'Description', + tags: [], + visibility: 'public', + platforms: { + sermon_audio: { + speakerName: 'Rev. Smith', + preachDate: '2026-06-01', + publishTimestamp, + }, + }, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', + }; + + const meta = buildMetadataForPlatform(draft, 'sermon_audio'); + expect(meta.publishTimestamp).toBe(publishTimestamp); + expect(meta.autoPublishOnProcessed).toBe(false); + }); + it('buildMetadataForPlatform sermon_audio includes crossPublish settings when set', () => { const draft: Draft = { id: 'd1', diff --git a/__tests__/lib/format-video-duration.test.ts b/__tests__/lib/format-video-duration.test.ts new file mode 100644 index 00000000..acc7b0b4 --- /dev/null +++ b/__tests__/lib/format-video-duration.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { formatVideoDuration } from '@/lib/format-video-duration'; + +describe('formatVideoDuration', () => { + it('formats sub-hour durations as M:SS', () => { + expect(formatVideoDuration(0)).toBe('0:00'); + expect(formatVideoDuration(45)).toBe('0:45'); + expect(formatVideoDuration(125)).toBe('2:05'); + expect(formatVideoDuration(3599)).toBe('59:59'); + }); + + it('formats hour-plus durations as H:MM:SS', () => { + expect(formatVideoDuration(3600)).toBe('1:00:00'); + expect(formatVideoDuration(3661)).toBe('1:01:01'); + expect(formatVideoDuration(6667)).toBe('1:51:07'); + }); + + it('floors fractional seconds', () => { + expect(formatVideoDuration(125.9)).toBe('2:05'); + }); +}); diff --git a/__tests__/lib/livestreams/livestream-list-filters.test.ts b/__tests__/lib/livestreams/livestream-list-filters.test.ts new file mode 100644 index 00000000..a13ca3b3 --- /dev/null +++ b/__tests__/lib/livestreams/livestream-list-filters.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { + buildStreamedLivestreamsMongoFilter, + buildYoutubeImportLivestreamsMongoFilter, + filterStreamedLivestreams, + filterYoutubeImportLivestreams, + isStreamedLivestream, + isYoutubeImportLivestream, + paginateLivestreams, +} from '@/lib/livestreams/livestream-list-filters'; +import type { Livestream } from '@/types'; + +function makeLivestream(overrides: Partial = {}): Livestream { + return { + id: 'livestream-1', + userId: 'user-1', + status: 'ended', + title: 'Sunday Service', + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + platforms: {}, + youtubeBroadcastId: 'broadcast-1', + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-02T00:00:00.000Z', + ...overrides, + }; +} + +describe('livestream list filters', () => { + it('identifies streamed livestreams by ended or failed status', () => { + expect(isStreamedLivestream(makeLivestream({ status: 'ended' }))).toBe(true); + expect(isStreamedLivestream(makeLivestream({ status: 'failed' }))).toBe(true); + expect(isStreamedLivestream(makeLivestream({ status: 'live' }))).toBe(false); + }); + + it('identifies YouTube import sources with a linked broadcast', () => { + expect(isYoutubeImportLivestream(makeLivestream())).toBe(true); + expect( + isYoutubeImportLivestream( + makeLivestream({ status: 'live', youtubeLifecycleStatus: 'complete' }) + ) + ).toBe(true); + expect(isYoutubeImportLivestream(makeLivestream({ targets: ['facebook'] }))).toBe(false); + expect(isYoutubeImportLivestream(makeLivestream({ youtubeBroadcastId: undefined }))).toBe( + false + ); + }); + + it('filters and paginates streamed livestreams in repository order', () => { + const rows = [ + makeLivestream({ id: 'ended-1', status: 'ended' }), + makeLivestream({ id: 'live-1', status: 'live' }), + makeLivestream({ id: 'failed-1', status: 'failed' }), + ]; + + expect(filterStreamedLivestreams(rows).map((row) => row.id)).toEqual(['ended-1', 'failed-1']); + expect(filterYoutubeImportLivestreams(rows).map((row) => row.id)).toEqual([ + 'ended-1', + 'failed-1', + ]); + expect(paginateLivestreams(filterStreamedLivestreams(rows), 1, 1).map((row) => row.id)).toEqual( + ['failed-1'] + ); + }); + + it('builds MongoDB filters for streamed and YouTube import pages', () => { + expect(buildStreamedLivestreamsMongoFilter('user-1')).toEqual({ + userId: 'user-1', + status: { $in: ['ended', 'failed'] }, + }); + + expect(buildYoutubeImportLivestreamsMongoFilter('user-2')).toEqual({ + userId: 'user-2', + hasYoutubeTarget: true, + youtubeBroadcastId: { $ne: '' }, + $or: [ + { status: { $in: ['ended', 'failed'] } }, + { status: 'live', youtubeLifecycleStatus: /^complete$/i }, + ], + }); + }); +}); diff --git a/__tests__/lib/livestreams/livestream-query-fields.test.ts b/__tests__/lib/livestreams/livestream-query-fields.test.ts new file mode 100644 index 00000000..194271ee --- /dev/null +++ b/__tests__/lib/livestreams/livestream-query-fields.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + livestreamQueryFieldsFromDocumentJson, + livestreamQueryFieldsFromStoredDocument, +} from '@/lib/livestreams/livestream-query-fields'; + +describe('livestreamQueryFieldsFromStoredDocument', () => { + it('derives indexed fields from parsed document payload', () => { + expect( + livestreamQueryFieldsFromStoredDocument({ + status: 'ended', + targets: ['youtube'], + youtubeBroadcastId: ' broadcast-1 ', + youtubeLifecycleStatus: ' complete ', + }) + ).toEqual({ + status: 'ended', + hasYoutubeTarget: true, + youtubeBroadcastId: 'broadcast-1', + youtubeLifecycleStatus: 'complete', + }); + }); +}); + +describe('livestreamQueryFieldsFromDocumentJson', () => { + it('parses query fields from a stored document JSON string', () => { + const documentJson = JSON.stringify({ + status: 'failed', + title: 'Service', + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + platforms: {}, + youtubeBroadcastId: 'abc123', + }); + + expect(livestreamQueryFieldsFromDocumentJson(documentJson)).toEqual({ + status: 'failed', + hasYoutubeTarget: true, + youtubeBroadcastId: 'abc123', + youtubeLifecycleStatus: '', + }); + }); + + it('defaults invalid or missing status to draft', () => { + expect( + livestreamQueryFieldsFromDocumentJson( + JSON.stringify({ + title: 'Draft row', + targets: ['facebook'], + }) + ) + ).toEqual({ + status: 'draft', + hasYoutubeTarget: false, + youtubeBroadcastId: '', + youtubeLifecycleStatus: '', + }); + }); +}); diff --git a/__tests__/lib/livestreams/youtube-thumbnail-preview.test.ts b/__tests__/lib/livestreams/youtube-thumbnail-preview.test.ts index 8745ee9f..e3ff57f6 100644 --- a/__tests__/lib/livestreams/youtube-thumbnail-preview.test.ts +++ b/__tests__/lib/livestreams/youtube-thumbnail-preview.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { livestreamWithThumbnailPreview } from '@/lib/livestreams/livestream-thumbnail-preview'; import { + getLivestreamListThumbnailUrl, livestreamYouTubeThumbnailCacheKey, youtubeThumbnailPreviewUrl, } from '@/lib/livestreams/youtube-thumbnail-preview'; @@ -19,6 +20,60 @@ describe('youtubeThumbnailPreviewUrl', () => { }); }); +describe('getLivestreamListThumbnailUrl', () => { + const baseLivestream: Livestream = { + id: 'ls-1', + userId: 'user-1', + status: 'ended', + title: 'Stream', + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + platforms: {}, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-02T00:00:00.000Z', + }; + + it('prefers an ephemeral R2 preview URL', () => { + expect( + getLivestreamListThumbnailUrl({ + ...baseLivestream, + thumbnailPreviewUrl: 'https://r2.example/presigned.jpg', + youtubeBroadcastId: 'abc123', + }) + ).toBe('https://r2.example/presigned.jpg'); + }); + + it('cache-busts a stored YouTube thumbnail URL', () => { + const livestream: Livestream = { + ...baseLivestream, + platforms: { + youtube: { + thumbnailUrl: 'https://i.ytimg.com/vi/abc/hqdefault.jpg', + thumbnailUpdatedAt: '2026-01-02T12:00:00.000Z', + }, + }, + }; + + expect(getLivestreamListThumbnailUrl(livestream)).toBe( + youtubeThumbnailPreviewUrl( + livestream.platforms.youtube!.thumbnailUrl!, + livestreamYouTubeThumbnailCacheKey(livestream) + ) + ); + }); + + it('falls back to the standard YouTube thumbnail for a broadcast id', () => { + expect( + getLivestreamListThumbnailUrl({ + ...baseLivestream, + youtubeBroadcastId: 'dQw4w9WgXcQ', + }) + ).toBe('https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg'); + }); +}); + describe('stripServerManagedLivestreamPlatformsPatch', () => { it('removes server-managed YouTube thumbnail fields from client PATCH payloads', () => { expect( diff --git a/__tests__/lib/openrouter.test.ts b/__tests__/lib/openrouter.test.ts index 69eaaa21..1814cb9a 100644 --- a/__tests__/lib/openrouter.test.ts +++ b/__tests__/lib/openrouter.test.ts @@ -73,6 +73,7 @@ describe('generateMetadata (OpenRouter client)', () => { vi.stubEnv('OPENROUTER_API_KEY', 'sk-test-key'); vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://test.app'); vi.stubEnv('NEXT_PUBLIC_APP_NAME', 'TestApp'); + vi.stubEnv('OPENROUTER_FETCH_TIMEOUT_MS', ''); }); afterEach(() => { diff --git a/__tests__/lib/platforms/connected-accounts-health.test.ts b/__tests__/lib/platforms/connected-accounts-health.test.ts new file mode 100644 index 00000000..623ecc03 --- /dev/null +++ b/__tests__/lib/platforms/connected-accounts-health.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { ConnectedAccount, ConnectedAccountPublic } from '@/types'; + +const mockGetConnectedAccountsByUser = vi.fn(); +const mockGetConnectedAccountWithTokens = vi.fn(); +const mockGetConnectedAccountForUser = vi.fn(); +const mockRefreshTokenIfNeeded = vi.fn(); + +vi.mock('@/lib/repositories/connected-accounts', () => ({ + getConnectedAccountsByUser: (...args: unknown[]) => mockGetConnectedAccountsByUser(...args), + getConnectedAccountWithTokens: (...args: unknown[]) => mockGetConnectedAccountWithTokens(...args), + getConnectedAccountForUser: (...args: unknown[]) => mockGetConnectedAccountForUser(...args), +})); + +vi.mock('@/lib/platforms/token-refresh', () => ({ + refreshTokenIfNeeded: (...args: unknown[]) => mockRefreshTokenIfNeeded(...args), +})); + +import { getConnectedAccountsWithHealth } from '@/lib/platforms/connected-accounts-health'; + +const USER_ID = 'user-1'; + +function publicYoutube(overrides: Partial = {}): ConnectedAccountPublic { + return { + id: 'acc-1', + userId: USER_ID, + platform: 'youtube', + tokenExpiry: new Date(Date.now() - 1000).toISOString(), + hasRefreshToken: true, + hasYoutubeMainStreamKey: false, + hasYoutubeTempStreamKey: false, + platformUserId: 'yt-1', + platformName: 'Channel', + $createdAt: '2020-01-01T00:00:00.000Z', + $updatedAt: '2020-01-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getConnectedAccountsWithHealth', () => { + it('marks OAuth accounts expired when refresh fails', async () => { + const account = publicYoutube(); + mockGetConnectedAccountsByUser.mockResolvedValue([account]); + mockGetConnectedAccountWithTokens.mockResolvedValue({ + ...account, + accessToken: 'access', + refreshToken: 'refresh', + } satisfies ConnectedAccount); + mockRefreshTokenIfNeeded.mockRejectedValue( + new Error('YOUTUBE_TOKEN_REFRESH_FAILED: invalid_grant') + ); + mockGetConnectedAccountForUser.mockResolvedValue({ + ...account, + hasRefreshToken: false, + }); + + const result = await getConnectedAccountsWithHealth(USER_ID); + + expect(mockRefreshTokenIfNeeded).toHaveBeenCalled(); + expect(result).toEqual([ + expect.objectContaining({ + id: 'acc-1', + connectionStatus: 'expired', + hasRefreshToken: false, + }), + ]); + }); + + it('keeps OAuth accounts connected when refresh succeeds', async () => { + const account = publicYoutube(); + const refreshedPublic = { + ...account, + tokenExpiry: new Date(Date.now() + 3_600_000).toISOString(), + hasRefreshToken: true, + }; + mockGetConnectedAccountsByUser.mockResolvedValue([account]); + mockGetConnectedAccountWithTokens.mockResolvedValue({ + ...account, + accessToken: 'access', + refreshToken: 'refresh', + } satisfies ConnectedAccount); + mockRefreshTokenIfNeeded.mockResolvedValue({ + accessToken: 'new-access', + refreshToken: 'refresh', + tokenExpiry: refreshedPublic.tokenExpiry, + }); + mockGetConnectedAccountForUser.mockResolvedValue(refreshedPublic); + + const result = await getConnectedAccountsWithHealth(USER_ID); + + expect(result).toEqual([ + expect.objectContaining({ + id: 'acc-1', + connectionStatus: 'connected', + }), + ]); + }); +}); diff --git a/__tests__/lib/platforms/connection-status.test.ts b/__tests__/lib/platforms/connection-status.test.ts new file mode 100644 index 00000000..ddd53ba5 --- /dev/null +++ b/__tests__/lib/platforms/connection-status.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { + accountNeedsOAuthHealthProbe, + getConnectionStatus, + getUsableConnectedPlatforms, + isUsablePlatformConnection, + resolveConnectionStatus, +} from '@/lib/platforms/connection-status'; +import type { ConnectedAccountPublic } from '@/types'; + +function youtubeAccount(overrides: Partial = {}): ConnectedAccountPublic { + return { + id: 'acc-1', + userId: 'user-1', + platform: 'youtube', + tokenExpiry: new Date(Date.now() + 3_600_000).toISOString(), + hasRefreshToken: true, + hasYoutubeMainStreamKey: false, + hasYoutubeTempStreamKey: false, + platformUserId: 'yt-1', + platformName: 'Channel', + $createdAt: '2020-01-01T00:00:00.000Z', + $updatedAt: '2020-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('connection-status', () => { + it('treats OAuth rows with expired access and refresh token as connected before health probe', () => { + const account = youtubeAccount({ + tokenExpiry: new Date(Date.now() - 1000).toISOString(), + hasRefreshToken: true, + }); + expect(getConnectionStatus(account)).toBe('connected'); + expect(accountNeedsOAuthHealthProbe(account)).toBe(true); + }); + + it('prefers server-verified connectionStatus over static derivation', () => { + const account = youtubeAccount({ + tokenExpiry: new Date(Date.now() - 1000).toISOString(), + hasRefreshToken: true, + connectionStatus: 'expired', + }); + expect(resolveConnectionStatus(account)).toBe('expired'); + expect(isUsablePlatformConnection(account)).toBe(false); + }); + + it('returns only usable platforms from connections payload', () => { + const accounts = [ + youtubeAccount({ connectionStatus: 'connected' }), + youtubeAccount({ + id: 'acc-2', + platform: 'vimeo', + connectionStatus: 'expired', + }), + ]; + expect(getUsableConnectedPlatforms(accounts)).toEqual(['youtube']); + }); +}); diff --git a/__tests__/lib/platforms/oauth-refresh-errors.test.ts b/__tests__/lib/platforms/oauth-refresh-errors.test.ts new file mode 100644 index 00000000..8e5c345b --- /dev/null +++ b/__tests__/lib/platforms/oauth-refresh-errors.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { isOAuthRefreshTokenRevokedError } from '@/lib/platforms/oauth-refresh-errors'; + +describe('isOAuthRefreshTokenRevokedError', () => { + it('returns true for invalid_grant details', () => { + expect( + isOAuthRefreshTokenRevokedError('invalid_grant: Token has been expired or revoked.') + ).toBe(true); + }); + + it('returns true for revoked token messages without invalid_grant prefix', () => { + expect(isOAuthRefreshTokenRevokedError('Token has been revoked')).toBe(true); + }); + + it('returns false for unrelated OAuth errors', () => { + expect(isOAuthRefreshTokenRevokedError('invalid_client: Unauthorized')).toBe(false); + }); + + it('returns true for object payloads that stringify to invalid_grant', () => { + expect(isOAuthRefreshTokenRevokedError({ error: 'invalid_grant' })).toBe(true); + }); + + it('does not throw when details contain circular references', () => { + const circular: { self?: unknown } = {}; + circular.self = circular; + + expect(() => isOAuthRefreshTokenRevokedError(circular)).not.toThrow(); + expect(isOAuthRefreshTokenRevokedError(circular)).toBe(false); + }); +}); diff --git a/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts b/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts index 0c5218b8..baba9e1e 100644 --- a/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts +++ b/__tests__/lib/platforms/sermon-audio-cross-publish.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - buildSermonAudioSocialSharingCreateFields, + buildSermonAudioSocialSharingSettings, normalizeSermonAudioCrossPublishPlatformSettings, normalizeSermonAudioCrossPublishSettings, - SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, sermonAudioCrossPublishHasActiveSelection, } from '@/lib/platforms/sermon-audio-cross-publish'; @@ -99,6 +98,51 @@ describe('normalizeSermonAudioCrossPublishPlatformSettings', () => { }) ).toBeUndefined(); }); + + it('drops Facebook uploadFullVideo when postLink is not enabled', () => { + expect( + normalizeSermonAudioCrossPublishPlatformSettings('facebook', { + uploadFullVideo: true, + }) + ).toBeUndefined(); + + expect( + normalizeSermonAudioCrossPublishPlatformSettings('facebook', { + postLink: false, + uploadFullVideo: true, + }) + ).toEqual({ postLink: false }); + }); + + it('drops X uploadVideoPreview when postLink is not enabled', () => { + expect( + normalizeSermonAudioCrossPublishPlatformSettings('x', { + uploadVideoPreview: true, + }) + ).toBeUndefined(); + + expect( + normalizeSermonAudioCrossPublishPlatformSettings('x', { + postLink: false, + uploadVideoPreview: true, + }) + ).toEqual({ postLink: false }); + }); + + it('drops Instagram uploadVideoPreview when postLink is not enabled', () => { + expect( + normalizeSermonAudioCrossPublishPlatformSettings('instagram', { + uploadVideoPreview: true, + }) + ).toBeUndefined(); + + expect( + normalizeSermonAudioCrossPublishPlatformSettings('instagram', { + postLink: false, + uploadVideoPreview: true, + }) + ).toEqual({ postLink: false }); + }); }); describe('sermonAudioCrossPublishHasActiveSelection', () => { @@ -138,29 +182,47 @@ describe('sermonAudioCrossPublishHasActiveSelection', () => { ).toBe(false); }); - it('returns true when only X video preview is selected', () => { + it('returns false when only Facebook uploadFullVideo is selected without postLink', () => { + expect( + sermonAudioCrossPublishHasActiveSelection({ + enabled: true, + facebook: { uploadFullVideo: true }, + }) + ).toBe(false); + }); + + it('returns false when only X uploadVideoPreview is selected without postLink', () => { expect( sermonAudioCrossPublishHasActiveSelection({ enabled: true, x: { uploadVideoPreview: true }, }) - ).toBe(true); + ).toBe(false); + }); + + it('returns false when only Instagram uploadVideoPreview is selected without postLink', () => { + expect( + sermonAudioCrossPublishHasActiveSelection({ + enabled: true, + instagram: { uploadVideoPreview: true }, + }) + ).toBe(false); }); }); -describe('buildSermonAudioSocialSharingCreateFields', () => { +describe('buildSermonAudioSocialSharingSettings', () => { it('returns undefined when Cross Publish is disabled', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: false, facebook: { postLink: true }, }) ).toBeUndefined(); }); - it('maps platform-specific Cross Publish settings to socialSharing array format', () => { + it('maps platform-specific Cross Publish settings to socialSharingSettings format', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { @@ -169,13 +231,13 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { title: 'YouTube Title', description: 'YouTube Description', }, - facebook: { uploadFullVideo: true }, + facebook: { postLink: true, uploadFullVideo: true }, x: { postLink: true, uploadVideoPreview: true, linkMessage: 'New sermon' }, }, { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'YouTube Title', @@ -184,7 +246,8 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { }, { platform: 'facebook', - message: 'Shared description', + message: 'Sunday Sermon', + useVideoClip: true, }, { platform: 'twitter', @@ -192,16 +255,15 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { useVideoClip: true, }, ], - social_sharing_video_clip: { - start: 0, - end: SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, - }, + google: true, + facebook: true, + twitter: true, }); }); it('uses draft defaults for YouTube title and description when fields are empty', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { uploadFullVideo: true }, @@ -209,7 +271,7 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'Sunday Sermon', @@ -217,12 +279,13 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { privacy: 'public', }, ], + google: true, }); }); it('falls back to sermon title for YouTube message when description defaults are empty', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, youtube: { uploadFullVideo: true }, @@ -230,7 +293,7 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { { defaultTitle: 'Sunday Sermon', defaultDescription: '' } ) ).toEqual({ - socialSharing: [ + platforms: [ { platform: 'google', title: 'Sunday Sermon', @@ -238,74 +301,115 @@ describe('buildSermonAudioSocialSharingCreateFields', () => { privacy: 'public', }, ], + google: true, }); }); - it('omits useVideoClip and clip range for link-only Facebook posts', () => { + it('sets useVideoClip false for Facebook link posts', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: true, facebook: { postLink: true, linkMessage: 'Listen here' }, }) ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Listen here' }], + platforms: [{ platform: 'facebook', message: 'Listen here', useVideoClip: false }], + facebook: true, }); }); - it('uses sermon description for Facebook full-video-only posts', () => { + it('sets useVideoClip true for Facebook link plus video posts', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, - facebook: { uploadFullVideo: true }, + facebook: { postLink: true, uploadFullVideo: true, linkMessage: 'Watch now' }, }, { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } ) ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Shared description' }], + platforms: [{ platform: 'facebook', message: 'Watch now', useVideoClip: true }], + facebook: true, }); }); - it('prefers link message over description when Facebook post link is enabled', () => { + it('ignores Facebook uploadFullVideo without postLink', () => { + expect( + buildSermonAudioSocialSharingSettings({ + enabled: true, + facebook: { uploadFullVideo: true }, + }) + ).toBeUndefined(); + }); + + it('sets useVideoClip only for X link plus video preview', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings( { enabled: true, - facebook: { postLink: true, uploadFullVideo: true, linkMessage: 'Watch now' }, + x: { postLink: true, uploadVideoPreview: true, linkMessage: 'Preview clip' }, }, - { defaultTitle: 'Sunday Sermon', defaultDescription: 'Shared description' } + { defaultTitle: 'Sunday Sermon' } ) ).toEqual({ - socialSharing: [{ platform: 'facebook', message: 'Watch now' }], + platforms: [{ platform: 'twitter', message: 'Preview clip', useVideoClip: true }], + twitter: true, }); }); - it('sets useVideoClip only for X video preview', () => { + it('sets useVideoClip false for X link-only posts', () => { expect( - buildSermonAudioSocialSharingCreateFields( + buildSermonAudioSocialSharingSettings({ + enabled: true, + x: { postLink: true, linkMessage: 'Read more' }, + }) + ).toEqual({ + platforms: [{ platform: 'twitter', message: 'Read more', useVideoClip: false }], + twitter: true, + }); + }); + + it('ignores X uploadVideoPreview without postLink', () => { + expect( + buildSermonAudioSocialSharingSettings({ + enabled: true, + x: { uploadVideoPreview: true }, + }) + ).toBeUndefined(); + }); + + it('sets useVideoClip only for Instagram link plus video preview', () => { + expect( + buildSermonAudioSocialSharingSettings( { enabled: true, - x: { postLink: true, uploadVideoPreview: true, linkMessage: 'Preview clip' }, + instagram: { postLink: true, uploadVideoPreview: true, linkMessage: 'Preview clip' }, }, { defaultTitle: 'Sunday Sermon' } ) ).toEqual({ - socialSharing: [{ platform: 'twitter', message: 'Preview clip', useVideoClip: true }], - social_sharing_video_clip: { - start: 0, - end: SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, - }, + platforms: [{ platform: 'instagram', message: 'Preview clip', useVideoClip: true }], + instagram: true, }); }); - it('omits useVideoClip for X link-only posts', () => { + it('sets useVideoClip false for Instagram link-only posts', () => { expect( - buildSermonAudioSocialSharingCreateFields({ + buildSermonAudioSocialSharingSettings({ enabled: true, - x: { postLink: true, linkMessage: 'Read more' }, + instagram: { postLink: true, linkMessage: 'Read more' }, }) ).toEqual({ - socialSharing: [{ platform: 'twitter', message: 'Read more' }], + platforms: [{ platform: 'instagram', message: 'Read more', useVideoClip: false }], + instagram: true, }); }); + + it('ignores Instagram uploadVideoPreview without postLink', () => { + expect( + buildSermonAudioSocialSharingSettings({ + enabled: true, + instagram: { uploadVideoPreview: true }, + }) + ).toBeUndefined(); + }); }); diff --git a/__tests__/lib/platforms/sermon-audio-schedule.test.ts b/__tests__/lib/platforms/sermon-audio-schedule.test.ts index 4ef5ede9..6d9d0dba 100644 --- a/__tests__/lib/platforms/sermon-audio-schedule.test.ts +++ b/__tests__/lib/platforms/sermon-audio-schedule.test.ts @@ -1,27 +1,25 @@ import { describe, expect, it } from 'vitest'; import { - formatSermonAudioPublishDate, - formatTimeZoneOffsetForInstant, - sermonAudioPublishDateToScheduleParts, - sermonAudioPublishDateToUtcIso, + sermonAudioPublishTimestampToScheduleParts, + validateSermonAudioScheduledPublishTime, } from '@/lib/platforms/sermon-audio-schedule'; describe('sermon-audio-schedule', () => { - it('formats publishDate with wall-clock time and offset', () => { - const publishDate = formatSermonAudioPublishDate('2026-07-01', '09:00', 'America/New_York'); - expect(publishDate).toMatch(/^2026-07-01T09:00:00[+-]\d{2}:\d{2}$/); + it('parses publishTimestamp into schedule picker parts', () => { + const publishTimestamp = Math.floor(Date.parse('2026-07-01T13:00:00.000Z') / 1000); + const parts = sermonAudioPublishTimestampToScheduleParts(publishTimestamp, 'America/New_York'); + expect(parts).toEqual({ dateStr: '2026-07-01', timeStr: '09:00' }); }); - it('round-trips publishDate through schedule picker parts', () => { - const publishDate = formatSermonAudioPublishDate('2026-07-01', '09:00', 'America/New_York'); - const utcIso = sermonAudioPublishDateToUtcIso(publishDate); - expect(utcIso).not.toBeNull(); - const parts = sermonAudioPublishDateToScheduleParts(publishDate, 'America/New_York'); - expect(parts).toEqual({ dateStr: '2026-07-01', timeStr: '09:00' }); + it('allows past publishTimestamp values', () => { + const nowMs = Date.parse('2026-07-01T15:00:00.000Z'); + const pastTs = Math.floor((nowMs - 60 * 60_000) / 1000); + expect(validateSermonAudioScheduledPublishTime(pastTs, nowMs)).toBeUndefined(); }); - it('parses GMT offset labels from Intl', () => { - const offset = formatTimeZoneOffsetForInstant('2026-07-01T13:00:00.000Z', 'America/New_York'); - expect(offset).toMatch(/^[+-]\d{2}:\d{2}$/); + it('rejects publishTimestamp beyond 60 days', () => { + const nowMs = Date.parse('2026-07-01T15:00:00.000Z'); + const tooFarTs = Math.floor((nowMs + 61 * 24 * 60 * 60_000) / 1000); + expect(validateSermonAudioScheduledPublishTime(tooFarTs, nowMs)).toMatch(/60 days/); }); }); diff --git a/__tests__/lib/platforms/sermon-audio-social-connections.test.ts b/__tests__/lib/platforms/sermon-audio-social-connections.test.ts new file mode 100644 index 00000000..05a807cd --- /dev/null +++ b/__tests__/lib/platforms/sermon-audio-social-connections.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + parseSermonAudioRefreshSocialResponse, + sermonAudioRefreshSocialUrl, +} from '@/lib/platforms/sermon-audio-social-connections'; + +describe('parseSermonAudioRefreshSocialResponse', () => { + it('maps refresh_social platforms to Cross Publish destination connection flags', () => { + expect( + parseSermonAudioRefreshSocialResponse({ + google: { hasOAUTH: false, name: null }, + facebook: { + hasOAUTH: true, + name: 'Covenant Reformed Presbyterian Church', + pageName: 'Covenant Reformed Presbyterian Church', + }, + twitter: { hasOAUTH: true, name: 'CRPCHalifax' }, + instagram: { hasOAUTH: false, username: null }, + }) + ).toEqual({ + youtube: { connected: false }, + facebook: { + connected: true, + displayName: 'Covenant Reformed Presbyterian Church', + }, + x: { connected: true, displayName: 'CRPCHalifax' }, + instagram: { connected: false }, + }); + }); + + it('returns disconnected defaults for invalid input', () => { + expect(parseSermonAudioRefreshSocialResponse(null)).toEqual({ + youtube: { connected: false }, + facebook: { connected: false }, + x: { connected: false }, + instagram: { connected: false }, + }); + }); +}); + +describe('sermonAudioRefreshSocialUrl', () => { + it('builds the refresh_social URL for a broadcaster id', () => { + expect(sermonAudioRefreshSocialUrl('crpc')).toBe( + 'https://api.sermonaudio.com/v2/node/broadcasters/crpc/refresh_social?cacheLanguage=en&cacheMax=181&cacheDomain=www.sermonaudio.com' + ); + }); +}); diff --git a/__tests__/lib/platforms/sermon-audio.test.ts b/__tests__/lib/platforms/sermon-audio.test.ts index bbaa3c97..e270d104 100644 --- a/__tests__/lib/platforms/sermon-audio.test.ts +++ b/__tests__/lib/platforms/sermon-audio.test.ts @@ -176,7 +176,7 @@ describe('uploadToSermonAudio', () => { expect(createBody).toMatchObject({ speakerName: 'Rev. Smith' }); }); - it('includes socialSharing on sermon create when Cross Publish is enabled', async () => { + it('does not include cross-publish fields on sermon create', async () => { const fetchMock = vi.mocked(global.fetch); fetchMock .mockResolvedValueOnce( @@ -204,45 +204,10 @@ describe('uploadToSermonAudio', () => { tokens, }); - const createInit = fetchMock.mock.calls[0]?.[1] as RequestInit; - const createBody = JSON.parse(String(createInit.body)) as Record; - expect(createBody.socialSharing).toEqual([ - { platform: 'google', title: 'YT Title', message: 'YT Desc', privacy: 'public' }, - { platform: 'facebook', message: 'Check this out' }, - { platform: 'twitter', message: 'Sunday Sermon', useVideoClip: true }, - ]); - expect(createBody.social_sharing_video_clip).toEqual({ start: 0, end: 120 }); - }); - - it('does not include socialSharing on sermon create when Cross Publish is disabled', async () => { - const fetchMock = vi.mocked(global.fetch); - fetchMock - .mockResolvedValueOnce( - new Response(JSON.stringify({ sermonID: 'sermon-456' }), { status: 200 }) - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ uploadURL: 'https://upload.sermonaudio.com/video' }), { - status: 200, - }) - ) - .mockResolvedValueOnce(new Response('', { status: 200 })); - - await uploadToSermonAudio({ - videoStream: makeVideoStream(), - contentLength: 3, - metadata: { - ...metadata, - crossPublish: { - enabled: false, - facebook: { postLink: true, linkMessage: 'Check this out' }, - }, - }, - tokens, - }); - const createInit = fetchMock.mock.calls[0]?.[1] as RequestInit; const createBody = JSON.parse(String(createInit.body)) as Record; expect(createBody).not.toHaveProperty('socialSharing'); + expect(createBody).not.toHaveProperty('socialSharingSettings'); expect(createBody).not.toHaveProperty('social_sharing_video_clip'); }); @@ -539,19 +504,12 @@ describe('pollSermonAudioProcessing', () => { vi.useRealTimers(); }); - it('resolves when any media.video entry has a videoCodec (custom thumbnail uploaded)', async () => { + it('resolves when hasVideo is true and videoMediaStatus is ready', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - media: { - video: [ - { - videoCodec: 'h264', - adaptiveBitrate: false, - thumbnailImageURL: null, - }, - ], - }, + hasVideo: true, + videoMediaStatus: 'ready', }), { status: 200 } ) @@ -561,7 +519,6 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) @@ -577,64 +534,12 @@ describe('pollSermonAudioProcessing', () => { ); }); - it('resolves when any media.video entry has a thumbnailImageURL (no custom thumbnail)', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - media: { - video: [ - { - videoCodec: 'h264', - adaptiveBitrate: false, - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - }, - ], - }, - }), - { status: 200 } - ) - ); - - await expect( - pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - intervalMs: 1000, - maxAttempts: 3, - }) - ).resolves.toBeUndefined(); - }); - - it('resolves when any media.video entry has a successful videoMediaStatus (custom thumbnail uploaded)', async () => { - vi.mocked(global.fetch).mockResolvedValueOnce( - new Response( - JSON.stringify({ - media: { - video: [{ videoCodec: null, videoMediaStatus: 'ready', thumbnailImageURL: null }], - }, - }), - { status: 200 } - ) - ); - - await expect( - pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - customThumbnailUploaded: true, - intervalMs: 1000, - maxAttempts: 3, - }) - ).resolves.toBeUndefined(); - }); - - it('rejects when any media.video entry reports a failed videoMediaStatus', async () => { + it('rejects when videoMediaStatus reports a failed state', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - media: { - video: [{ videoCodec: null, videoMediaStatus: 'failed', thumbnailImageURL: null }], - }, + hasVideo: true, + videoMediaStatus: 'failed', }), { status: 200 } ) @@ -644,7 +549,6 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) @@ -656,28 +560,14 @@ describe('pollSermonAudioProcessing', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); - it('resolves when the ABR video entry exposes a codec on a progressive rendition (custom thumbnail uploaded)', async () => { + it('rejects when any media.video entry reports a failed videoMediaStatus', async () => { vi.mocked(global.fetch).mockResolvedValueOnce( new Response( JSON.stringify({ - videoMediaStatus: 'ready', + hasVideo: true, + videoMediaStatus: 'processing', media: { - video: [ - { - mediaType: 'mp4', - streamURL: 'https://cloud.sermonaudio.com/media/video/abr/662635113143.m3u8', - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - adaptiveBitrate: true, - videoCodec: null, - }, - { - mediaType: 'mp4', - streamURL: 'https://cloud.sermonaudio.com/media/video/high/662635113143.m3u8', - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/662635113143.jpg', - adaptiveBitrate: false, - videoCodec: 'h264', - }, - ], + video: [{ videoMediaStatus: 'failed' }], }, }), { status: 200 } @@ -688,55 +578,21 @@ describe('pollSermonAudioProcessing', () => { pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 1000, maxAttempts: 3, }) - ).resolves.toBeUndefined(); - }); - - it('keeps polling when codec is ready but thumbnailImageURL is still null (no custom thumbnail)', async () => { - vi.mocked(global.fetch).mockResolvedValue( - new Response( - JSON.stringify({ - videoMediaStatus: 'ready', - media: { - video: [{ videoCodec: 'h264', adaptiveBitrate: false, thumbnailImageURL: null }], - }, - }), - { status: 200 } - ) - ); - - const promise = pollSermonAudioProcessing({ - sermonID: 'sermon-123', - tokens, - intervalMs: 100, - maxAttempts: 2, - }); - - const expectation = expect(promise).rejects.toMatchObject({ - code: 'SERMONAUDIO_PROCESSING_TIMEOUT', + ).rejects.toMatchObject({ + code: 'SERMONAUDIO_PROCESSING_FAILED', + details: 'videoMediaStatus: failed', }); - await vi.runAllTimersAsync(); - await expectation; - expect(global.fetch).toHaveBeenCalledTimes(2); }); - it('keeps polling when a custom thumbnail is present but transcoding is not done yet', async () => { + it('keeps polling when video is not ready yet', async () => { vi.mocked(global.fetch).mockResolvedValue( new Response( JSON.stringify({ - media: { - video: [ - { - videoCodec: null, - videoMediaStatus: 'processing', - adaptiveBitrate: false, - thumbnailImageURL: 'https://media.sermonaudio.com/thumbnails/custom.jpg', - }, - ], - }, + hasVideo: true, + videoMediaStatus: 'processing', }), { status: 200 } ) @@ -745,7 +601,6 @@ describe('pollSermonAudioProcessing', () => { const promise = pollSermonAudioProcessing({ sermonID: 'sermon-123', tokens, - customThumbnailUploaded: true, intervalMs: 100, maxAttempts: 2, }); @@ -758,13 +613,12 @@ describe('pollSermonAudioProcessing', () => { expect(global.fetch).toHaveBeenCalledTimes(2); }); - it('rejects when max attempts are exceeded without a generated thumbnail (no custom thumbnail)', async () => { + it('keeps polling when hasVideo is false', async () => { vi.mocked(global.fetch).mockResolvedValue( new Response( JSON.stringify({ - media: { - video: [{ videoCodec: 'h264', adaptiveBitrate: false, thumbnailImageURL: null }], - }, + hasVideo: false, + videoMediaStatus: 'ready', }), { status: 200 } ) @@ -787,7 +641,9 @@ describe('pollSermonAudioProcessing', () => { it('rejects when the abort signal fires during polling', async () => { vi.mocked(global.fetch).mockResolvedValue( - new Response(JSON.stringify({ media: { video: [] } }), { status: 200 }) + new Response(JSON.stringify({ hasVideo: false, videoMediaStatus: 'processing' }), { + status: 200, + }) ); const controller = new AbortController(); @@ -810,17 +666,14 @@ describe('pollSermonAudioProcessing', () => { describe('publishSermonAudio', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-06-05T12:00:00.000Z')); }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); - vi.useRealTimers(); }); - it('PATCHes publishDate only on success (Cross Publish is configured on sermon create)', async () => { + it('PATCHes publishTimestamp when Cross Publish is disabled', async () => { vi.mocked(global.fetch).mockResolvedValueOnce(new Response('', { status: 200 })); await publishSermonAudio({ @@ -833,9 +686,59 @@ describe('publishSermonAudio', () => { expect.objectContaining({ method: 'PATCH', headers: expect.objectContaining({ 'X-Api-Key': 'sa-api-key' }), - body: JSON.stringify({ publishDate: '2026-06-05' }), + body: expect.stringMatching(/"publishTimestamp":\d+/), }) ); + const body = JSON.parse(String(vi.mocked(global.fetch).mock.calls[0]?.[1]?.body)); + expect(body.publishNow).toBeUndefined(); + }); + + it('PATCHes publishTimestamp with socialSharingSettings when Cross Publish is enabled', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('', { status: 200 })); + + await publishSermonAudio({ + sermonID: 'sermon-123', + tokens, + crossPublish: { + enabled: true, + facebook: { postLink: true, linkMessage: 'Check this out' }, + }, + defaultTitle: 'Sunday Sermon', + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.sermonaudio.com/v2/node/sermons/sermon-123', + expect.objectContaining({ + method: 'PATCH', + body: expect.stringContaining('"socialSharingSettings"'), + }) + ); + const body = JSON.parse(String(vi.mocked(global.fetch).mock.calls[0]?.[1]?.body)); + expect(body.publishTimestamp).toEqual(expect.any(Number)); + expect(body.publishNow).toBeUndefined(); + expect(body.socialSharingSettings).toEqual({ + platforms: [ + { + platform: 'facebook', + message: 'Check this out', + useVideoClip: false, + }, + ], + facebook: true, + }); + }); + + it('PATCHes an explicit publishTimestamp when provided', async () => { + vi.mocked(global.fetch).mockResolvedValueOnce(new Response('', { status: 200 })); + + await publishSermonAudio({ + sermonID: 'sermon-123', + tokens, + publishTimestamp: 1_782_772_962, + }); + + const body = JSON.parse(String(vi.mocked(global.fetch).mock.calls[0]?.[1]?.body)); + expect(body.publishTimestamp).toBe(1_782_772_962); }); it('throws when publish request fails', async () => { diff --git a/__tests__/lib/platforms/token-refresh.test.ts b/__tests__/lib/platforms/token-refresh.test.ts index 3c6fc613..dd6a8a2d 100644 --- a/__tests__/lib/platforms/token-refresh.test.ts +++ b/__tests__/lib/platforms/token-refresh.test.ts @@ -3,6 +3,7 @@ import type { ConnectedAccount } from '@/types'; const mockRefreshYouTubeAccessToken = vi.fn(); const mockUpdateTokens = vi.fn(); +const mockClearOAuthRefreshToken = vi.fn(); vi.mock('@/lib/platforms/youtube', () => ({ refreshYouTubeAccessToken: (...args: unknown[]) => mockRefreshYouTubeAccessToken(...args), @@ -24,6 +25,7 @@ vi.mock('@/lib/platforms/facebook-oauth', async (importOriginal) => { vi.mock('@/lib/repositories/connected-accounts', () => ({ updateTokens: (...args: unknown[]) => mockUpdateTokens(...args), + clearOAuthRefreshToken: (...args: unknown[]) => mockClearOAuthRefreshToken(...args), })); import { @@ -166,6 +168,21 @@ describe('refreshTokenIfNeeded', () => { await expect(refreshTokenIfNeeded(acc)).rejects.toThrow(/YOUTUBE_TOKEN_REFRESH_FAILED/); }); + it('clears stored refresh token when YouTube refresh returns invalid_grant', async () => { + mockRefreshYouTubeAccessToken.mockResolvedValue({ + ok: false, + error: { + code: 'YOUTUBE_TOKEN_REFRESH_FAILED', + message: 'Failed to refresh YouTube access token.', + details: 'invalid_grant: Token has been expired or revoked.', + }, + }); + + const acc = youtubeAccount({ tokenExpiry: new Date(Date.now() - 1000).toISOString() }); + await expect(refreshTokenIfNeeded(acc)).rejects.toThrow(/YOUTUBE_TOKEN_REFRESH_FAILED/); + expect(mockClearOAuthRefreshToken).toHaveBeenCalledWith('acc-1'); + }); + it('returns stored Vimeo tokens without calling YouTube refresh', async () => { const past = new Date(Date.now() - 60_000).toISOString(); const acc: ConnectedAccount = { diff --git a/__tests__/lib/r2-upload-local-file.test.ts b/__tests__/lib/r2-upload-local-file.test.ts new file mode 100644 index 00000000..62a429fb --- /dev/null +++ b/__tests__/lib/r2-upload-local-file.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSend = vi.hoisted(() => vi.fn()); +const mockUploadDone = vi.hoisted(() => vi.fn()); +const mockUploadConstructor = vi.hoisted(() => vi.fn()); + +vi.mock('@aws-sdk/client-s3', () => ({ + S3Client: vi.fn(function MockS3Client(this: { send: typeof mockSend }) { + this.send = mockSend; + }), + GetObjectCommand: vi.fn(), + PutObjectCommand: vi.fn(), + DeleteObjectCommand: vi.fn(), + HeadObjectCommand: vi.fn(function HeadObjectCommand(input: unknown) { + return { input, _type: 'HeadObjectCommand' }; + }), + CopyObjectCommand: vi.fn(), + CreateMultipartUploadCommand: vi.fn(), + UploadPartCommand: vi.fn(), + CompleteMultipartUploadCommand: vi.fn(), + AbortMultipartUploadCommand: vi.fn(), +})); + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: vi.fn(), +})); + +vi.mock('@aws-sdk/lib-storage', () => ({ + Upload: vi.fn(function MockUpload(this: { done: typeof mockUploadDone }, config: unknown) { + mockUploadConstructor(config); + this.done = mockUploadDone; + }), +})); + +import { HeadObjectCommand } from '@aws-sdk/client-s3'; +import { uploadLocalFileToR2 } from '@/lib/r2'; + +const MOCK_BUCKET = 'test-bucket'; +const KEY = 'temp/uploads/user-1/import/trimmed.mp4'; +const CONTENT_TYPE = 'video/mp4'; +const OBJECT_SIZE = 42_000_000; + +describe('uploadLocalFileToR2', () => { + let tempDir = ''; + let localFilePath = ''; + + beforeAll(async () => { + process.env.R2_ACCOUNT_ID = 'test-account'; + process.env.R2_ACCESS_KEY_ID = 'test-access-key'; + process.env.R2_SECRET_ACCESS_KEY = 'test-secret-key'; + process.env.R2_BUCKET_NAME = MOCK_BUCKET; + + tempDir = await mkdtemp(join(tmpdir(), 'r2-upload-local-')); + localFilePath = join(tempDir, 'trimmed.mp4'); + await writeFile(localFilePath, Buffer.alloc(1024)); + }); + + afterAll(async () => { + delete process.env.R2_ACCOUNT_ID; + delete process.env.R2_ACCESS_KEY_ID; + delete process.env.R2_SECRET_ACCESS_KEY; + delete process.env.R2_BUCKET_NAME; + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockSend.mockReset(); + mockUploadDone.mockReset(); + }); + + it('streams the local file via Upload and returns the HEAD content length', async () => { + mockUploadDone.mockResolvedValueOnce(undefined); + mockSend.mockResolvedValueOnce({ ContentLength: OBJECT_SIZE }); + + const size = await uploadLocalFileToR2(localFilePath, KEY, CONTENT_TYPE); + + expect(size).toBe(OBJECT_SIZE); + expect(mockUploadConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + client: expect.objectContaining({ send: mockSend }), + params: expect.objectContaining({ + Bucket: MOCK_BUCKET, + Key: KEY, + ContentType: CONTENT_TYPE, + }), + }) + ); + const uploadConfig = mockUploadConstructor.mock.calls[0]?.[0] as { + params?: { Body?: { path?: string } }; + }; + expect(uploadConfig.params?.Body?.path).toBe(localFilePath); + expect(mockUploadDone).toHaveBeenCalledTimes(1); + expect(HeadObjectCommand).toHaveBeenCalledWith({ + Bucket: MOCK_BUCKET, + Key: KEY, + }); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('propagates errors from the Upload SDK', async () => { + const sdkError = new Error('multipart upload failed'); + mockUploadDone.mockRejectedValueOnce(sdkError); + + await expect(uploadLocalFileToR2(localFilePath, KEY, CONTENT_TYPE)).rejects.toThrow( + 'multipart upload failed' + ); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('propagates errors from the post-upload HEAD request', async () => { + mockUploadDone.mockResolvedValueOnce(undefined); + mockSend.mockRejectedValueOnce(new Error('head failed')); + + await expect(uploadLocalFileToR2(localFilePath, KEY, CONTENT_TYPE)).rejects.toThrow( + `Failed to HEAD object "${KEY}": head failed` + ); + }); +}); diff --git a/__tests__/lib/schedule-bounds.test.ts b/__tests__/lib/schedule-bounds.test.ts index 961d1282..59705f17 100644 --- a/__tests__/lib/schedule-bounds.test.ts +++ b/__tests__/lib/schedule-bounds.test.ts @@ -6,8 +6,10 @@ import { getScheduleMaxDate, getScheduleMaxDateTimeMs, getScheduleMinDate, + SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS, validateFacebookScheduledPublishTime, validateSchedulePublishAtIso, + validateSermonAudioScheduledPublishTime, YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS, } from '@/lib/schedule-bounds'; @@ -24,8 +26,13 @@ describe('schedule bounds', () => { expect(getScheduleMaxDate('facebook', now).toISOString().slice(0, 10)).toBe('2025-08-22'); }); + it('limits SermonAudio dates to today through 60 days ahead', () => { + const july1 = new Date('2026-07-01T12:00:00.000Z'); + expect(getScheduleMaxDate('sermon_audio', july1).toISOString().slice(0, 10)).toBe('2026-08-30'); + }); + it('ends the max schedule day at local end-of-day (DST-safe)', () => { - for (const platform of ['youtube', 'facebook'] as const) { + for (const platform of ['youtube', 'facebook', 'sermon_audio'] as const) { const maxDay = getScheduleMaxDate(platform, now); expect(getScheduleMaxDateTimeMs(platform, now)).toBe(endOfDay(maxDay).getTime()); } @@ -47,6 +54,20 @@ describe('schedule bounds', () => { expect(validateFacebookScheduledPublishTime(tooFarSec, now.getTime())).toMatch(/75 days/); }); + it('accepts SermonAudio timestamps in the past and within 60 days', () => { + const pastSec = Math.floor(now.getTime() / 1000) - 3600; + const futureSec = + Math.floor(now.getTime() / 1000) + SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS * 24 * 60 * 60; + expect(validateSermonAudioScheduledPublishTime(pastSec, now.getTime())).toBeUndefined(); + expect(validateSermonAudioScheduledPublishTime(futureSec, now.getTime())).toBeUndefined(); + }); + + it('rejects SermonAudio timestamps beyond 60 days', () => { + const tooFarSec = + Math.floor(now.getTime() / 1000) + (SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS + 1) * 24 * 60 * 60; + expect(validateSermonAudioScheduledPublishTime(tooFarSec, now.getTime())).toMatch(/60 days/); + }); + it('rejects timezone-less ISO schedule timestamps', () => { expect(validateSchedulePublishAtIso('2026-06-09T15:30:00', now)).toBe( 'Scheduled time must be a valid date and time.' diff --git a/__tests__/lib/schedule-date-time.test.ts b/__tests__/lib/schedule-date-time.test.ts index f9a8ae88..4e74a053 100644 --- a/__tests__/lib/schedule-date-time.test.ts +++ b/__tests__/lib/schedule-date-time.test.ts @@ -6,6 +6,7 @@ import { normalizeScheduleTimeStr, parseScheduleHourInput, parseScheduleMinuteInput, + parseScheduleTimeInput, parseScheduleTimeParts, scheduleDateStrToDate, scheduleDateToDateStr, @@ -72,4 +73,20 @@ describe('schedule-date-time helpers', () => { expect(parseScheduleMinuteInput('1.5')).toBeNull(); expect(parseScheduleMinuteInput('1e2')).toBeNull(); }); + + it('parses free-form schedule time input', () => { + expect(parseScheduleTimeInput('2:00 pm', { hour12: true })).toEqual({ hour: 14, minute: 0 }); + expect(parseScheduleTimeInput('2:00pm', { hour12: true })).toEqual({ hour: 14, minute: 0 }); + expect(parseScheduleTimeInput('2 pm', { hour12: true })).toEqual({ hour: 14, minute: 0 }); + expect(parseScheduleTimeInput('2pm', { hour12: true })).toEqual({ hour: 14, minute: 0 }); + expect(parseScheduleTimeInput('12:30 am', { hour12: true })).toEqual({ hour: 0, minute: 30 }); + expect(parseScheduleTimeInput('14:00', { hour12: false })).toEqual({ hour: 14, minute: 0 }); + expect(parseScheduleTimeInput('9:05')).toEqual({ hour: 9, minute: 5 }); + expect(parseScheduleTimeInput('2:00', { hour12: true, fallbackPeriod: 'PM' })).toEqual({ + hour: 14, + minute: 0, + }); + expect(parseScheduleTimeInput('invalid')).toBeNull(); + expect(parseScheduleTimeInput('13:00 pm', { hour12: true })).toBeNull(); + }); }); diff --git a/__tests__/lib/uploads/status.test.ts b/__tests__/lib/uploads/status.test.ts index b67d0aef..c9611dc4 100644 --- a/__tests__/lib/uploads/status.test.ts +++ b/__tests__/lib/uploads/status.test.ts @@ -4,14 +4,16 @@ import { isPlatformUploadRowActive, isPlatformUploadStatusInProgress, isSermonAudioAwaitingAutoPublish, + resolveSermonAudioTerminalUploadStatus, SERMONAUDIO_AUTO_PUBLISH_UI_STALE_MS, } from '@/lib/uploads/status'; describe('isPlatformUploadDistributionComplete', () => { - it('treats completed, unpublished, and published as distribution-complete', () => { + it('treats completed, unpublished, published, and scheduled as distribution-complete', () => { expect(isPlatformUploadDistributionComplete('completed')).toBe(true); expect(isPlatformUploadDistributionComplete('unpublished')).toBe(true); expect(isPlatformUploadDistributionComplete('published')).toBe(true); + expect(isPlatformUploadDistributionComplete('scheduled')).toBe(true); }); it('treats pending, uploading, and failed as not distribution-complete', () => { @@ -31,15 +33,31 @@ describe('isPlatformUploadStatusInProgress', () => { expect(isPlatformUploadStatusInProgress('completed')).toBe(false); expect(isPlatformUploadStatusInProgress('unpublished')).toBe(false); expect(isPlatformUploadStatusInProgress('published')).toBe(false); + expect(isPlatformUploadStatusInProgress('scheduled')).toBe(false); expect(isPlatformUploadStatusInProgress('failed')).toBe(false); }); }); +describe('resolveSermonAudioTerminalUploadStatus', () => { + const nowSec = 1_700_000_000; + + it('returns scheduled when publishTimestamp is in the future', () => { + expect(resolveSermonAudioTerminalUploadStatus(nowSec + 3600, nowSec)).toBe('scheduled'); + }); + + it('returns published when publishTimestamp is now, past, or omitted', () => { + expect(resolveSermonAudioTerminalUploadStatus(undefined, nowSec)).toBe('published'); + expect(resolveSermonAudioTerminalUploadStatus(nowSec, nowSec)).toBe('published'); + expect(resolveSermonAudioTerminalUploadStatus(nowSec - 60, nowSec)).toBe('published'); + }); +}); + describe('isSermonAudioAwaitingAutoPublish', () => { it('is true only for unpublished rows when auto-publish is enabled', () => { expect(isSermonAudioAwaitingAutoPublish('unpublished', true)).toBe(true); expect(isSermonAudioAwaitingAutoPublish('unpublished', false)).toBe(false); expect(isSermonAudioAwaitingAutoPublish('published', true)).toBe(false); + expect(isSermonAudioAwaitingAutoPublish('scheduled', true)).toBe(false); }); }); diff --git a/__tests__/lib/youtube-import/discard-draft-import.test.ts b/__tests__/lib/youtube-import/discard-draft-import.test.ts new file mode 100644 index 00000000..c3d5181c --- /dev/null +++ b/__tests__/lib/youtube-import/discard-draft-import.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetDraftById = vi.fn(); +const mockGetYoutubeImportJobForDraftEditor = vi.fn(); +const mockUpdateYoutubeImportJobStatus = vi.fn(); +const mockGetUploadJobById = vi.fn(); +const mockUpdateUploadJobStatus = vi.fn(); +const mockDeleteObject = vi.fn(); + +vi.mock('@/lib/repositories/drafts', () => ({ + getDraftById: (...args: unknown[]) => mockGetDraftById(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getYoutubeImportJobForDraftEditor: (...args: unknown[]) => + mockGetYoutubeImportJobForDraftEditor(...args), + getYoutubeImportJobById: vi.fn(), + updateYoutubeImportJobStatus: (...args: unknown[]) => mockUpdateYoutubeImportJobStatus(...args), +})); + +vi.mock('@/lib/repositories/upload-jobs', () => ({ + getUploadJobById: (...args: unknown[]) => mockGetUploadJobById(...args), + updateUploadJobStatus: (...args: unknown[]) => mockUpdateUploadJobStatus(...args), +})); + +vi.mock('@/lib/r2', () => ({ + deleteObject: (...args: unknown[]) => mockDeleteObject(...args), + R2ObjectNotFoundError: class R2ObjectNotFoundError extends Error {}, +})); + +import { discardBlockingDraftYoutubeImport } from '@/lib/youtube-import/discard-draft-import'; + +const USER_ID = 'user-123'; +const DRAFT_ID = 'draft-1'; + +beforeEach(() => { + vi.clearAllMocks(); + mockGetDraftById.mockResolvedValue({ id: DRAFT_ID, userId: USER_ID }); + mockUpdateYoutubeImportJobStatus.mockResolvedValue(undefined); + mockUpdateUploadJobStatus.mockResolvedValue(null); + mockDeleteObject.mockResolvedValue(undefined); +}); + +describe('discardBlockingDraftYoutubeImport', () => { + it('cancels an active import job for the draft', async () => { + mockGetYoutubeImportJobForDraftEditor.mockResolvedValue({ + id: 'import-1', + userId: USER_ID, + draftId: DRAFT_ID, + status: 'downloading', + uploadJobId: null, + }); + + await discardBlockingDraftYoutubeImport(DRAFT_ID, USER_ID); + + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('import-1', { + status: 'cancelled', + errorMessage: null, + }); + }); + + it('discards a staged upload and cancels the completed import job', async () => { + mockGetYoutubeImportJobForDraftEditor.mockResolvedValue({ + id: 'import-2', + userId: USER_ID, + draftId: DRAFT_ID, + status: 'completed', + uploadJobId: 'upload-1', + }); + mockGetUploadJobById.mockResolvedValue({ + id: 'upload-1', + userId: USER_ID, + status: 'uploading', + r2Key: 'temp/uploads/user-123/video.mp4', + }); + + await discardBlockingDraftYoutubeImport(DRAFT_ID, USER_ID); + + expect(mockUpdateUploadJobStatus).toHaveBeenCalledWith('upload-1', 'cancelled', null); + expect(mockDeleteObject).toHaveBeenCalledWith('temp/uploads/user-123/video.mp4'); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('import-2', { + status: 'cancelled', + errorMessage: null, + distributeQueued: false, + }); + }); +}); diff --git a/__tests__/lib/youtube-import/execute-import-job.test.ts b/__tests__/lib/youtube-import/execute-import-job.test.ts new file mode 100644 index 00000000..e8769396 --- /dev/null +++ b/__tests__/lib/youtube-import/execute-import-job.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockClaimPendingYoutubeImportJob = vi.fn(); +const mockGetYoutubeImportJobById = vi.fn(); +const mockRunYoutubeImportJob = vi.fn(); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + claimPendingYoutubeImportJob: (...args: unknown[]) => mockClaimPendingYoutubeImportJob(...args), + getYoutubeImportJobById: (...args: unknown[]) => mockGetYoutubeImportJobById(...args), +})); + +vi.mock('@/lib/youtube-import/run-import-job', () => ({ + runYoutubeImportJob: (...args: unknown[]) => mockRunYoutubeImportJob(...args), +})); + +import { executeYoutubeImportJobWorker } from '@/lib/youtube-import/execute-import-job'; + +const JOB_ID = 'import-job-1'; +const USER_ID = 'user-123'; + +const pendingJob = { + id: JOB_ID, + userId: USER_ID, + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 0, + endSeconds: 120, + status: 'pending' as const, + progressPercent: 0, + errorMessage: null, + r2Key: null, + uploadJobId: null, + distributeQueued: false, + $createdAt: '2026-01-01T00:00:00.000Z', + $updatedAt: '2026-01-01T00:00:00.000Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockClaimPendingYoutubeImportJob.mockResolvedValue({ + ...pendingJob, + status: 'downloading', + }); + mockRunYoutubeImportJob.mockResolvedValue(undefined); +}); + +describe('executeYoutubeImportJobWorker', () => { + it('claims and runs a pending job', async () => { + const result = await executeYoutubeImportJobWorker(JOB_ID, USER_ID); + + expect(result).toEqual({ outcome: 'ran' }); + expect(mockClaimPendingYoutubeImportJob).toHaveBeenCalledWith(JOB_ID, USER_ID); + expect(mockRunYoutubeImportJob).toHaveBeenCalledWith(JOB_ID); + }); + + it('returns already_running when the job is no longer pending', async () => { + mockClaimPendingYoutubeImportJob.mockResolvedValueOnce(null); + mockGetYoutubeImportJobById.mockResolvedValueOnce({ + ...pendingJob, + status: 'downloading', + }); + + const result = await executeYoutubeImportJobWorker(JOB_ID, USER_ID); + + expect(result).toEqual({ outcome: 'already_running', status: 'downloading' }); + expect(mockRunYoutubeImportJob).not.toHaveBeenCalled(); + }); + + it('returns not_found when the job row disappeared', async () => { + mockClaimPendingYoutubeImportJob.mockResolvedValueOnce(null); + mockGetYoutubeImportJobById.mockResolvedValueOnce(null); + + const result = await executeYoutubeImportJobWorker(JOB_ID, USER_ID); + + expect(result).toEqual({ outcome: 'not_found' }); + }); +}); diff --git a/__tests__/lib/youtube-import/preview-media-url.test.ts b/__tests__/lib/youtube-import/preview-media-url.test.ts new file mode 100644 index 00000000..7a9fef84 --- /dev/null +++ b/__tests__/lib/youtube-import/preview-media-url.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildYoutubeImportPreviewStreamPath, + clearPreviewMediaCacheForTests, + isAllowedPreviewUpstreamUrl, + resolvePreviewDirectMediaUrl, + setPreviewMediaCacheMaxEntriesForTests, +} from '@/lib/youtube-import/preview-media-url'; + +const mockGetDirectMediaUrl = vi.fn(); + +vi.mock('@/lib/youtube-import/probe-keyframes', () => ({ + getDirectMediaUrl: (...args: unknown[]) => mockGetDirectMediaUrl(...args), +})); + +describe('isAllowedPreviewUpstreamUrl', () => { + it('allows googlevideo.com hosts', () => { + expect( + isAllowedPreviewUpstreamUrl('https://r1---sn.example.googlevideo.com/videoplayback') + ).toBe(true); + }); + + it('rejects non-CDN YouTube hosts', () => { + expect(isAllowedPreviewUpstreamUrl('https://www.youtube.com/videoplayback')).toBe(false); + expect(isAllowedPreviewUpstreamUrl('https://evil.example.com/video.mp4')).toBe(false); + }); +}); + +describe('buildYoutubeImportPreviewStreamPath', () => { + it('builds a same-origin preview stream path', () => { + expect(buildYoutubeImportPreviewStreamPath('dQw4w9WgXcQ')).toBe( + '/api/youtube-import/preview/stream?youtubeVideoId=dQw4w9WgXcQ' + ); + }); +}); + +describe('resolvePreviewDirectMediaUrl', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearPreviewMediaCacheForTests(); + setPreviewMediaCacheMaxEntriesForTests(null); + mockGetDirectMediaUrl.mockResolvedValue({ + url: 'https://r1---sn.example.googlevideo.com/videoplayback', + expiresAt: Date.now() + 3_600_000, + }); + }); + + it('resolves and caches direct media URLs', async () => { + const first = await resolvePreviewDirectMediaUrl('user-1', 'dQw4w9WgXcQ'); + const second = await resolvePreviewDirectMediaUrl('user-1', 'dQw4w9WgXcQ'); + + expect(first.url).toContain('googlevideo.com'); + expect(second.url).toBe(first.url); + expect(mockGetDirectMediaUrl).toHaveBeenCalledTimes(1); + }); + + it('bypasses the cache when forceRefresh is true', async () => { + await resolvePreviewDirectMediaUrl('user-1', 'dQw4w9WgXcQ'); + await resolvePreviewDirectMediaUrl('user-1', 'dQw4w9WgXcQ', { forceRefresh: true }); + + expect(mockGetDirectMediaUrl).toHaveBeenCalledTimes(2); + }); + + it('evicts the oldest entry when the cache exceeds its size cap', async () => { + setPreviewMediaCacheMaxEntriesForTests(2); + + await resolvePreviewDirectMediaUrl('user-1', 'aaaaaaaaaaa'); + await resolvePreviewDirectMediaUrl('user-1', 'bbbbbbbbbbb'); + await resolvePreviewDirectMediaUrl('user-1', 'ccccccccccc'); + await resolvePreviewDirectMediaUrl('user-1', 'aaaaaaaaaaa'); + + expect(mockGetDirectMediaUrl).toHaveBeenCalledTimes(4); + }); +}); diff --git a/__tests__/lib/youtube-import/probe-keyframes.test.ts b/__tests__/lib/youtube-import/probe-keyframes.test.ts new file mode 100644 index 00000000..192883cb --- /dev/null +++ b/__tests__/lib/youtube-import/probe-keyframes.test.ts @@ -0,0 +1,226 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSpawnProcess = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/youtube-import/spawn-process', () => ({ + spawnProcess: (...args: unknown[]) => mockSpawnProcess(...args), +})); + +import { + getDirectMediaUrl, + parseFfprobeKeyframeCsv, + probeNearbyKeyframes, + setYouTubeImportProcessTimeoutMsForTests, +} from '@/lib/youtube-import/probe-keyframes'; + +type MockChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; +}; + +function createMockChild(options: { + stdout?: string; + stderr?: string; + code?: number; + delayMs?: number; + hangUntilKilled?: boolean; +}): MockChild { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => { + child.emit('close', options.code ?? null); + }); + + setTimeout(() => { + if (options.stdout) { + child.stdout.emit('data', Buffer.from(options.stdout)); + } + if (options.stderr) { + child.stderr.emit('data', Buffer.from(options.stderr)); + } + if (!options.hangUntilKilled) { + child.emit('close', options.code ?? 0); + } + }, options.delayMs ?? 0); + + return child; +} + +describe('parseFfprobeKeyframeCsv', () => { + it('parses keyframe rows from ffprobe csv output', () => { + const stdout = ['1,0.000000', '0,0.040000', '1,2.000000', '0,2.040000'].join('\n'); + + expect(parseFfprobeKeyframeCsv(stdout)).toEqual([0, 2]); + }); + + it('returns an empty array when no keyframes are present', () => { + expect(parseFfprobeKeyframeCsv('0,0.040000\n0,0.080000\n')).toEqual([]); + }); +}); + +describe('probeNearbyKeyframes', () => { + beforeEach(() => { + mockSpawnProcess.mockReset(); + setYouTubeImportProcessTimeoutMsForTests(null); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs ffprobe with a centered read interval and returns parsed keyframes', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stdout: '1,10.000000\n0,10.040000\n1,12.000000\n', + }) + ); + + const keyframes = await probeNearbyKeyframes('https://example.com/video.mp4', 14, 8); + + expect(keyframes).toEqual([10, 12]); + expect(mockSpawnProcess).toHaveBeenCalledWith( + 'ffprobe', + [ + '-read_intervals', + '10%+8', + '-select_streams', + 'v:0', + '-show_entries', + 'frame=key_frame,pts_time', + '-of', + 'csv=p=0', + 'https://example.com/video.mp4', + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + }); + + it('returns an empty array when ffprobe finds no keyframes in the window', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stdout: '0,1.000000\n0,1.040000\n', + }) + ); + + await expect(probeNearbyKeyframes('https://example.com/video.mp4', 1)).resolves.toEqual([]); + }); + + it('rejects when ffprobe exits non-zero', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stderr: 'Invalid data found when processing input', + code: 1, + }) + ); + + await expect(probeNearbyKeyframes('https://example.com/video.mp4', 5)).rejects.toThrow( + /ffprobe keyframe probe failed/ + ); + }); + + it('rejects when ffprobe exceeds the process timeout', async () => { + setYouTubeImportProcessTimeoutMsForTests(50); + + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + hangUntilKilled: true, + }) + ); + + await expect(probeNearbyKeyframes('https://example.com/video.mp4', 5)).rejects.toThrow( + /timed out after 50ms/ + ); + }); +}); + +describe('getDirectMediaUrl', () => { + beforeEach(() => { + mockSpawnProcess.mockReset(); + setYouTubeImportProcessTimeoutMsForTests(null); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('selects a low-resolution progressive format from yt-dlp metadata', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stdout: JSON.stringify({ + formats: [ + { + url: 'https://example.com/high.mp4', + height: 1080, + vcodec: 'avc1', + acodec: 'mp4a', + }, + { + url: 'https://example.com/low.mp4?expire=2000000000', + height: 360, + vcodec: 'avc1', + acodec: 'mp4a', + }, + ], + }), + }) + ); + + const result = await getDirectMediaUrl('dQw4w9WgXcQ'); + + expect(result.url).toBe('https://example.com/low.mp4?expire=2000000000'); + expect(result.expiresAt).toBe(2_000_000_000_000); + expect(mockSpawnProcess).toHaveBeenCalledWith( + 'yt-dlp', + expect.arrayContaining([ + '-J', + '--no-playlist', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + ]), + { stdio: ['ignore', 'pipe', 'pipe'] } + ); + const args = mockSpawnProcess.mock.calls[0]?.[1] as string[]; + expect(args).toContain('--js-runtimes'); + }); + + it('rejects when yt-dlp exits non-zero', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stderr: 'Video unavailable', + code: 1, + }) + ); + + await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow(/yt-dlp metadata lookup failed/); + }); + + it('returns a friendly message when yt-dlp reports a private video', async () => { + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + stderr: + "ERROR: [youtube] h3DhnqpppU8: Private video. Sign in if you've been granted access to this video.", + code: 1, + }) + ); + + await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow( + 'This video is private. Make it public or unlisted on YouTube before importing.' + ); + }); + + it('rejects when yt-dlp exceeds the process timeout', async () => { + setYouTubeImportProcessTimeoutMsForTests(50); + + mockSpawnProcess.mockImplementationOnce(() => + createMockChild({ + hangUntilKilled: true, + }) + ); + + await expect(getDirectMediaUrl('dQw4w9WgXcQ')).rejects.toThrow(/timed out after 50ms/); + }); +}); diff --git a/__tests__/lib/youtube-import/proxy-preview-media.test.ts b/__tests__/lib/youtube-import/proxy-preview-media.test.ts new file mode 100644 index 00000000..c3a9d0a0 --- /dev/null +++ b/__tests__/lib/youtube-import/proxy-preview-media.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchProxiedPreviewMedia } from '@/lib/youtube-import/proxy-preview-media'; + +describe('fetchProxiedPreviewMedia', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards range requests to the upstream media URL', async () => { + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValue( + new Response('video-bytes', { + status: 206, + headers: { + 'Content-Type': 'video/mp4', + 'Content-Range': 'bytes 0-99/1000', + 'Accept-Ranges': 'bytes', + }, + }) + ); + + const response = await fetchProxiedPreviewMedia( + 'https://r1---sn.example.googlevideo.com/videoplayback', + 'bytes=0-99' + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://r1---sn.example.googlevideo.com/videoplayback', + expect.objectContaining({ + headers: expect.objectContaining({ Range: 'bytes=0-99' }), + }) + ); + expect(response.status).toBe(206); + expect(response.headers.get('content-range')).toBe('bytes 0-99/1000'); + expect(response.headers.get('cache-control')).toBe('no-store'); + }); +}); diff --git a/__tests__/lib/youtube-import/resolve-source.test.ts b/__tests__/lib/youtube-import/resolve-source.test.ts new file mode 100644 index 00000000..a18fd9e9 --- /dev/null +++ b/__tests__/lib/youtube-import/resolve-source.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + buildYouTubeWatchUrl, + extractYouTubeVideoId, + isYouTubeImportableCompletedBroadcast, + mapYouTubeImportResolvedSource, + parseIso8601DurationToSeconds, +} from '@/lib/youtube-import/resolve-source'; + +describe('extractYouTubeVideoId', () => { + it('parses youtube.com/watch URLs', () => { + expect(extractYouTubeVideoId('https://www.youtube.com/watch?v=dQw4w9WgXcQ')).toBe( + 'dQw4w9WgXcQ' + ); + expect(extractYouTubeVideoId('https://youtube.com/watch?v=dQw4w9WgXcQ&t=120')).toBe( + 'dQw4w9WgXcQ' + ); + }); + + it('parses youtu.be short URLs', () => { + expect(extractYouTubeVideoId('https://youtu.be/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ'); + }); + + it('parses youtube.com/live URLs', () => { + expect(extractYouTubeVideoId('https://www.youtube.com/live/dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ'); + }); + + it('accepts bare 11-character video ids', () => { + expect(extractYouTubeVideoId('dQw4w9WgXcQ')).toBe('dQw4w9WgXcQ'); + }); + + it('returns null for garbage input', () => { + expect(extractYouTubeVideoId('')).toBeNull(); + expect(extractYouTubeVideoId('not-a-url')).toBeNull(); + expect(extractYouTubeVideoId('https://example.com/watch?v=dQw4w9WgXcQ')).toBeNull(); + expect(extractYouTubeVideoId('https://youtu.be/short')).toBeNull(); + }); +}); + +describe('buildYouTubeWatchUrl', () => { + it('builds a canonical watch URL', () => { + expect(buildYouTubeWatchUrl('dQw4w9WgXcQ')).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + }); +}); + +describe('parseIso8601DurationToSeconds', () => { + it('parses hour/minute/second durations', () => { + expect(parseIso8601DurationToSeconds('PT1H2M3S')).toBe(3723); + expect(parseIso8601DurationToSeconds('PT15M')).toBe(900); + expect(parseIso8601DurationToSeconds('PT0S')).toBe(0); + }); + + it('returns null for invalid durations', () => { + expect(parseIso8601DurationToSeconds('')).toBeNull(); + expect(parseIso8601DurationToSeconds('P1D')).toBeNull(); + }); +}); + +describe('isYouTubeImportableCompletedBroadcast', () => { + it('accepts completed live archives', () => { + expect( + isYouTubeImportableCompletedBroadcast({ + snippet: { liveBroadcastContent: 'none' }, + liveStreamingDetails: { + actualStartTime: '2026-01-01T00:00:00Z', + actualEndTime: '2026-01-01T01:00:00Z', + }, + }) + ).toBe(true); + }); + + it('rejects upcoming and in-progress live broadcasts', () => { + expect( + isYouTubeImportableCompletedBroadcast({ + snippet: { liveBroadcastContent: 'upcoming' }, + }) + ).toBe(false); + + expect( + isYouTubeImportableCompletedBroadcast({ + snippet: { liveBroadcastContent: 'live' }, + liveStreamingDetails: { actualStartTime: '2026-01-01T00:00:00Z' }, + }) + ).toBe(false); + }); +}); + +describe('mapYouTubeImportResolvedSource', () => { + const completedVideo = { + id: 'dQw4w9WgXcQ', + snippet: { + title: 'Sunday Service', + liveBroadcastContent: 'none', + thumbnails: { + high: { url: 'https://img.youtube.com/high.jpg' }, + }, + }, + contentDetails: { duration: 'PT1H' }, + liveStreamingDetails: { + actualStartTime: '2026-01-01T00:00:00Z', + actualEndTime: '2026-01-01T01:00:00Z', + }, + }; + + it('maps a completed broadcast into import metadata', () => { + const result = mapYouTubeImportResolvedSource(completedVideo); + + expect(result).toEqual({ + ok: true, + data: { + youtubeVideoId: 'dQw4w9WgXcQ', + title: 'Sunday Service', + durationSeconds: 3600, + thumbnailUrl: 'https://img.youtube.com/high.jpg', + }, + }); + }); + + it('rejects videos longer than the configured max duration', () => { + const previous = process.env.YT_IMPORT_MAX_DURATION_SECONDS; + process.env.YT_IMPORT_MAX_DURATION_SECONDS = '60'; + + const result = mapYouTubeImportResolvedSource(completedVideo); + + expect(result).toEqual({ + ok: false, + message: 'Video exceeds the maximum import length of 60 seconds.', + }); + + if (previous == null) { + delete process.env.YT_IMPORT_MAX_DURATION_SECONDS; + } else { + process.env.YT_IMPORT_MAX_DURATION_SECONDS = previous; + } + }); +}); diff --git a/__tests__/lib/youtube-import/run-import-job.test.ts b/__tests__/lib/youtube-import/run-import-job.test.ts new file mode 100644 index 00000000..57ca5772 --- /dev/null +++ b/__tests__/lib/youtube-import/run-import-job.test.ts @@ -0,0 +1,399 @@ +import { EventEmitter } from 'node:events'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSpawnProcess = vi.hoisted(() => vi.fn()); +const mockGetYoutubeImportJobById = vi.hoisted(() => vi.fn()); +const mockUpdateYoutubeImportJobStatus = vi.hoisted(() => vi.fn()); +const mockCreateUploadJob = vi.hoisted(() => vi.fn()); +const mockUpdateUploadJobStatus = vi.hoisted(() => vi.fn()); +const mockUploadLocalFileToR2 = vi.hoisted(() => vi.fn()); +const mockDistributeStagedYoutubeImportUpload = vi.hoisted(() => vi.fn()); +const mockMkdir = vi.hoisted(() => vi.fn()); +const mockMkdtemp = vi.hoisted(() => vi.fn()); +const mockRm = vi.hoisted(() => vi.fn()); +const mockReadFile = vi.hoisted(() => vi.fn()); +const mockStat = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/youtube-import/spawn-process', () => ({ + spawnProcess: (...args: unknown[]) => mockSpawnProcess(...args), +})); + +vi.mock('@/lib/repositories/youtube-import-jobs', () => ({ + getYoutubeImportJobById: (...args: unknown[]) => mockGetYoutubeImportJobById(...args), + updateYoutubeImportJobStatus: (...args: unknown[]) => mockUpdateYoutubeImportJobStatus(...args), +})); + +vi.mock('@/lib/repositories/upload-jobs', () => ({ + createUploadJob: (...args: unknown[]) => mockCreateUploadJob(...args), + updateUploadJobStatus: (...args: unknown[]) => mockUpdateUploadJobStatus(...args), +})); + +vi.mock('@/lib/r2', () => ({ + uploadLocalFileToR2: (...args: unknown[]) => mockUploadLocalFileToR2(...args), +})); + +vi.mock('@/lib/youtube-import/queue-import-distribute', () => ({ + distributeStagedYoutubeImportUpload: (...args: unknown[]) => + mockDistributeStagedYoutubeImportUpload(...args), +})); + +vi.mock('@/lib/youtube-import/import-job-fs', () => ({ + mkdir: (...args: unknown[]) => mockMkdir(...args), + mkdtemp: (...args: unknown[]) => mockMkdtemp(...args), + rm: (...args: unknown[]) => mockRm(...args), + readFile: (...args: unknown[]) => mockReadFile(...args), + stat: (...args: unknown[]) => mockStat(...args), +})); + +import { + buildYoutubeImportUploadKey, + computeDownloadPhaseProgressPercent, + computeTrimOffsets, + parseFfmpegTimeSeconds, + parseYtDlpDownloadPercent, + runYoutubeImportJob, +} from '@/lib/youtube-import/run-import-job'; + +type MockChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; +}; + +function createMockChild(options: { + stdout?: string; + stderr?: string; + code?: number; + delayMs?: number; + onClose?: () => void; +}): MockChild { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => { + child.emit('close', options.code ?? null); + }); + + setTimeout(() => { + if (options.stdout) { + child.stdout.emit('data', Buffer.from(options.stdout)); + } + if (options.stderr) { + child.stderr.emit('data', Buffer.from(options.stderr)); + } + child.emit('close', options.code ?? 0); + options.onClose?.(); + }, options.delayMs ?? 0); + + return child; +} + +const baseJob = { + id: 'yt-import-1', + userId: 'user-123', + draftId: 'draft-abc', + sourceUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + youtubeVideoId: 'dQw4w9WgXcQ', + livestreamId: null, + startSeconds: 100, + endSeconds: 160, + status: 'pending' as const, + progressPercent: 0, + errorMessage: null, + r2Key: null, + uploadJobId: null, + distributeQueued: false, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', +}; + +const WORK_DIR = '/tmp/yt-import/yt-import-job-abc'; + +beforeEach(() => { + vi.clearAllMocks(); + mockMkdir.mockResolvedValue(undefined); + mockMkdtemp.mockResolvedValue(WORK_DIR); + mockRm.mockResolvedValue(undefined); + mockReadFile.mockResolvedValue(JSON.stringify({ section_start: 95 })); + mockStat.mockResolvedValue({ size: 4096 }); + mockGetYoutubeImportJobById.mockResolvedValue(baseJob); + mockUpdateYoutubeImportJobStatus.mockResolvedValue(undefined); + mockUploadLocalFileToR2.mockResolvedValue(4096); + mockCreateUploadJob.mockResolvedValue({ + id: 'upload-job-1', + userId: 'user-123', + draftId: 'draft-abc', + r2Key: 'temp/uploads/user-123/x/youtube-import-dQw4w9WgXcQ.mp4', + status: 'pending', + errorMessage: null, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', + }); + mockUpdateUploadJobStatus.mockResolvedValue(undefined); + mockDistributeStagedYoutubeImportUpload.mockResolvedValue({ distributing: false }); + + mockSpawnProcess.mockImplementation((command: string) => { + if (command === 'yt-dlp') { + return createMockChild({ + stderr: '[download] 50.0% of ~10.00MiB at 1.00MiB/s ETA 00:05\n', + }); + } + if (command === 'ffprobe') { + return createMockChild({ stdout: '120.5\n' }); + } + if (command === 'ffmpeg') { + return createMockChild({}); + } + throw new Error(`Unexpected command: ${command}`); + }); +}); + +describe('parseYtDlpDownloadPercent', () => { + it('parses yt-dlp download progress lines', () => { + expect(parseYtDlpDownloadPercent('[download] 42.5% of file')).toBe(42.5); + expect(parseYtDlpDownloadPercent('noise')).toBeNull(); + }); +}); + +describe('parseFfmpegTimeSeconds', () => { + it('parses the latest non-negative ffmpeg time value', () => { + expect(parseFfmpegTimeSeconds('size=0kB time=-00:00:02.96 bitrate=N/A')).toBeNull(); + expect(parseFfmpegTimeSeconds('size=512kB time=00:00:15.52 bitrate=11891.1kbits/s')).toBe( + 15.52 + ); + expect( + parseFfmpegTimeSeconds( + 'size=512kB time=00:00:10.00 bitrate=N/A\rsize=1024kB time=00:00:20.00 bitrate=N/A' + ) + ).toBe(20); + }); +}); + +describe('computeDownloadPhaseProgressPercent', () => { + it('prefers yt-dlp percent over ffmpeg time', () => { + expect( + computeDownloadPhaseProgressPercent({ + downloadPercent: 50, + ffmpegTimeSeconds: 10, + sectionDurationSeconds: 60, + }) + ).toBe(35); + }); + + it('maps ffmpeg time into the download phase when yt-dlp percent is absent', () => { + expect( + computeDownloadPhaseProgressPercent({ + downloadPercent: null, + ffmpegTimeSeconds: 30, + sectionDurationSeconds: 60, + }) + ).toBe(35); + }); +}); + +describe('computeTrimOffsets', () => { + it('computes relative trim points inside a section download', () => { + expect( + computeTrimOffsets({ + jobStartSeconds: 100, + jobEndSeconds: 160, + sectionStartSeconds: 95, + downloadedDurationSeconds: 120, + }) + ).toEqual({ relativeStart: 5, relativeEnd: 65 }); + }); +}); + +describe('buildYoutubeImportUploadKey', () => { + it('scopes keys under temp/uploads/{userId}', () => { + const key = buildYoutubeImportUploadKey('user-123', 'dQw4w9WgXcQ'); + expect(key).toMatch(/^temp\/uploads\/user-123\/\d+-[^/]+\/youtube-import-dQw4w9WgXcQ\.mp4$/); + }); +}); + +describe('runYoutubeImportJob', () => { + it('runs download, trim, upload, and handoff with status transitions', async () => { + await runYoutubeImportJob('yt-import-1'); + + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + status: 'downloading', + progressPercent: 0, + }); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + progressPercent: 35, + }); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + status: 'trimming', + progressPercent: 70, + }); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + status: 'uploading', + progressPercent: 85, + }); + expect(mockUploadLocalFileToR2).toHaveBeenCalledWith( + `${WORK_DIR}/trimmed.mp4`, + expect.stringMatching(/^temp\/uploads\/user-123\//), + 'video/mp4' + ); + expect(mockCreateUploadJob).toHaveBeenCalledWith({ + userId: 'user-123', + draftId: 'draft-abc', + r2Key: expect.stringMatching(/^temp\/uploads\/user-123\//), + }); + expect(mockUpdateUploadJobStatus).toHaveBeenCalledWith('upload-job-1', 'uploading'); + expect(mockDistributeStagedYoutubeImportUpload).not.toHaveBeenCalled(); + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + status: 'completed', + progressPercent: 100, + errorMessage: null, + }); + expect(mockRm).toHaveBeenCalledWith(WORK_DIR, { recursive: true, force: true }); + }); + + it('distributes immediately when distributeQueued was set before staging finished', async () => { + mockGetYoutubeImportJobById.mockResolvedValue({ ...baseJob, distributeQueued: true }); + + await runYoutubeImportJob('yt-import-1'); + + expect(mockDistributeStagedYoutubeImportUpload).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'yt-import-1', + distributeQueued: true, + uploadJobId: 'upload-job-1', + }), + 'user-123' + ); + expect(mockUpdateUploadJobStatus).not.toHaveBeenCalled(); + }); + + it('updates progress from ffmpeg time output during section downloads', async () => { + mockSpawnProcess.mockImplementation((command: string) => { + if (command === 'yt-dlp') { + return createMockChild({ + stderr: 'size=512kB time=00:00:30.00 bitrate=N/A\n', + }); + } + if (command === 'ffprobe') { + return createMockChild({ stdout: '120.5\n' }); + } + if (command === 'ffmpeg') { + return createMockChild({}); + } + throw new Error(`Unexpected command: ${command}`); + }); + + await runYoutubeImportJob('yt-import-1'); + + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + progressPercent: 35, + }); + }); + + it('marks the job failed and still cleans up temp files on errors', async () => { + mockSpawnProcess.mockImplementationOnce((command: string) => { + if (command === 'yt-dlp') { + return createMockChild({ stderr: 'boom', code: 1 }); + } + throw new Error(`Unexpected command: ${command}`); + }); + + await runYoutubeImportJob('yt-import-1'); + + expect(mockUpdateYoutubeImportJobStatus).toHaveBeenCalledWith('yt-import-1', { + status: 'failed', + errorMessage: expect.stringContaining('yt-dlp section download failed'), + }); + expect(mockUploadLocalFileToR2).not.toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith(WORK_DIR, { recursive: true, force: true }); + }); + + it('honors cancellation between phases before trim starts', async () => { + let ytDlpDone = false; + let ffprobeDone = false; + + mockSpawnProcess.mockImplementation((command: string) => { + if (command === 'yt-dlp') { + return createMockChild({ + stderr: '[download] 50.0% of ~10.00MiB at 1.00MiB/s ETA 00:05\n', + onClose: () => { + ytDlpDone = true; + }, + }); + } + if (command === 'ffprobe') { + return createMockChild({ + stdout: '120.5\n', + onClose: () => { + ffprobeDone = true; + }, + }); + } + if (command === 'ffmpeg') { + return createMockChild({}); + } + throw new Error(`Unexpected command: ${command}`); + }); + + mockGetYoutubeImportJobById.mockImplementation(async () => { + if (ytDlpDone && ffprobeDone) { + return { ...baseJob, status: 'cancelled' }; + } + return { ...baseJob, status: 'downloading' }; + }); + + await runYoutubeImportJob('yt-import-1'); + + expect(mockSpawnProcess).toHaveBeenCalledTimes(2); + expect(mockSpawnProcess.mock.calls[0]?.[0]).toBe('yt-dlp'); + expect(mockSpawnProcess.mock.calls[1]?.[0]).toBe('ffprobe'); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalledWith('yt-import-1', { + status: 'trimming', + progressPercent: 70, + }); + expect(mockUploadLocalFileToR2).not.toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith(WORK_DIR, { recursive: true, force: true }); + }); + + it('kills yt-dlp when cancelled during download and does not mark the job failed', async () => { + vi.useFakeTimers(); + + let ytDlpChild: MockChild | null = null; + let getCount = 0; + mockGetYoutubeImportJobById.mockImplementation(async () => { + getCount += 1; + // Startup reads plus immediate cancel poll see baseJob; cancellation on second scheduled poll. + if (getCount < 5) { + return baseJob; + } + return { ...baseJob, status: 'cancelled' }; + }); + + mockSpawnProcess.mockImplementation((command: string) => { + if (command === 'yt-dlp') { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(() => { + child.emit('close', null); + }); + ytDlpChild = child; + return child; + } + throw new Error(`Unexpected command: ${command}`); + }); + + const runPromise = runYoutubeImportJob('yt-import-1'); + await vi.advanceTimersByTimeAsync(2_600); + await runPromise; + + expect(ytDlpChild?.kill).toHaveBeenCalledWith('SIGTERM'); + expect(mockUpdateYoutubeImportJobStatus).not.toHaveBeenCalledWith('yt-import-1', { + status: 'failed', + errorMessage: expect.any(String), + }); + expect(mockUploadLocalFileToR2).not.toHaveBeenCalled(); + expect(mockRm).toHaveBeenCalledWith(WORK_DIR, { recursive: true, force: true }); + + vi.useRealTimers(); + }); +}); diff --git a/__tests__/lib/youtube-import/schedule-import-job.test.ts b/__tests__/lib/youtube-import/schedule-import-job.test.ts new file mode 100644 index 00000000..97e4323b --- /dev/null +++ b/__tests__/lib/youtube-import/schedule-import-job.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockExecuteYoutubeImportJobWorker, mockAfter } = vi.hoisted(() => { + const execute = vi.fn(); + const after = vi.fn((callback: () => void | Promise) => { + void callback(); + }); + return { mockExecuteYoutubeImportJobWorker: execute, mockAfter: after }; +}); + +vi.mock('next/server', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, after: mockAfter }; +}); + +vi.mock('@/lib/youtube-import/execute-import-job', () => ({ + executeYoutubeImportJobWorker: (...args: unknown[]) => mockExecuteYoutubeImportJobWorker(...args), +})); + +import { scheduleYoutubeImportJob } from '@/lib/youtube-import/schedule-import-job'; + +beforeEach(() => { + vi.clearAllMocks(); + mockExecuteYoutubeImportJobWorker.mockResolvedValue({ outcome: 'ran' }); +}); + +describe('scheduleYoutubeImportJob', () => { + it('runs the worker via after()', async () => { + scheduleYoutubeImportJob('import-job-1', 'user-123'); + + expect(mockAfter).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(mockExecuteYoutubeImportJobWorker).toHaveBeenCalledWith('import-job-1', 'user-123'); + }); +}); diff --git a/__tests__/lib/youtube-import/yt-dlp-args.test.ts b/__tests__/lib/youtube-import/yt-dlp-args.test.ts new file mode 100644 index 00000000..53842d63 --- /dev/null +++ b/__tests__/lib/youtube-import/yt-dlp-args.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { + YT_DLP_DEFAULT_REMOTE_COMPONENTS, + buildYtDlpBaseArgs, + buildYtDlpMetadataArgs, +} from '@/lib/youtube-import/yt-dlp-args'; + +describe('buildYtDlpBaseArgs', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('enables the Node runtime and GitHub-hosted EJS scripts by default', () => { + const args = buildYtDlpBaseArgs(); + + expect(args).toContain('--no-update'); + expect(args).toContain('--js-runtimes'); + expect(args).toContainEqual(expect.stringMatching(/^node:/)); + expect(args).toContain('--remote-components'); + expect(args).toContain(YT_DLP_DEFAULT_REMOTE_COMPONENTS); + }); + + it('allows disabling remote component downloads', () => { + vi.stubEnv('YT_DLP_REMOTE_COMPONENTS', 'none'); + + const args = buildYtDlpBaseArgs(); + + expect(args).not.toContain('--remote-components'); + }); + + it('supports a custom remote component source', () => { + vi.stubEnv('YT_DLP_REMOTE_COMPONENTS', 'ejs:npm'); + + const args = buildYtDlpBaseArgs(); + + expect(args).toContain('ejs:npm'); + }); +}); + +describe('buildYtDlpMetadataArgs', () => { + it('appends JSON metadata flags after the shared base args', () => { + const watchUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + const args = buildYtDlpMetadataArgs(watchUrl); + + expect(args.at(-1)).toBe(watchUrl); + expect(args).toContain('-J'); + expect(args).toContain('--no-playlist'); + expect(args).toContain('--js-runtimes'); + }); +}); diff --git a/__tests__/lib/youtube-import/yt-dlp-errors.test.ts b/__tests__/lib/youtube-import/yt-dlp-errors.test.ts new file mode 100644 index 00000000..af8d91c1 --- /dev/null +++ b/__tests__/lib/youtube-import/yt-dlp-errors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + buildYtDlpProcessError, + getUserFriendlyYtDlpErrorMessage, +} from '@/lib/youtube-import/yt-dlp-errors'; + +describe('getUserFriendlyYtDlpErrorMessage', () => { + it('returns a friendly message for private videos', () => { + const stderr = + "ERROR: [youtube] h3DhnqpppU8: Private video. Sign in if you've been granted access to this video."; + + expect(getUserFriendlyYtDlpErrorMessage(stderr)).toBe( + 'This video is private. Make it public or unlisted on YouTube before importing.' + ); + }); + + it('returns null for unrecognized stderr', () => { + expect(getUserFriendlyYtDlpErrorMessage('Video unavailable')).toBeNull(); + }); +}); + +describe('buildYtDlpProcessError', () => { + it('prefers a friendly message over raw yt-dlp stderr', () => { + const error = buildYtDlpProcessError('yt-dlp metadata lookup', 1, [ + Buffer.from('ERROR: [youtube] abc: Private video.'), + ]); + + expect(error.message).toBe( + 'This video is private. Make it public or unlisted on YouTube before importing.' + ); + }); + + it('falls back to the technical message for other failures', () => { + const error = buildYtDlpProcessError('yt-dlp metadata lookup', 1, [ + Buffer.from('Video unavailable'), + ]); + + expect(error.message).toBe('yt-dlp metadata lookup failed (exit 1): Video unavailable'); + }); +}); diff --git a/__tests__/middleware/proxy.test.ts b/__tests__/middleware/proxy.test.ts index d452bb52..14fab2a6 100644 --- a/__tests__/middleware/proxy.test.ts +++ b/__tests__/middleware/proxy.test.ts @@ -73,7 +73,7 @@ describe('Proxy Middleware', () => { }); it('should redirect to login when no session cookie is present', async () => { - const request = createMockRequest('/dashboard/videos'); + const request = createMockRequest('/dashboard/uploads'); const result = await proxy(request); @@ -81,12 +81,12 @@ describe('Proxy Middleware', () => { expect(result.status).toBe(307); const location = result.headers.get('location') || ''; expect(location).toContain('/login'); - expect(location).toContain('redirect=%2Fdashboard%2Fvideos'); + expect(location).toContain('redirect=%2Fdashboard%2Fuploads'); }); it('should allow authenticated users through', async () => { const sessionToken = 'valid_session_token_xyz'; - const request = createMockRequest('/dashboard/videos', { + const request = createMockRequest('/dashboard/uploads', { videosphere_session: sessionToken, }); @@ -312,7 +312,7 @@ describe('Proxy Middleware', () => { it('should allow admin users to access /dashboard routes', async () => { const sessionToken = 'admin_session_token'; - const request = createMockRequest('/dashboard/videos', { + const request = createMockRequest('/dashboard/uploads', { videosphere_session: sessionToken, }); @@ -418,7 +418,7 @@ describe('Proxy Middleware', () => { describe('Route Matching', () => { it('should protect /dashboard routes', async () => { - const request = createMockRequest('/dashboard/videos'); + const request = createMockRequest('/dashboard/uploads'); const result = await proxy(request); expect(result.status).toBe(307); diff --git a/__tests__/pages/connections.test.tsx b/__tests__/pages/connections.test.tsx index c2304951..720ae350 100644 --- a/__tests__/pages/connections.test.tsx +++ b/__tests__/pages/connections.test.tsx @@ -16,9 +16,9 @@ import userEvent from '@testing-library/user-event'; // the top of the file at compile-time, so plain `const` refs are TDZ). // --------------------------------------------------------------------------- -const { mockGetCurrentUserIdFromCookies, mockGetConnectedAccountsByUser } = vi.hoisted(() => ({ +const { mockGetCurrentUserIdFromCookies, mockGetConnectedAccountsWithHealth } = vi.hoisted(() => ({ mockGetCurrentUserIdFromCookies: vi.fn(), - mockGetConnectedAccountsByUser: vi.fn(), + mockGetConnectedAccountsWithHealth: vi.fn(), })); // --------------------------------------------------------------------------- @@ -42,10 +42,9 @@ vi.mock('@/lib/auth/get-current-user-id-from-cookies', () => ({ getCurrentUserIdFromCookies: (...args: unknown[]) => mockGetCurrentUserIdFromCookies(...args), })); -vi.mock('@/lib/repositories/connected-accounts', () => ({ - getConnectedAccountsByUser: (...args: unknown[]) => mockGetConnectedAccountsByUser(...args), - getConnectedAccountWithTokens: vi.fn(), - deleteConnectedAccount: vi.fn(), +vi.mock('@/lib/platforms/connected-accounts-health', () => ({ + getConnectedAccountsWithHealth: (...args: unknown[]) => + mockGetConnectedAccountsWithHealth(...args), })); import ConnectionsPage from '@/app/(dashboard)/profile/connections/page'; @@ -82,7 +81,7 @@ function getPlatformsInSection(sectionTitle: string): ConnectedAccountPlatform[] describe('ConnectionsPage', () => { beforeEach(() => { vi.resetAllMocks(); - mockGetConnectedAccountsByUser.mockResolvedValue([]); + mockGetConnectedAccountsWithHealth.mockResolvedValue([]); mockGetCurrentUserIdFromCookies.mockResolvedValue('user-123'); }); @@ -138,7 +137,7 @@ describe('ConnectionsPage', () => { }); it('shows connected video platforms first in alphabetical order within the section', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'account-youtube', userId: 'user-123', @@ -179,7 +178,7 @@ describe('ConnectionsPage', () => { }); it('shows connected backup platforms first in alphabetical order within the section', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -254,7 +253,7 @@ describe('ConnectionsPage', () => { }); it('shows Disconnect button and channel name when YouTube is connected', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'account-1', userId: 'user-123', @@ -275,8 +274,8 @@ describe('ConnectionsPage', () => { expect(screen.getByText('My Test Channel')).toBeInTheDocument(); }); - it('shows Connected (not Expired) when access token is past but a refresh token exists', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + it('shows Connected (not Reconnect required) when OAuth health check succeeds', async () => { + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'account-1', userId: 'user-123', @@ -287,6 +286,7 @@ describe('ConnectionsPage', () => { hasYoutubeTempStreamKey: false, platformUserId: 'UCtest123', platformName: 'My Test Channel', + connectionStatus: 'connected', $createdAt: new Date().toISOString(), $updatedAt: new Date().toISOString(), }, @@ -294,11 +294,34 @@ describe('ConnectionsPage', () => { const page = await ConnectionsPage({ searchParams: makeSearchParams() }); render(page); expect(screen.getByText('Connected')).toBeInTheDocument(); - expect(screen.queryByText(/expired/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/reconnect required/i)).not.toBeInTheDocument(); + }); + + it('shows Reconnect required when OAuth health check fails', async () => { + mockGetConnectedAccountsWithHealth.mockResolvedValue([ + { + id: 'account-1', + userId: 'user-123', + platform: 'youtube', + tokenExpiry: new Date(Date.now() - 1000).toISOString(), + hasRefreshToken: false, + hasYoutubeMainStreamKey: false, + hasYoutubeTempStreamKey: false, + platformUserId: 'UCtest123', + platformName: 'My Test Channel', + connectionStatus: 'expired', + $createdAt: new Date().toISOString(), + $updatedAt: new Date().toISOString(), + }, + ]); + const page = await ConnectionsPage({ searchParams: makeSearchParams() }); + render(page); + expect(screen.getByText(/reconnect required/i)).toBeInTheDocument(); + expect(screen.queryByText(/^connected$/i)).not.toBeInTheDocument(); }); it('shows Edit and Disconnect when SFTP is connected', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -326,7 +349,7 @@ describe('ConnectionsPage', () => { }); it('shows Expired and Reconnect when SFTP row is missing required fields', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -343,13 +366,13 @@ describe('ConnectionsPage', () => { ]); const page = await ConnectionsPage({ searchParams: makeSearchParams() }); render(page); - expect(screen.getByText(/expired/i)).toBeInTheDocument(); + expect(screen.getByText(/reconnect required/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^reconnect$/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument(); }); it('shows Expired and Reconnect when SFTP host key is not pinned', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -370,13 +393,13 @@ describe('ConnectionsPage', () => { ]); const page = await ConnectionsPage({ searchParams: makeSearchParams() }); render(page); - expect(screen.getByText(/expired/i)).toBeInTheDocument(); + expect(screen.getByText(/reconnect required/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^reconnect$/i })).toBeInTheDocument(); expect(screen.queryByText(/^connected$/i)).not.toBeInTheDocument(); }); it('shows Expired and Reconnect when SFTP host key fingerprint format is invalid', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -398,14 +421,14 @@ describe('ConnectionsPage', () => { ]); const page = await ConnectionsPage({ searchParams: makeSearchParams() }); render(page); - expect(screen.getByText(/expired/i)).toBeInTheDocument(); + expect(screen.getByText(/reconnect required/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^reconnect$/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument(); }); it('prefills the reconnect modal when SFTP settings exist but host key is not pinned', async () => { const user = userEvent.setup(); - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'sftp-1', userId: 'user-123', @@ -479,7 +502,7 @@ describe('ConnectionsPage', () => { }); it('opens the Google Drive backup folder dialog after OAuth connect', async () => { - mockGetConnectedAccountsByUser.mockResolvedValue([ + mockGetConnectedAccountsWithHealth.mockResolvedValue([ { id: 'drive-1', userId: 'user-123', diff --git a/__tests__/pages/dashboard.a11y.test.tsx b/__tests__/pages/dashboard.a11y.test.tsx index a7792453..dfe4af17 100644 --- a/__tests__/pages/dashboard.a11y.test.tsx +++ b/__tests__/pages/dashboard.a11y.test.tsx @@ -90,7 +90,7 @@ describe('Dashboard accessibility', () => { expect(screen.getByRole('status')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /open draft/i })).toHaveAttribute( 'href', - '/dashboard/drafts/draft-ready' + '/dashboard/uploads/draft-ready' ); await expectNoAxeViolations(baseElement); diff --git a/__tests__/pages/dashboard.test.tsx b/__tests__/pages/dashboard.test.tsx index c7dabf15..c534d60e 100644 --- a/__tests__/pages/dashboard.test.tsx +++ b/__tests__/pages/dashboard.test.tsx @@ -144,11 +144,11 @@ describe('DashboardPage Component', () => { expect(screen.getByRole('button', { name: /new draft/i })).toBeInTheDocument(); }); - it('should link "View drafts" to /dashboard/drafts', async () => { + it('should link "View drafts" to /dashboard/uploads', async () => { render(await DashboardPage()); const viewDraftsLink = screen.getByRole('link', { name: /view drafts/i }); - expect(viewDraftsLink).toHaveAttribute('href', '/dashboard/drafts'); + expect(viewDraftsLink).toHaveAttribute('href', '/dashboard/uploads'); }); }); @@ -203,7 +203,7 @@ describe('DashboardPage Component', () => { expect(screen.getByText('YouTube, Vimeo')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /open draft/i })).toHaveAttribute( 'href', - '/dashboard/drafts/draft-ready' + '/dashboard/uploads/draft-ready' ); expect(screen.queryByText('Already uploaded draft')).not.toBeInTheDocument(); }); diff --git a/__tests__/pages/drafts.test.tsx b/__tests__/pages/drafts.test.tsx index 8628ad60..cbc0d812 100644 --- a/__tests__/pages/drafts.test.tsx +++ b/__tests__/pages/drafts.test.tsx @@ -2,8 +2,8 @@ // DRAFTS PAGE COMPONENT TESTS // ============================================================================= // Basic UI rendering tests for the Drafts page: verify header, empty state, -// and primary CTA link render correctly. Initial load uses three GETs (drafts, -// connections, ai-access); edit-from-query also GETs /api/drafts/:id after openEditDraft. +// and primary CTA link render correctly. Initial load uses four GETs (drafts, +// connections, ai-access, labels); edit-from-query also GETs /api/drafts/:id after openEditDraft. // ============================================================================= import { describe, it, expect, vi, afterEach } from 'vitest'; @@ -25,7 +25,7 @@ const mockRouterReplace = vi.fn(); vi.mock('next/navigation', () => ({ useSearchParams: () => mockSearchParams, useRouter: () => ({ push: vi.fn(), replace: mockRouterReplace }), - usePathname: () => '/dashboard/drafts', + usePathname: () => '/dashboard/uploads', })); vi.mock('@/components/drafts/DraftMetadataModal', () => ({ @@ -50,7 +50,7 @@ vi.mock('@/components/onboarding/OnboardingContext', () => ({ }), })); -import DraftsPage from '@/app/(dashboard)/dashboard/drafts/page'; +import UploadsPage from '@/app/(dashboard)/dashboard/uploads/page'; afterEach(() => { vi.restoreAllMocks(); @@ -83,6 +83,46 @@ function jsonResponse(body: unknown): Response { } as Response; } +function labelsJsonResponse(labels: unknown[] = []): Response { + return jsonResponse({ data: labels }); +} + +function youtubeConnectionPayload() { + return { + id: 'conn-yt', + userId: 'user-1', + platform: 'youtube', + tokenExpiry: new Date(Date.now() + 3_600_000).toISOString(), + hasRefreshToken: true, + hasYoutubeMainStreamKey: false, + hasYoutubeTempStreamKey: false, + platformUserId: 'yt-1', + platformName: 'Channel', + connectionStatus: 'connected', + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-02T00:00:00.000Z', + }; +} + +/** Mocks the four parallel GETs issued by loadDrafts (call order matters). */ +function mockInitialPageLoadFetch(options?: { + drafts?: unknown[]; + connections?: unknown; + canUseAiMetadata?: boolean; + labels?: unknown[]; +}) { + return vi + .spyOn(global, 'fetch') + .mockResolvedValueOnce(jsonResponse({ data: options?.drafts ?? [] })) + .mockResolvedValueOnce( + jsonResponse({ + data: options?.connections ?? [youtubeConnectionPayload()], + }) + ) + .mockResolvedValueOnce(jsonResponse({ canUseAiMetadata: options?.canUseAiMetadata ?? true })) + .mockResolvedValueOnce(labelsJsonResponse(options?.labels ?? [])); +} + /** Route-aware fetch mock for editDraft query tests (survives remount / repeated loadDrafts). */ function mockEditDraftQueryFetch() { const draftListItem = { @@ -101,11 +141,14 @@ function mockEditDraftQueryFetch() { if (url.includes('/api/drafts/draft-1')) { return Promise.resolve(jsonResponse(draft1DetailPayload())); } + if (url.includes('/api/drafts/labels')) { + return Promise.resolve(labelsJsonResponse()); + } if (url.includes('/api/drafts')) { return Promise.resolve(jsonResponse({ data: [draftListItem] })); } if (url.includes('/api/platforms/connections')) { - return Promise.resolve(jsonResponse({ data: ['youtube'] })); + return Promise.resolve(jsonResponse({ data: [youtubeConnectionPayload()] })); } if (url.includes('/api/auth/ai-access')) { return Promise.resolve(jsonResponse({ canUseAiMetadata: true })); @@ -115,25 +158,13 @@ function mockEditDraftQueryFetch() { }); } -describe('DraftsPage', () => { - it('renders the Drafts page heading', async () => { - vi.spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response); - - render(); - - expect(screen.getByRole('heading', { level: 1, name: /drafts/i })).toBeInTheDocument(); +describe('UploadsPage', () => { + it('renders the Uploads page heading', async () => { + mockInitialPageLoadFetch(); + + render(); + + expect(screen.getByRole('heading', { level: 1, name: /uploads/i })).toBeInTheDocument(); await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/drafts', expect.any(Object)) ); @@ -143,85 +174,50 @@ describe('DraftsPage', () => { await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/auth/ai-access', expect.any(Object)) ); + await waitFor(() => + expect(global.fetch).toHaveBeenCalledWith('/api/drafts/labels', expect.any(Object)) + ); }); it('renders the empty state message when there are no drafts', async () => { - vi.spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response); - - render(); + mockInitialPageLoadFetch(); + + render(); expect(await screen.findByText(/no drafts yet/i)).toBeInTheDocument(); expect(await screen.findByText(/create a draft to get started/i)).toBeInTheDocument(); }); it('renders a Create draft button', async () => { - vi.spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response); - - render(); + mockInitialPageLoadFetch(); + + render(); expect(screen.getByRole('button', { name: /create draft/i })).toBeInTheDocument(); - await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(4)); }); it('opens create modal from openCreateDraft query param', async () => { mockSearchParams = new URLSearchParams('openCreateDraft=true'); - vi.spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - id: 'draft-from-minimal', - userId: 'user-1', - title: '', - description: '', - tags: [], - visibility: 'private', - targets: [], - platforms: {}, - $createdAt: '2000-01-01T00:00:00.000Z', - $updatedAt: '2000-01-01T00:00:00.000Z', - }, - }), - } as Response); - - render(); + mockInitialPageLoadFetch().mockResolvedValueOnce( + jsonResponse({ + data: { + id: 'draft-from-minimal', + userId: 'user-1', + title: '', + description: '', + tags: [], + visibility: 'private', + targets: [], + platforms: {}, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', + }, + }) + ); + + render(); expect(await screen.findByTestId('create-modal-open')).toBeInTheDocument(); }); @@ -229,81 +225,52 @@ describe('DraftsPage', () => { it('opens create modal from createDraftId query param', async () => { mockSearchParams = new URLSearchParams('createDraftId=draft-from-dashboard'); - vi.spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - id: 'draft-from-dashboard', - userId: 'user-1', - title: '', - description: '', - tags: [], - visibility: 'private', - targets: [], - platforms: {}, - $createdAt: '2000-01-01T00:00:00.000Z', - $updatedAt: '2000-01-01T00:00:00.000Z', - }, - }), - } as Response); - - render(); + mockInitialPageLoadFetch().mockResolvedValueOnce( + jsonResponse({ + data: { + id: 'draft-from-dashboard', + userId: 'user-1', + title: '', + description: '', + tags: [], + visibility: 'private', + targets: [], + platforms: {}, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', + }, + }) + ); + + render(); expect(await screen.findByTestId('create-modal-open')).toBeInTheDocument(); - expect(mockRouterReplace).toHaveBeenCalledWith('/dashboard/drafts'); + expect(mockRouterReplace).toHaveBeenCalledWith('/dashboard/uploads'); }); it('does not delete a non-minimal existing draft opened via createDraftId and preserves targets', async () => { mockSearchParams = new URLSearchParams('createDraftId=existing-draft'); - const fetchSpy = vi - .spyOn(global, 'fetch') - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ data: [] }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: [{ platform: 'vimeo' }], - }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ canUseAiMetadata: true }), - } as Response) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - data: { - id: 'existing-draft', - userId: 'user-1', - title: 'Already saved', - description: '', - tags: [], - visibility: 'private', - targets: ['youtube'], - platforms: {}, - $createdAt: '2000-01-01T00:00:00.000Z', - $updatedAt: '2000-01-01T00:00:00.000Z', - }, - }), - } as Response); - - render(); + const fetchSpy = mockInitialPageLoadFetch({ + connections: [{ platform: 'vimeo' }], + }).mockResolvedValueOnce( + jsonResponse({ + data: { + id: 'existing-draft', + userId: 'user-1', + title: 'Already saved', + description: '', + tags: [], + visibility: 'private', + targets: ['youtube'], + platforms: {}, + $createdAt: '2000-01-01T00:00:00.000Z', + $updatedAt: '2000-01-01T00:00:00.000Z', + }, + }) + ); + + render(); expect(await screen.findByTestId('create-modal-open')).toBeInTheDocument(); expect(screen.getByTestId('create-modal-targets')).toHaveTextContent('youtube'); @@ -321,7 +288,7 @@ describe('DraftsPage', () => { mockEditDraftQueryFetch(); - render(); + render(); expect(await screen.findByTestId('edit-modal-open')).toBeInTheDocument(); await waitFor(() => @@ -334,12 +301,12 @@ describe('DraftsPage', () => { mockEditDraftQueryFetch(); - render(); + render(); expect(await screen.findByTestId('edit-modal-open')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'close-edit' })); - expect(mockRouterReplace).toHaveBeenCalledWith('/dashboard/drafts'); + expect(mockRouterReplace).toHaveBeenCalledWith('/dashboard/uploads'); }); }); diff --git a/__tests__/pages/edit-draft.test.tsx b/__tests__/pages/edit-draft.test.tsx index 19421120..451d4c75 100644 --- a/__tests__/pages/edit-draft.test.tsx +++ b/__tests__/pages/edit-draft.test.tsx @@ -16,6 +16,6 @@ describe('EditDraftPage', () => { params: Promise.resolve({ id: 'draft-123' }), }); - expect(redirectMock).toHaveBeenCalledWith('/dashboard/drafts?editDraft=draft-123'); + expect(redirectMock).toHaveBeenCalledWith('/dashboard/uploads?editDraft=draft-123'); }); }); diff --git a/__tests__/pages/login.test.tsx b/__tests__/pages/login.test.tsx index 524a96fc..082cba9a 100644 --- a/__tests__/pages/login.test.tsx +++ b/__tests__/pages/login.test.tsx @@ -358,11 +358,13 @@ describe('LoginPage Component', () => { beforeEach(() => { // jsdom's window.location is not writable directly, so we replace the // whole object with a plain object that has a writable `href` property. + // Include `replace` so delayed credential-login redirects from prior tests + // do not throw when this mock is active. originalLocation = window.location; Object.defineProperty(window, 'location', { configurable: true, writable: true, - value: { href: '' }, + value: { href: '', replace: mockLocationReplace }, }); }); diff --git a/__tests__/repositories/drafts.test.ts b/__tests__/repositories/drafts.test.ts index d1adbc86..f4dc74fc 100644 --- a/__tests__/repositories/drafts.test.ts +++ b/__tests__/repositories/drafts.test.ts @@ -8,6 +8,7 @@ const { mockCountDocuments, mockFindByIdAndUpdate, mockDeleteOne, + mockBulkWrite, } = vi.hoisted(() => ({ mockConnectToDatabase: vi.fn(), mockCreate: vi.fn(), @@ -16,6 +17,7 @@ const { mockCountDocuments: vi.fn(), mockFindByIdAndUpdate: vi.fn(), mockDeleteOne: vi.fn(), + mockBulkWrite: vi.fn(), })); vi.mock('@/lib/mongodb', () => ({ @@ -30,6 +32,7 @@ vi.mock('@/lib/models/Draft', () => ({ countDocuments: (...args: unknown[]) => mockCountDocuments(...args), findByIdAndUpdate: (...args: unknown[]) => mockFindByIdAndUpdate(...args), deleteOne: (...args: unknown[]) => mockDeleteOne(...args), + bulkWrite: (...args: unknown[]) => mockBulkWrite(...args), }, })); @@ -40,6 +43,7 @@ import { getDraftById, listDraftsByUser, markDraftUsedInUpload, + removeLabelsFromAllDraftsForUser, updateDraft, } from '@/lib/repositories/drafts'; @@ -52,6 +56,34 @@ function chain(value: T) { }; } +function cursorChain(rows: Array<{ _id: string; document: string }>) { + async function* generate() { + for (const row of rows) { + yield row; + } + } + + return { + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockReturnValue({ + cursor: vi.fn().mockReturnValue(generate()), + }), + }), + }; +} + +function draftDocumentWithLabels(labels: string[]): string { + return JSON.stringify({ + targets: ['youtube'], + title: 'My Video', + description: 'A great video', + visibility: 'private', + tags: [], + labels, + platforms: {}, + }); +} + const baseDoc = { _id: 'draft-1', userId: 'user-1', @@ -209,4 +241,50 @@ describe('drafts repository (mongo)', () => { vi.useRealTimers(); }); + + it('removes matching labels from drafts via cursor and bulkWrite', async () => { + mockFind.mockReturnValueOnce( + cursorChain([ + { _id: 'draft-1', document: draftDocumentWithLabels(['Easter', 'Sunday']) }, + { _id: 'draft-2', document: draftDocumentWithLabels(['Christmas']) }, + ]) + ); + mockBulkWrite.mockResolvedValueOnce({}); + + await removeLabelsFromAllDraftsForUser('user-1', ['Easter']); + + expect(mockFind).toHaveBeenCalledWith({ userId: 'user-1' }); + expect(mockBulkWrite).toHaveBeenCalledTimes(1); + const ops = mockBulkWrite.mock.calls[0][0] as Array<{ + updateOne: { filter: { _id: string }; update: { $set: { document: string } } }; + }>; + expect(ops).toHaveLength(1); + expect(ops[0].updateOne.filter._id).toBe('draft-1'); + expect(JSON.parse(ops[0].updateOne.update.$set.document).labels).toEqual(['Sunday']); + }); + + it('skips bulkWrite when no drafts contain the removed labels', async () => { + mockFind.mockReturnValueOnce( + cursorChain([{ _id: 'draft-1', document: draftDocumentWithLabels(['Sunday']) }]) + ); + + await removeLabelsFromAllDraftsForUser('user-1', ['Easter']); + + expect(mockBulkWrite).not.toHaveBeenCalled(); + }); + + it('flushes bulkWrite in chunks when many drafts need updates', async () => { + const rows = Array.from({ length: 501 }, (_, index) => ({ + _id: `draft-${index}`, + document: draftDocumentWithLabels(['Easter']), + })); + mockFind.mockReturnValueOnce(cursorChain(rows)); + mockBulkWrite.mockResolvedValue({}); + + await removeLabelsFromAllDraftsForUser('user-1', ['Easter']); + + expect(mockBulkWrite).toHaveBeenCalledTimes(2); + expect(mockBulkWrite.mock.calls[0][0]).toHaveLength(500); + expect(mockBulkWrite.mock.calls[1][0]).toHaveLength(1); + }); }); diff --git a/__tests__/repositories/livestreams-list-pages.test.ts b/__tests__/repositories/livestreams-list-pages.test.ts new file mode 100644 index 00000000..d4726196 --- /dev/null +++ b/__tests__/repositories/livestreams-list-pages.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockConnectToDatabase = vi.fn(); +const mockCountDocuments = vi.fn(); +const mockFind = vi.fn(); +const mockUpdateOne = vi.fn(); + +vi.mock('@/lib/mongodb', () => ({ + connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), +})); + +vi.mock('@/lib/models/Livestream', () => ({ + LivestreamModel: { + countDocuments: (...args: unknown[]) => mockCountDocuments(...args), + find: (...args: unknown[]) => mockFind(...args), + updateOne: (...args: unknown[]) => mockUpdateOne(...args), + }, +})); + +import { + getStreamedLivestreamsPage, + getYoutubeImportLivestreamsPage, +} from '@/lib/repositories/livestreams'; + +const USER_ID = 'user-123'; + +const streamedDoc = { + _id: 'streamed-1', + userId: USER_ID, + document: JSON.stringify({ + status: 'ended', + title: 'Sunday Service', + description: '', + tags: [], + visibility: 'public', + targets: ['youtube'], + platforms: {}, + youtubeBroadcastId: 'broadcast-1', + }), + status: 'ended', + hasYoutubeTarget: true, + youtubeBroadcastId: 'broadcast-1', + youtubeLifecycleStatus: '', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), +}; + +function mockFindChain(docs: unknown[]) { + const chain = { + sort: vi.fn(), + skip: vi.fn(), + limit: vi.fn(), + lean: vi.fn(), + }; + chain.sort.mockReturnValue(chain); + chain.skip.mockReturnValue(chain); + chain.limit.mockReturnValue(chain); + chain.lean.mockResolvedValue(docs); + mockFind.mockReturnValue(chain); + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockConnectToDatabase.mockResolvedValue(undefined); + mockCountDocuments.mockResolvedValue(1); + mockFind.mockReturnValue({ + lean: vi.fn().mockResolvedValue([]), + }); + mockUpdateOne.mockResolvedValue({ acknowledged: true }); +}); + +describe('livestream list page queries', () => { + it('queries streamed livestreams with indexed filters and pagination', async () => { + const chain = mockFindChain([streamedDoc]); + + const result = await getStreamedLivestreamsPage(USER_ID, { limit: 2, offset: 0 }); + + expect(result.total).toBe(1); + expect(result.livestreams).toHaveLength(1); + expect(result.livestreams[0]?.id).toBe('streamed-1'); + expect(mockCountDocuments).toHaveBeenCalledWith({ + userId: USER_ID, + status: { $in: ['ended', 'failed'] }, + }); + expect(mockFind).toHaveBeenCalledWith({ + userId: USER_ID, + status: { $in: ['ended', 'failed'] }, + }); + expect(chain.sort).toHaveBeenCalledWith({ updatedAt: -1 }); + expect(chain.skip).toHaveBeenCalledWith(0); + expect(chain.limit).toHaveBeenCalledWith(2); + }); + + it('queries YouTube import livestreams with indexed filters and pagination', async () => { + const chain = mockFindChain([streamedDoc]); + + const result = await getYoutubeImportLivestreamsPage(USER_ID, { limit: 2, offset: 2 }); + + expect(result.total).toBe(1); + expect(result.livestreams).toHaveLength(1); + expect(mockCountDocuments).toHaveBeenCalledWith({ + userId: USER_ID, + hasYoutubeTarget: true, + youtubeBroadcastId: { $ne: '' }, + $or: [ + { status: { $in: ['ended', 'failed'] } }, + { status: 'live', youtubeLifecycleStatus: /^complete$/i }, + ], + }); + expect(chain.skip).toHaveBeenCalledWith(2); + expect(chain.limit).toHaveBeenCalledWith(2); + }); + + it('backfills query fields for legacy rows before paging', async () => { + const backfillLimit = vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { + _id: 'legacy-1', + userId: USER_ID, + document: streamedDoc.document, + }, + ]), + }); + mockFind + .mockReturnValueOnce({ + limit: backfillLimit, + }) + .mockReturnValueOnce(mockFindChain([streamedDoc])); + + await getStreamedLivestreamsPage(USER_ID, { limit: 1, offset: 0 }); + + expect(backfillLimit).toHaveBeenCalledWith(50); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'legacy-1' }, + { + status: 'ended', + hasYoutubeTarget: true, + youtubeBroadcastId: 'broadcast-1', + youtubeLifecycleStatus: '', + } + ); + }); +}); diff --git a/__tests__/repositories/youtube-import-jobs.test.ts b/__tests__/repositories/youtube-import-jobs.test.ts new file mode 100644 index 00000000..096aec8d --- /dev/null +++ b/__tests__/repositories/youtube-import-jobs.test.ts @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + mockConnectToDatabase, + mockCreate, + mockFindById, + mockFindOne, + mockFindByIdAndUpdate, + mockDeleteOne, +} = vi.hoisted(() => ({ + mockConnectToDatabase: vi.fn(), + mockCreate: vi.fn(), + mockFindById: vi.fn(), + mockFindOne: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockDeleteOne: vi.fn(), +})); + +vi.mock('@/lib/mongodb', () => ({ + connectToDatabase: (...args: unknown[]) => mockConnectToDatabase(...args), +})); + +vi.mock('@/lib/models/YoutubeImportJob', () => ({ + YoutubeImportJobModel: { + create: (...args: unknown[]) => mockCreate(...args), + findById: (...args: unknown[]) => mockFindById(...args), + findOne: (...args: unknown[]) => mockFindOne(...args), + findByIdAndUpdate: (...args: unknown[]) => mockFindByIdAndUpdate(...args), + deleteOne: (...args: unknown[]) => mockDeleteOne(...args), + }, +})); + +import { + createYoutubeImportJob, + getActiveYoutubeImportJobForUser, + updateYoutubeImportJobStatus, +} from '@/lib/repositories/youtube-import-jobs'; +import type { YoutubeImportJobDocument } from '@/lib/models/YoutubeImportJob'; + +function chain(value: T) { + return { + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockResolvedValue(value), + }; +} + +const baseDoc: YoutubeImportJobDocument = { + _id: 'yt-import-1', + userId: 'user-1', + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + youtubeVideoId: 'abc123', + livestreamId: '', + startSeconds: 0, + endSeconds: 120, + status: 'pending', + progressPercent: 0, + errorMessage: '', + r2Key: '', + uploadJobId: '', + distributeQueued: false, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockConnectToDatabase.mockResolvedValue(undefined); +}); + +describe('youtube-import-jobs repository (mongo)', () => { + it('creates a pending YouTube import job', async () => { + mockCreate.mockResolvedValueOnce({ toObject: () => baseDoc }); + + const row = await createYoutubeImportJob({ + userId: 'user-1', + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + youtubeVideoId: 'abc123', + startSeconds: 0, + endSeconds: 120, + }); + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + draftId: 'draft-1', + status: 'pending', + progressPercent: 0, + }) + ); + expect(row.id).toBe('yt-import-1'); + expect(row.livestreamId).toBeNull(); + }); + + it('throws YoutubeImportJobAlreadyActiveError on duplicate active job insert', async () => { + const duplicateKeyError = Object.assign(new Error('E11000 duplicate key error'), { + code: 11000, + }); + mockCreate.mockRejectedValueOnce(duplicateKeyError); + + await expect( + createYoutubeImportJob({ + userId: 'user-1', + draftId: 'draft-1', + sourceUrl: 'https://www.youtube.com/watch?v=abc123', + youtubeVideoId: 'abc123', + startSeconds: 0, + endSeconds: 120, + }) + ).rejects.toMatchObject({ + name: 'YoutubeImportJobAlreadyActiveError', + userId: 'user-1', + }); + }); + + it('updates only the fields provided in the status patch', async () => { + mockFindByIdAndUpdate.mockResolvedValueOnce(undefined); + + await updateYoutubeImportJobStatus('yt-import-1', { + status: 'downloading', + progressPercent: 25, + }); + + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith( + 'yt-import-1', + { + status: 'downloading', + progressPercent: 25, + }, + { runValidators: true } + ); + }); + + it('returns null from getActiveYoutubeImportJobForUser when none active', async () => { + mockFindOne.mockReturnValueOnce(chain(null)); + + const row = await getActiveYoutubeImportJobForUser('user-1'); + + expect(mockFindOne).toHaveBeenCalledWith({ + userId: 'user-1', + status: { $in: ['pending', 'downloading', 'trimming', 'uploading'] }, + }); + expect(row).toBeNull(); + }); +}); diff --git a/app/(dashboard)/dashboard/drafts/[id]/page.tsx b/app/(dashboard)/dashboard/drafts/[id]/page.tsx index cb976578..2d9181b8 100644 --- a/app/(dashboard)/dashboard/drafts/[id]/page.tsx +++ b/app/(dashboard)/dashboard/drafts/[id]/page.tsx @@ -1,24 +1,14 @@ -import type { Metadata } from 'next'; import { redirect } from 'next/navigation'; -/** - * Provides static page metadata for this route segment. - */ -export const metadata: Metadata = { - title: 'Edit Draft', - description: 'Open the draft metadata modal for this draft.', -}; - interface Props { params: Promise<{ id: string }>; } /** - * Renders the edit draft page component. - * @param props - Component props. - * @returns The rendered UI output. + * Legacy route redirect: /dashboard/drafts/[id] → /dashboard/uploads?editDraft=[id]. + * @param props - Route params. */ -export default async function EditDraftPage({ params }: Props) { +export default async function LegacyEditDraftRedirectPage({ params }: Props) { const { id } = await params; - redirect(`/dashboard/drafts?editDraft=${encodeURIComponent(id)}`); + redirect(`/dashboard/uploads?editDraft=${encodeURIComponent(id)}`); } diff --git a/app/(dashboard)/dashboard/drafts/[id]/upload/page.tsx b/app/(dashboard)/dashboard/drafts/[id]/upload/page.tsx index e7a0bdc9..e57c836d 100644 --- a/app/(dashboard)/dashboard/drafts/[id]/upload/page.tsx +++ b/app/(dashboard)/dashboard/drafts/[id]/upload/page.tsx @@ -1,44 +1,14 @@ -import type { Metadata } from 'next'; -import Link from 'next/link'; -import UploadVideoForm from '@/components/UploadVideoForm'; - -/** - * Provides static page metadata for this route segment. - */ -export const metadata: Metadata = { - title: 'Upload Video', - description: 'Upload a video file for this draft.', -}; +import { redirect } from 'next/navigation'; interface Props { params: Promise<{ id: string }>; } /** - * Renders the draft upload page component. - * @param props - Component props. - * @returns The rendered UI output. + * Legacy route redirect: /dashboard/drafts/[id]/upload → /dashboard/uploads/[id]/upload. + * @param props - Route params. */ -export default async function DraftUploadPage({ params }: Props) { +export default async function LegacyDraftUploadRedirectPage({ params }: Props) { const { id } = await params; - const backHref = `/dashboard/drafts?editDraft=${encodeURIComponent(id)}`; - - return ( -
-
-
-

Upload Video

-

- Upload your video file. Supported formats: MP4, MOV, AVI, MKV, WebM. Maximum size: 5 GB. -

-
- - - - - ← Back to draft - -
-
- ); + redirect(`/dashboard/uploads/${encodeURIComponent(id)}/upload`); } diff --git a/app/(dashboard)/dashboard/drafts/page.tsx b/app/(dashboard)/dashboard/drafts/page.tsx index 9cb3d393..8bd81ff8 100644 --- a/app/(dashboard)/dashboard/drafts/page.tsx +++ b/app/(dashboard)/dashboard/drafts/page.tsx @@ -1,1116 +1,8 @@ -'use client'; - -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { toast } from 'sonner'; -import { Trash2 } from 'lucide-react'; -import { DraftMetadataModal, type DraftEditorValues } from '@/components/drafts/DraftMetadataModal'; -import { backupNamingForStorage, normalizeBackupFileNameSettings } from '@/lib/backup-filename'; -import { useOnboardingContext } from '@/components/onboarding/OnboardingContext'; -import type { ApiResponse, ConnectedAccountPlatform, ConnectedAccountPublic, Draft } from '@/types'; - -const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); -type DraftView = 'list' | 'cards'; - -function draftTargetsEqual( - a: readonly ConnectedAccountPlatform[], - b: readonly ConnectedAccountPlatform[] -): boolean { - if (a.length !== b.length) return false; - const sa = [...a].sort(); - const sb = [...b].sort(); - return sa.every((v, i) => v === sb[i]); -} - -function formatLastEdited(isoDate: string): string { - const updatedDate = new Date(isoDate); - if (Number.isNaN(updatedDate.getTime())) return 'Recently'; - - const diffMs = updatedDate.getTime() - Date.now(); - const minute = 60 * 1000; - const hour = 60 * minute; - const day = 24 * hour; - - if (Math.abs(diffMs) < hour) { - return relativeTimeFormatter.format(Math.round(diffMs / minute), 'minute'); - } - - if (Math.abs(diffMs) < day) { - return relativeTimeFormatter.format(Math.round(diffMs / hour), 'hour'); - } - - return relativeTimeFormatter.format(Math.round(diffMs / day), 'day'); -} - -function createEditorValues(draft: Draft): DraftEditorValues { - return { - id: draft.id, - title: draft.title, - description: draft.description, - tags: draft.tags, - visibility: draft.visibility, - targets: [...draft.targets], - platforms: draft.platforms ?? {}, - backupNaming: normalizeBackupFileNameSettings(draft.backupNaming), - ...(draft.thumbnailR2Key ? { thumbnailR2Key: draft.thumbnailR2Key } : {}), - ...(draft.thumbnailContentType ? { thumbnailContentType: draft.thumbnailContentType } : {}), - ...(draft.thumbnailPreviewUrl ? { thumbnailPreviewUrl: draft.thumbnailPreviewUrl } : {}), - }; -} - -function createNewEditorValues(): DraftEditorValues { - return { - id: '', - title: '', - description: '', - tags: [], - visibility: 'public', - targets: [], - platforms: {}, - backupNaming: { ...normalizeBackupFileNameSettings(undefined) }, - }; -} - -function isMinimalCreateDraft(draft: Draft): boolean { - return ( - draft.title.trim() === '' && - draft.description.trim() === '' && - draft.tags.length === 0 && - draft.targets.length === 0 && - !draft.thumbnailR2Key && - !draft.thumbnailPreviewUrl - ); -} +import { redirect } from 'next/navigation'; /** - * Renders the drafts page component. - * @returns The rendered UI output. + * Legacy route redirect: /dashboard/drafts → /dashboard/uploads. */ -export default function DraftsPage() { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - const { setOnboardingDraftId } = useOnboardingContext(); - const handledEditDraftIdRef = useRef(null); - const handledCreateDraftIdRef = useRef(null); - /** Prevents duplicate router.replace when opening create from URL query (e.g. React Strict Mode). */ - const handledOpenCreateQueryRef = useRef(false); - const [drafts, setDrafts] = useState([]); - const [connectedPlatforms, setConnectedPlatforms] = useState([]); - const [hasLoadedConnections, setHasLoadedConnections] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [errorMessage, setErrorMessage] = useState(null); - const [view, setView] = useState('list'); - const [creatingDraft, setCreatingDraft] = useState(null); - const [editingDraft, setEditingDraft] = useState(null); - const [isSavingCreate, setIsSavingCreate] = useState(false); - const [isSavingEdit, setIsSavingEdit] = useState(false); - const [isDeletingId, setIsDeletingId] = useState(null); - const [isDuplicatingId, setIsDuplicatingId] = useState(null); - const [canUseAiMetadata, setCanUseAiMetadata] = useState(false); - const [isOpeningCreate, setIsOpeningCreate] = useState(false); - /** True after the user successfully saves a draft that was opened via minimal create. */ - const [createDraftSaved, setCreateDraftSaved] = useState(false); - /** True only when closing the create modal should delete the backing draft row. */ - const shouldDeleteCreateDraftOnCancelRef = useRef(false); - /** Baseline targets when minimal create opened (updated if auto-fill effect adds platforms). */ - const createModalBaselineTargetsRef = useRef(null); - /** Blocks duplicate POST when the duplicate button is clicked again before re-render. */ - const duplicatingDraftIdRef = useRef(null); - - const loadDrafts = useCallback(async (signal?: AbortSignal) => { - setIsLoading(true); - setErrorMessage(null); - setHasLoadedConnections(false); - - try { - const [draftsResponse, connectionsResponse, aiAccessResponse] = await Promise.all([ - fetch('/api/drafts', { - method: 'GET', - signal, - cache: 'no-store', - }), - fetch('/api/platforms/connections', { - method: 'GET', - signal, - cache: 'no-store', - }), - fetch('/api/auth/ai-access', { - method: 'GET', - signal, - cache: 'no-store', - }), - ]); - - if (!draftsResponse.ok) { - const errorBody = (await draftsResponse.json().catch(() => null)) as { - message?: string; - } | null; - throw new Error(errorBody?.message ?? 'Failed to load drafts.'); - } - - const draftsJson = (await draftsResponse.json()) as ApiResponse; - - let platforms: ConnectedAccountPlatform[] = []; - if (connectionsResponse.ok) { - const connectionsPayload = (await connectionsResponse.json()) as ApiResponse< - ConnectedAccountPublic[] - >; - platforms = Array.isArray(connectionsPayload.data) - ? connectionsPayload.data.map((account) => account.platform) - : []; - } - setConnectedPlatforms(platforms); - setHasLoadedConnections(connectionsResponse.ok); - - const aiAccessPayload = aiAccessResponse.ok - ? ((await aiAccessResponse.json()) as { canUseAiMetadata?: boolean }) - : null; - - setDrafts(Array.isArray(draftsJson.data) ? draftsJson.data : []); - setCanUseAiMetadata(Boolean(aiAccessPayload?.canUseAiMetadata)); - } catch (error) { - if (signal?.aborted) return; - const message = error instanceof Error ? error.message : 'Failed to load drafts.'; - setErrorMessage(message); - setDrafts([]); - setConnectedPlatforms([]); - setCanUseAiMetadata(false); - setHasLoadedConnections(false); - } finally { - if (!signal?.aborted) { - setIsLoading(false); - } - } - }, []); - - useEffect(() => { - const controller = new AbortController(); - void loadDrafts(controller.signal); - return () => controller.abort(); - }, [loadDrafts]); - - // Track onboarding draft in context so tour can clean it up - useEffect(() => { - const isOnboarding = searchParams.get('onboardingFlow') === 'true'; - if (isOnboarding && creatingDraft?.id) { - setOnboardingDraftId(creatingDraft.id); - } else { - // Clear stale draft ID when onboarding ends or draft is cleared - setOnboardingDraftId(null); - } - }, [creatingDraft?.id, searchParams, setOnboardingDraftId]); - - const openEditDraft = useCallback(async (draft: Draft) => { - try { - const res = await fetch(`/api/drafts/${draft.id}`, { cache: 'no-store' }); - if (!res.ok) { - setEditingDraft(createEditorValues(draft)); - return; - } - const payload = (await res.json()) as ApiResponse; - setEditingDraft(createEditorValues(payload.data ?? draft)); - } catch { - setEditingDraft(createEditorValues(draft)); - } - }, []); - - useEffect(() => { - const editDraftId = searchParams.get('editDraft'); - if (!editDraftId) { - handledEditDraftIdRef.current = null; - return; - } - if (handledEditDraftIdRef.current === editDraftId) return; - if (!editDraftId || isLoading || editingDraft !== null) return; - const draft = drafts.find((item) => item.id === editDraftId); - if (draft) { - handledEditDraftIdRef.current = editDraftId; - void openEditDraft(draft); - } - }, [drafts, editingDraft, isLoading, searchParams, openEditDraft]); - - const clearEditDraftQuery = useCallback(() => { - const nextParams = new URLSearchParams(searchParams.toString()); - if (!nextParams.has('editDraft')) return; - nextParams.delete('editDraft'); - const nextQuery = nextParams.toString(); - router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname); - }, [pathname, router, searchParams]); - - useEffect(() => { - if (!creatingDraft) return; - if (creatingDraft.targets.length > 0) return; - if (connectedPlatforms.length === 0) return; - - // Create mode default: preselect every connected platform. - setCreatingDraft((prev) => { - if (!prev || prev.targets.length > 0) return prev; - const nextTargets = [...connectedPlatforms]; - createModalBaselineTargetsRef.current = nextTargets; - return { - ...prev, - targets: nextTargets, - }; - }); - }, [connectedPlatforms, creatingDraft]); - - const handleDeleteDraft = useCallback( - async (draft: Draft) => { - if (creatingDraft?.id === draft.id || editingDraft?.id === draft.id) { - toast.error('Close the draft editor before deleting this draft.'); - return; - } - - const confirmed = window.confirm(`Delete "${draft.title}"? This cannot be undone.`); - if (!confirmed) return; - - setIsDeletingId(draft.id); - try { - const response = await fetch(`/api/drafts/${draft.id}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete draft'); - } - toast.success('Draft deleted'); - await loadDrafts(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete draft'); - } finally { - setIsDeletingId(null); - } - }, - [creatingDraft?.id, editingDraft?.id, loadDrafts] - ); - - const handleDeleteDraftById = useCallback( - async (draftId: string): Promise => { - setIsDeletingId(draftId); - try { - const response = await fetch(`/api/drafts/${draftId}`, { method: 'DELETE' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to delete draft'); - } - toast.success('Draft deleted'); - await loadDrafts(); - return true; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to delete draft'); - return false; - } finally { - setIsDeletingId(null); - } - }, - [loadDrafts] - ); - - const handleDuplicateDraft = useCallback( - async (draft: Draft) => { - if (duplicatingDraftIdRef.current === draft.id) return; - duplicatingDraftIdRef.current = draft.id; - setIsDuplicatingId(draft.id); - try { - const response = await fetch('/api/drafts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: `${draft.title} (copy)`, - description: draft.description, - tags: draft.tags, - targets: draft.targets, - visibility: draft.visibility, - platforms: draft.platforms, - backupNaming: draft.backupNaming, - }), - }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to duplicate draft'); - } - toast.success('Draft duplicated'); - await loadDrafts(); - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to duplicate draft'); - } finally { - duplicatingDraftIdRef.current = null; - setIsDuplicatingId(null); - } - }, - [loadDrafts] - ); - - const handleSaveEdit = useCallback( - async (options?: { - closeAfterSave?: boolean; - }): Promise<{ saved: boolean; draftId?: string; message?: string }> => { - if (!editingDraft) return { saved: false }; - if (editingDraft.targets.length === 0) { - toast.error('Select at least one target platform'); - return { saved: false }; - } - - setIsSavingEdit(true); - try { - const response = await fetch(`/api/drafts/${editingDraft.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: editingDraft.title, - description: editingDraft.description, - tags: editingDraft.tags, - visibility: editingDraft.visibility, - targets: editingDraft.targets, - platforms: editingDraft.platforms, - backupNaming: backupNamingForStorage(editingDraft.backupNaming), - }), - }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to update draft'); - } - const payload = (await response.json()) as ApiResponse; - const updatedDraft = payload.data; - if (updatedDraft && options?.closeAfterSave !== true) { - setEditingDraft(createEditorValues(updatedDraft)); - } - if (options?.closeAfterSave === true) { - setEditingDraft(null); - } - await loadDrafts(); - return { saved: true, draftId: editingDraft.id, message: 'Draft updated' }; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to update draft'); - return { saved: false }; - } finally { - setIsSavingEdit(false); - } - }, - [editingDraft, loadDrafts] - ); - - const handleSaveCreate = useCallback( - async (options?: { - closeAfterSave?: boolean; - }): Promise<{ saved: boolean; draftId?: string; message?: string }> => { - if (!creatingDraft) return { saved: false }; - if (creatingDraft.targets.length === 0) { - toast.error('Select at least one target platform'); - return { saved: false }; - } - - setIsSavingCreate(true); - try { - const isExistingDraft = creatingDraft.id.trim() !== ''; - const requestUrl = isExistingDraft ? `/api/drafts/${creatingDraft.id}` : '/api/drafts'; - const requestMethod = isExistingDraft ? 'PATCH' : 'POST'; - - const response = await fetch(requestUrl, { - method: requestMethod, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: creatingDraft.title, - description: creatingDraft.description, - tags: creatingDraft.tags, - visibility: creatingDraft.visibility, - targets: creatingDraft.targets, - platforms: creatingDraft.platforms, - backupNaming: backupNamingForStorage(creatingDraft.backupNaming), - }), - }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error( - err?.message ?? (isExistingDraft ? 'Failed to update draft' : 'Failed to create draft') - ); - } - const payload = (await response.json()) as ApiResponse; - const createdDraft = payload.data; - if (!createdDraft) { - throw new Error(isExistingDraft ? 'Failed to update draft' : 'Failed to create draft'); - } - - setCreatingDraft(createEditorValues(createdDraft)); - setDrafts((prev) => [ - createdDraft, - ...prev.filter((draft) => draft.id !== createdDraft.id), - ]); - const saveMessage = isExistingDraft ? 'Draft updated' : 'Draft created'; - setCreateDraftSaved(true); - - if (options?.closeAfterSave === true) { - setCreatingDraft(null); - setCreateDraftSaved(false); - } - return { saved: true, draftId: createdDraft.id, message: saveMessage }; - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : creatingDraft.id.trim() !== '' - ? 'Failed to update draft' - : 'Failed to create draft' - ); - return { saved: false }; - } finally { - setIsSavingCreate(false); - } - }, - [creatingDraft] - ); - - const handleOpenCreateModal = useCallback(async () => { - setIsOpeningCreate(true); - try { - const response = await fetch('/api/drafts', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ minimal: true }), - }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to create draft'); - } - const payload = (await response.json()) as ApiResponse; - const d = payload.data; - if (!d) { - throw new Error('Failed to create draft'); - } - const initialTargets = connectedPlatforms.length > 0 ? [...connectedPlatforms] : []; - createModalBaselineTargetsRef.current = initialTargets; - setCreatingDraft({ - ...createEditorValues(d), - targets: initialTargets, - }); - setCreateDraftSaved(false); - shouldDeleteCreateDraftOnCancelRef.current = true; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to create draft'); - } finally { - setIsOpeningCreate(false); - } - }, [connectedPlatforms]); - - const openExistingCreateDraft = useCallback( - async (draftId: string) => { - setIsOpeningCreate(true); - try { - const response = await fetch(`/api/drafts/${draftId}`, { cache: 'no-store' }); - if (!response.ok) { - const err = (await response.json().catch(() => null)) as { message?: string } | null; - throw new Error(err?.message ?? 'Failed to open draft'); - } - - const payload = (await response.json()) as ApiResponse; - const draft = payload.data; - if (!draft) { - throw new Error('Failed to open draft'); - } - - const draftWasMinimal = isMinimalCreateDraft(draft); - const initialTargets = - draft.targets.length > 0 - ? [...draft.targets] - : connectedPlatforms.length > 0 - ? [...connectedPlatforms] - : []; - createModalBaselineTargetsRef.current = initialTargets; - setCreatingDraft({ - ...createEditorValues(draft), - targets: initialTargets, - }); - setCreateDraftSaved(!draftWasMinimal); - shouldDeleteCreateDraftOnCancelRef.current = draftWasMinimal; - } catch (error) { - toast.error(error instanceof Error ? error.message : 'Failed to open draft'); - } finally { - setIsOpeningCreate(false); - } - }, - [connectedPlatforms] - ); - - useEffect(() => { - const shouldOpenCreate = - searchParams.get('openCreateDraft') === 'true' || searchParams.get('openWizard') === 'true'; - if (!shouldOpenCreate) { - handledOpenCreateQueryRef.current = false; - return; - } - if (creatingDraft || isOpeningCreate || handledOpenCreateQueryRef.current) return; - - handledOpenCreateQueryRef.current = true; - void handleOpenCreateModal(); - - const nextParams = new URLSearchParams(searchParams.toString()); - nextParams.delete('openCreateDraft'); - nextParams.delete('openWizard'); - const q = nextParams.toString(); - router.replace(q ? `${pathname}?${q}` : pathname); - }, [searchParams, creatingDraft, handleOpenCreateModal, isOpeningCreate, pathname, router]); - - useEffect(() => { - const createDraftId = searchParams.get('createDraftId'); - if (!createDraftId) { - handledCreateDraftIdRef.current = null; - return; - } - if (handledCreateDraftIdRef.current === createDraftId) return; - if (creatingDraft || isOpeningCreate) return; - - handledCreateDraftIdRef.current = createDraftId; - void openExistingCreateDraft(createDraftId); - - const nextParams = new URLSearchParams(searchParams.toString()); - nextParams.delete('createDraftId'); - const q = nextParams.toString(); - router.replace(q ? `${pathname}?${q}` : pathname); - }, [searchParams, creatingDraft, isOpeningCreate, openExistingCreateDraft, pathname, router]); - - const handleCloseCreateModal = useCallback(async () => { - if (creatingDraft?.id) { - if (!createDraftSaved && shouldDeleteCreateDraftOnCancelRef.current) { - const baselineTargets = createModalBaselineTargetsRef.current; - const hasTargetsChanged = - baselineTargets !== null && !draftTargetsEqual(creatingDraft.targets, baselineTargets); - const hasMeaningful = - creatingDraft.title.trim() !== '' || - creatingDraft.description.trim() !== '' || - creatingDraft.tags.length > 0 || - Boolean(creatingDraft.thumbnailR2Key || creatingDraft.thumbnailPreviewUrl) || - hasTargetsChanged; - if (hasMeaningful) { - const ok = window.confirm( - 'Discard draft? Unsaved changes will be lost and this draft will be deleted.' - ); - if (!ok) return; - } - try { - const res = await fetch(`/api/drafts/${creatingDraft.id}`, { method: 'DELETE' }); - if (!res.ok) { - const err = (await res.json().catch(() => null)) as { message?: string } | null; - toast.error(err?.message ?? 'Failed to discard draft'); - return; - } - await loadDrafts(); - } catch { - toast.error('Failed to discard draft'); - return; - } - } - } - createModalBaselineTargetsRef.current = null; - shouldDeleteCreateDraftOnCancelRef.current = false; - setCreatingDraft(null); - setCreateDraftSaved(false); - }, [creatingDraft, createDraftSaved, loadDrafts]); - - const hasDrafts = drafts.length > 0; - const headingDescription = useMemo( - () => - hasDrafts - ? `You have ${drafts.length} draft${drafts.length === 1 ? '' : 's'} ready to edit or upload.` - : "Videos you've started but haven't published yet. Drafts help you prepare uploads before distributing them.", - [drafts.length, hasDrafts] - ); - - return ( -
-
-
-

Drafts

-

{headingDescription}

-
- -
- -
- - -
- {isLoading ? ( - Loading drafts... - ) : null} -
- - {errorMessage ? ( -
- Failed to load drafts: {errorMessage} -
- ) : null} - - {!isLoading && !hasDrafts && !errorMessage ? ( -
-

No drafts yet

-

- Create a draft to get started. You can come back later to upload and publish your - video when you're ready. -

-
- ) : null} - - {hasDrafts ? ( - view === 'list' ? ( - { - void openEditDraft(draft); - }} - onDelete={handleDeleteDraft} - onDuplicate={handleDuplicateDraft} - isDeletingId={isDeletingId} - isDuplicatingId={isDuplicatingId} - /> - ) : ( - { - void openEditDraft(draft); - }} - onDelete={handleDeleteDraft} - onDuplicate={handleDuplicateDraft} - isDeletingId={isDeletingId} - isDuplicatingId={isDuplicatingId} - /> - ) - ) : null} -
- - { - void handleCloseCreateModal(); - }} - onSave={handleSaveCreate} - onUploadComplete={loadDrafts} - isSaving={isSavingCreate} - canUseAiMetadata={canUseAiMetadata} - disableInteractionLock={searchParams.get('onboardingFlow') === 'true'} - /> - { - setEditingDraft(null); - clearEditDraftQuery(); - }} - onSave={handleSaveEdit} - onUploadComplete={loadDrafts} - onDelete={handleDeleteDraftById} - isSaving={isSavingEdit} - canUseAiMetadata={canUseAiMetadata} - /> -
- ); -} - -interface DraftActionsProps { - draft: Draft; - onDelete: (draft: Draft) => void; - onDuplicate: (draft: Draft) => void; - isDeletingId: string | null; - isDuplicatingId: string | null; -} - -function DraftActions({ - draft, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, -}: DraftActionsProps) { - return ( -
- - -
- ); -} - -/** True when the draft has a real upload timestamp (aligned with GET /api/drafts backfill). */ -function hasNonEmptyUsedInUploadAt(draft: Draft): boolean { - return typeof draft.usedInUploadAt === 'string' && draft.usedInUploadAt.trim() !== ''; -} - -function partitionDraftsByUploadStatus(drafts: Draft[]): { unused: Draft[]; used: Draft[] } { - const unused: Draft[] = []; - const used: Draft[] = []; - for (const draft of drafts) { - if (hasNonEmptyUsedInUploadAt(draft)) { - used.push(draft); - } else { - unused.push(draft); - } - } - return { unused, used }; -} - -function UsedIndicator({ used }: { used: boolean }) { - return ( - - {used ? 'Used in upload' : 'Ready to upload'} - - ); -} - -interface DraftSectionProps { - title: string; - description: string; - used?: boolean; - children: ReactNode; -} - -function DraftSection({ title, description, used = false, children }: DraftSectionProps) { - return ( -
-
-

{title}

-

{description}

-
- {children} -
- ); -} - -interface DraftCollectionProps { - drafts: Draft[]; - onEdit: (draft: Draft) => void; - onDelete: (draft: Draft) => void; - onDuplicate: (draft: Draft) => void; - isDeletingId: string | null; - isDuplicatingId: string | null; -} - -function DraftsTable({ - drafts, - onEdit, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, -}: DraftCollectionProps) { - const { unused, used } = partitionDraftsByUploadStatus(drafts); - - return ( -
- {unused.length > 0 ? ( - - - - ) : null} - {used.length > 0 ? ( - - - - ) : null} -
- ); -} - -function DraftsTableContent({ - drafts, - onEdit, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, - dimUsedRows = false, -}: DraftCollectionProps & { dimUsedRows?: boolean }) { - return ( -
- - - - - - - - - - - {drafts.map((draft) => { - const displayTitle = draft.title.trim() || 'Untitled draft'; - const used = hasNonEmptyUsedInUploadAt(draft); - return ( - - - - - - - ); - })} - -
- Draft title - - Last edited - - Status - - Actions -
- - - - - - -
-
-
-
- ); -} - -function DraftCards({ - drafts, - onEdit, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, -}: DraftCollectionProps) { - const { unused, used } = partitionDraftsByUploadStatus(drafts); - - return ( -
- {unused.length > 0 ? ( - - - - ) : null} - {used.length > 0 ? ( - - - - ) : null} -
- ); -} - -function DraftCardsGrid({ - drafts, - onEdit, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, - dimUsedCards = false, -}: DraftCollectionProps & { dimUsedCards?: boolean }) { - return ( -
- {drafts.map((draft) => { - const displayTitle = draft.title.trim() || 'Untitled draft'; - const used = hasNonEmptyUsedInUploadAt(draft); - return ( -
-
- ); - })} -
- ); +export default function LegacyDraftsRedirectPage() { + redirect('/dashboard/uploads'); } diff --git a/app/(dashboard)/dashboard/history/page.tsx b/app/(dashboard)/dashboard/history/page.tsx index b130099b..e7006f8f 100644 --- a/app/(dashboard)/dashboard/history/page.tsx +++ b/app/(dashboard)/dashboard/history/page.tsx @@ -1,28 +1,8 @@ -import type { Metadata } from 'next'; -import { UploadHistoryClient } from '@/components/dashboard/UploadHistoryClient'; +import { redirect } from 'next/navigation'; /** - * Provides static page metadata for this route segment. + * Legacy route redirect: /dashboard/history → /dashboard/uploads/history. */ -export const metadata: Metadata = { - title: 'Upload History', - description: 'View your completed and failed video uploads.', -}; - -/** - * Renders the history page component. - * @returns The rendered UI output. - */ -export default function HistoryPage() { - return ( -
-
-

Upload History

-

- A record of all your completed and failed video distributions. -

- -
-
- ); +export default function LegacyHistoryRedirectPage() { + redirect('/dashboard/uploads/history'); } diff --git a/app/(dashboard)/dashboard/livestreams/history/page.tsx b/app/(dashboard)/dashboard/livestreams/history/page.tsx new file mode 100644 index 00000000..2c2420c5 --- /dev/null +++ b/app/(dashboard)/dashboard/livestreams/history/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import { StreamedLivestreamsHistoryClient } from '@/components/livestreams/StreamedLivestreamsHistoryClient'; + +/** + * Provides static page metadata for this route segment. + */ +export const metadata: Metadata = { + title: 'Livestream History', + description: 'View past streamed live broadcasts.', +}; + +/** + * Renders the paginated streamed livestreams history page. + * @returns The rendered UI output. + */ +export default function LivestreamsHistoryPage() { + return ( +
+
+

Livestream History

+

+ Past broadcasts that have ended on YouTube. +

+ +
+
+ ); +} diff --git a/app/(dashboard)/dashboard/livestreams/page.tsx b/app/(dashboard)/dashboard/livestreams/page.tsx index f8c5ec85..bb32b152 100644 --- a/app/(dashboard)/dashboard/livestreams/page.tsx +++ b/app/(dashboard)/dashboard/livestreams/page.tsx @@ -1,19 +1,24 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; import { toast } from 'sonner'; import { createLivestreamEditorValues, LivestreamMetadataModal, type LivestreamEditorValues, } from '@/components/livestreams/LivestreamMetadataModal'; +import { + LivestreamSection, + LivestreamsTableContent, + STREAMED_LIVESTREAM_PREVIEW_LIMIT, + displayTitle, +} from '@/components/livestreams/LivestreamsListTable'; import type { ApiResponse, ConnectedAccountPlatform, ConnectedAccountPublic, Livestream, - LivestreamStatus, } from '@/types'; import { canEditLivestreamSchedule } from '@/lib/livestreams/livestream-edit-policy'; import { partitionLivestreams } from '@/lib/livestreams/partition-livestreams'; @@ -63,50 +68,6 @@ function livestreamEditorHasMeaningfulChanges( ); } -function formatScheduledDateTime(iso: string | undefined): string { - if (!iso) return '—'; - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return '—'; - return new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(date); -} - -function displayTitle(livestream: Livestream): string { - return livestream.title.trim() || 'Untitled livestream'; -} - -function statusBadgeLabel(status: LivestreamStatus): string { - switch (status) { - case 'draft': - return 'Draft'; - case 'scheduled': - return 'Scheduled'; - case 'live': - return 'Live'; - case 'ended': - return 'Ended'; - case 'failed': - return 'Failed'; - default: - return status; - } -} - -function formatKeySwapNote(livestream: Livestream): string | null { - if (livestream.keySlotStaleAt) { - return `Key: main → stale (never went live) at ${formatScheduledDateTime(livestream.keySlotStaleAt)}`; - } - if (livestream.keySwapPromotedAt && livestream.status === 'scheduled') { - return `Key: temp → promoted to main at ${formatScheduledDateTime(livestream.keySwapPromotedAt)}`; - } - if (livestream.keySlot === 'temp' && livestream.status === 'scheduled') { - return 'Key: temp (queued)'; - } - return null; -} - /** * Livestreams dashboard list page: drafts, scheduled, live, and streamed sections. * @returns The rendered livestreams list UI. @@ -209,6 +170,11 @@ export default function LivestreamsPage() { () => partitionLivestreams(livestreams), [livestreams] ); + const streamedPreview = useMemo( + () => streamed.slice(0, STREAMED_LIVESTREAM_PREVIEW_LIMIT), + [streamed] + ); + const hasMoreStreamed = streamed.length > STREAMED_LIVESTREAM_PREVIEW_LIMIT; const armedLivestreamsForKeySlot = useMemo( () => @@ -649,16 +615,28 @@ export default function LivestreamsPage() { {streamed.length === 0 ? (

No streamed livestreams yet.

) : ( - + <> + + {hasMoreStreamed ? ( +
+ + View all streamed livestreams + +
+ ) : null} + )}
@@ -685,255 +663,3 @@ export default function LivestreamsPage() { ); } - -interface LivestreamActionsProps { - livestream: Livestream; - onDelete: (livestream: Livestream) => void; - onDuplicate: (livestream: Livestream) => void; - isDeletingId: string | null; - isDuplicatingId: string | null; -} - -function LivestreamActions({ - livestream, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, -}: LivestreamActionsProps) { - return ( -
- - -
- ); -} - -function StatusBadge({ status }: { status: LivestreamStatus }) { - const label = statusBadgeLabel(status); - const className = - status === 'draft' - ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100' - : status === 'scheduled' - ? 'border-sky-500/30 bg-sky-500/10 text-sky-950 dark:text-sky-100' - : status === 'live' - ? 'border-amber-500/40 bg-amber-500/15 text-amber-950 dark:text-amber-100' - : status === 'failed' - ? 'border-destructive/40 bg-destructive/10 text-destructive' - : 'border-muted-foreground/30 bg-muted/40 text-muted-foreground'; - - return ( - - {label} - - ); -} - -interface LivestreamSectionProps { - title: string; - description: string; - live?: boolean; - streamed?: boolean; - children: ReactNode; -} - -function LivestreamSection({ - title, - description, - live = false, - streamed = false, - children, -}: LivestreamSectionProps) { - const sectionClassName = live - ? 'border-amber-500/40 bg-amber-500/10' - : streamed - ? 'border-muted-foreground/30 bg-muted/20' - : 'border-border bg-background'; - - return ( -
-
-

{title}

-

{description}

-
- {children} -
- ); -} - -interface LivestreamsTableContentProps { - livestreams: Livestream[]; - showScheduledColumn: boolean; - onEdit: (livestream: Livestream) => void; - onDelete: (livestream: Livestream) => void; - onDuplicate: (livestream: Livestream) => void; - isDeletingId: string | null; - isDuplicatingId: string | null; - dimStreamedRows?: boolean; -} - -function LivestreamsTableContent({ - livestreams, - showScheduledColumn, - onEdit, - onDelete, - onDuplicate, - isDeletingId, - isDuplicatingId, - dimStreamedRows = false, -}: LivestreamsTableContentProps) { - return ( -
- - - - - {showScheduledColumn ? ( - - ) : null} - - - - - - {livestreams.map((livestream) => { - const title = displayTitle(livestream); - const keySwapNote = formatKeySwapNote(livestream); - return ( - - - {showScheduledColumn ? ( - - ) : null} - - - - ); - })} - -
- Title - - Scheduled - - Status - - Actions -
- - - - - - -
-
-
-
- ); -} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx index 13d5fd05..8ee88e78 100644 --- a/app/(dashboard)/dashboard/page.tsx +++ b/app/(dashboard)/dashboard/page.tsx @@ -147,7 +147,7 @@ export default async function DashboardPage() { Open draft @@ -162,7 +162,7 @@ export default async function DashboardPage() { {readyDrafts > previewDrafts.length ? (
View all drafts diff --git a/app/(dashboard)/dashboard/uploads/[id]/page.tsx b/app/(dashboard)/dashboard/uploads/[id]/page.tsx new file mode 100644 index 00000000..8c51e083 --- /dev/null +++ b/app/(dashboard)/dashboard/uploads/[id]/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next'; +import { redirect } from 'next/navigation'; + +/** + * Provides static page metadata for this route segment. + */ +export const metadata: Metadata = { + title: 'Edit Draft', + description: 'Open the draft metadata modal for this draft.', +}; + +interface Props { + params: Promise<{ id: string }>; +} + +/** + * Renders the edit draft page component. + * @param props - Component props. + * @returns The rendered UI output. + */ +export default async function EditDraftPage({ params }: Props) { + const { id } = await params; + redirect(`/dashboard/uploads?editDraft=${encodeURIComponent(id)}`); +} diff --git a/app/(dashboard)/dashboard/uploads/[id]/upload/page.tsx b/app/(dashboard)/dashboard/uploads/[id]/upload/page.tsx new file mode 100644 index 00000000..ab42c885 --- /dev/null +++ b/app/(dashboard)/dashboard/uploads/[id]/upload/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import UploadVideoForm from '@/components/UploadVideoForm'; + +/** + * Provides static page metadata for this route segment. + */ +export const metadata: Metadata = { + title: 'Upload Video', + description: 'Upload a video file for this draft.', +}; + +interface Props { + params: Promise<{ id: string }>; +} + +/** + * Renders the draft upload page component. + * @param props - Component props. + * @returns The rendered UI output. + */ +export default async function DraftUploadPage({ params }: Props) { + const { id } = await params; + const backHref = `/dashboard/uploads?editDraft=${encodeURIComponent(id)}`; + + return ( +
+
+
+

Upload Video

+

+ Upload your video file. Supported formats: MP4, MOV, AVI, MKV, WebM. Maximum size: 5 GB. +

+
+ + + + + ← Back to draft + +
+
+ ); +} diff --git a/app/(dashboard)/dashboard/uploads/history/page.tsx b/app/(dashboard)/dashboard/uploads/history/page.tsx new file mode 100644 index 00000000..b130099b --- /dev/null +++ b/app/(dashboard)/dashboard/uploads/history/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import { UploadHistoryClient } from '@/components/dashboard/UploadHistoryClient'; + +/** + * Provides static page metadata for this route segment. + */ +export const metadata: Metadata = { + title: 'Upload History', + description: 'View your completed and failed video uploads.', +}; + +/** + * Renders the history page component. + * @returns The rendered UI output. + */ +export default function HistoryPage() { + return ( +
+
+

Upload History

+

+ A record of all your completed and failed video distributions. +

+ +
+
+ ); +} diff --git a/app/(dashboard)/dashboard/uploads/page.tsx b/app/(dashboard)/dashboard/uploads/page.tsx new file mode 100644 index 00000000..403856ca --- /dev/null +++ b/app/(dashboard)/dashboard/uploads/page.tsx @@ -0,0 +1,1280 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { Copy, Loader2, Trash2 } from 'lucide-react'; +import { DraftMetadataModal, type DraftEditorValues } from '@/components/drafts/DraftMetadataModal'; +import { backupNamingForStorage, normalizeBackupFileNameSettings } from '@/lib/backup-filename'; +import { useOnboardingContext } from '@/components/onboarding/OnboardingContext'; +import type { + ApiResponse, + ConnectedAccountPlatform, + ConnectedAccountPublic, + Draft, + DraftLabelDefinition, +} from '@/types'; +import { DraftLabelChip } from '@/components/drafts/DraftLabelChip'; +import { getUsableConnectedPlatforms } from '@/lib/platforms/connection-status'; + +const relativeTimeFormatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); +type UploadsView = 'list' | 'cards'; + +function draftTargetsEqual( + a: readonly ConnectedAccountPlatform[], + b: readonly ConnectedAccountPlatform[] +): boolean { + if (a.length !== b.length) return false; + const sa = [...a].sort(); + const sb = [...b].sort(); + return sa.every((v, i) => v === sb[i]); +} + +function formatLastEdited(isoDate: string): string { + const updatedDate = new Date(isoDate); + if (Number.isNaN(updatedDate.getTime())) return 'Recently'; + + const diffMs = updatedDate.getTime() - Date.now(); + const minute = 60 * 1000; + const hour = 60 * minute; + const day = 24 * hour; + + if (Math.abs(diffMs) < hour) { + return relativeTimeFormatter.format(Math.round(diffMs / minute), 'minute'); + } + + if (Math.abs(diffMs) < day) { + return relativeTimeFormatter.format(Math.round(diffMs / hour), 'hour'); + } + + return relativeTimeFormatter.format(Math.round(diffMs / day), 'day'); +} + +function createEditorValues(draft: Draft): DraftEditorValues { + return { + id: draft.id, + title: draft.title, + description: draft.description, + tags: draft.tags, + labels: draft.labels ?? [], + visibility: draft.visibility, + targets: [...draft.targets], + platforms: draft.platforms ?? {}, + backupNaming: normalizeBackupFileNameSettings(draft.backupNaming), + ...(draft.thumbnailR2Key ? { thumbnailR2Key: draft.thumbnailR2Key } : {}), + ...(draft.thumbnailContentType ? { thumbnailContentType: draft.thumbnailContentType } : {}), + ...(draft.thumbnailPreviewUrl ? { thumbnailPreviewUrl: draft.thumbnailPreviewUrl } : {}), + }; +} + +function isMinimalCreateDraft(draft: Draft): boolean { + return ( + draft.title.trim() === '' && + draft.description.trim() === '' && + draft.tags.length === 0 && + draft.targets.length === 0 && + !draft.thumbnailR2Key && + !draft.thumbnailPreviewUrl + ); +} + +/** + * Renders the dashboard Uploads page. + * @returns The rendered UI output. + */ +export default function UploadsPage() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { setOnboardingDraftId } = useOnboardingContext(); + const handledEditDraftIdRef = useRef(null); + const handledCreateDraftIdRef = useRef(null); + /** Prevents duplicate router.replace when opening create from URL query (e.g. React Strict Mode). */ + const handledOpenCreateQueryRef = useRef(false); + const [drafts, setDrafts] = useState([]); + const [labelLibrary, setLabelLibrary] = useState([]); + const [connectedPlatforms, setConnectedPlatforms] = useState([]); + const [hasLoadedConnections, setHasLoadedConnections] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [view, setView] = useState('list'); + const [creatingDraft, setCreatingDraft] = useState(null); + const [editingDraft, setEditingDraft] = useState(null); + const [isSavingCreate, setIsSavingCreate] = useState(false); + const [isSavingEdit, setIsSavingEdit] = useState(false); + const [isDeletingId, setIsDeletingId] = useState(null); + const [isDuplicatingId, setIsDuplicatingId] = useState(null); + const [canUseAiMetadata, setCanUseAiMetadata] = useState(false); + const [isOpeningCreate, setIsOpeningCreate] = useState(false); + /** True after the user successfully saves a draft that was opened via minimal create. */ + const [createDraftSaved, setCreateDraftSaved] = useState(false); + /** True only when closing the create modal should delete the backing draft row. */ + const shouldDeleteCreateDraftOnCancelRef = useRef(false); + /** Baseline targets when minimal create opened (updated if auto-fill effect adds platforms). */ + const createModalBaselineTargetsRef = useRef(null); + /** Blocks duplicate POST when the duplicate button is clicked again before re-render. */ + const duplicatingDraftIdRef = useRef(null); + + const loadDrafts = useCallback(async (signal?: AbortSignal) => { + setIsLoading(true); + setErrorMessage(null); + setHasLoadedConnections(false); + + try { + const [draftsResponse, connectionsResponse, aiAccessResponse, labelsResponse] = + await Promise.all([ + fetch('/api/drafts', { + method: 'GET', + signal, + cache: 'no-store', + }), + fetch('/api/platforms/connections', { + method: 'GET', + signal, + cache: 'no-store', + }), + fetch('/api/auth/ai-access', { + method: 'GET', + signal, + cache: 'no-store', + }), + fetch('/api/drafts/labels', { + method: 'GET', + signal, + cache: 'no-store', + }), + ]); + + if (!draftsResponse.ok) { + const errorBody = (await draftsResponse.json().catch(() => null)) as { + message?: string; + } | null; + throw new Error(errorBody?.message ?? 'Failed to load drafts.'); + } + + const draftsJson = (await draftsResponse.json()) as ApiResponse; + + let platforms: ConnectedAccountPlatform[] = []; + if (connectionsResponse.ok) { + const connectionsPayload = (await connectionsResponse.json()) as ApiResponse< + ConnectedAccountPublic[] + >; + platforms = Array.isArray(connectionsPayload.data) + ? getUsableConnectedPlatforms(connectionsPayload.data) + : []; + } + setConnectedPlatforms(platforms); + setHasLoadedConnections(connectionsResponse.ok); + + const aiAccessPayload = aiAccessResponse.ok + ? ((await aiAccessResponse.json()) as { canUseAiMetadata?: boolean }) + : null; + + setDrafts(Array.isArray(draftsJson.data) ? draftsJson.data : []); + if (labelsResponse.ok) { + const labelsPayload = (await labelsResponse.json()) as ApiResponse; + setLabelLibrary(Array.isArray(labelsPayload.data) ? labelsPayload.data : []); + } else { + setLabelLibrary([]); + } + setCanUseAiMetadata(Boolean(aiAccessPayload?.canUseAiMetadata)); + } catch (error) { + if (signal?.aborted) return; + const message = error instanceof Error ? error.message : 'Failed to load drafts.'; + setErrorMessage(message); + setDrafts([]); + setLabelLibrary([]); + setConnectedPlatforms([]); + setCanUseAiMetadata(false); + setHasLoadedConnections(false); + } finally { + if (!signal?.aborted) { + setIsLoading(false); + } + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void loadDrafts(controller.signal); + return () => controller.abort(); + }, [loadDrafts]); + + // Track onboarding draft in context so tour can clean it up + useEffect(() => { + const isOnboarding = searchParams.get('onboardingFlow') === 'true'; + if (isOnboarding && creatingDraft?.id) { + setOnboardingDraftId(creatingDraft.id); + } else { + // Clear stale draft ID when onboarding ends or draft is cleared + setOnboardingDraftId(null); + } + }, [creatingDraft?.id, searchParams, setOnboardingDraftId]); + + const openEditDraft = useCallback(async (draft: Draft) => { + try { + const res = await fetch(`/api/drafts/${draft.id}`, { cache: 'no-store' }); + if (!res.ok) { + setEditingDraft(createEditorValues(draft)); + return; + } + const payload = (await res.json()) as ApiResponse; + setEditingDraft(createEditorValues(payload.data ?? draft)); + } catch { + setEditingDraft(createEditorValues(draft)); + } + }, []); + + useEffect(() => { + const editDraftId = searchParams.get('editDraft'); + if (!editDraftId) { + handledEditDraftIdRef.current = null; + return; + } + if (handledEditDraftIdRef.current === editDraftId) return; + if (!editDraftId || isLoading || editingDraft !== null) return; + const draft = drafts.find((item) => item.id === editDraftId); + if (draft) { + handledEditDraftIdRef.current = editDraftId; + void openEditDraft(draft); + } + }, [drafts, editingDraft, isLoading, searchParams, openEditDraft]); + + const clearEditDraftQuery = useCallback(() => { + const nextParams = new URLSearchParams(searchParams.toString()); + if (!nextParams.has('editDraft')) return; + nextParams.delete('editDraft'); + const nextQuery = nextParams.toString(); + router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname); + }, [pathname, router, searchParams]); + + useEffect(() => { + if (!creatingDraft) return; + if (creatingDraft.targets.length > 0) return; + if (connectedPlatforms.length === 0) return; + + // Create mode default: preselect every connected platform. + setCreatingDraft((prev) => { + if (!prev || prev.targets.length > 0) return prev; + const nextTargets = [...connectedPlatforms]; + createModalBaselineTargetsRef.current = nextTargets; + return { + ...prev, + targets: nextTargets, + }; + }); + }, [connectedPlatforms, creatingDraft]); + + const handleDeleteDraft = useCallback( + async (draft: Draft) => { + if (creatingDraft?.id === draft.id || editingDraft?.id === draft.id) { + toast.error('Close the draft editor before deleting this draft.'); + return; + } + + const confirmed = window.confirm(`Delete "${draft.title}"? This cannot be undone.`); + if (!confirmed) return; + + setIsDeletingId(draft.id); + try { + const response = await fetch(`/api/drafts/${draft.id}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete draft'); + } + toast.success('Draft deleted'); + await loadDrafts(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete draft'); + } finally { + setIsDeletingId(null); + } + }, + [creatingDraft?.id, editingDraft?.id, loadDrafts] + ); + + const handleDeleteDraftById = useCallback( + async (draftId: string): Promise => { + setIsDeletingId(draftId); + try { + const response = await fetch(`/api/drafts/${draftId}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete draft'); + } + toast.success('Draft deleted'); + await loadDrafts(); + return true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete draft'); + return false; + } finally { + setIsDeletingId(null); + } + }, + [loadDrafts] + ); + + const handleDuplicateDraft = useCallback( + async (draft: Draft) => { + if (duplicatingDraftIdRef.current === draft.id) return; + duplicatingDraftIdRef.current = draft.id; + setIsDuplicatingId(draft.id); + try { + const response = await fetch('/api/drafts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: `${draft.title} (copy)`, + description: draft.description, + tags: draft.tags, + labels: draft.labels, + targets: draft.targets, + visibility: draft.visibility, + platforms: draft.platforms, + backupNaming: draft.backupNaming, + }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to duplicate draft'); + } + toast.success('Draft duplicated'); + await loadDrafts(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to duplicate draft'); + } finally { + duplicatingDraftIdRef.current = null; + setIsDuplicatingId(null); + } + }, + [loadDrafts] + ); + + const handleSaveEdit = useCallback( + async (options?: { + closeAfterSave?: boolean; + }): Promise<{ saved: boolean; draftId?: string; message?: string }> => { + if (!editingDraft) return { saved: false }; + if (editingDraft.targets.length === 0) { + toast.error('Select at least one target platform'); + return { saved: false }; + } + + setIsSavingEdit(true); + try { + const response = await fetch(`/api/drafts/${editingDraft.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: editingDraft.title, + description: editingDraft.description, + tags: editingDraft.tags, + labels: editingDraft.labels, + visibility: editingDraft.visibility, + targets: editingDraft.targets, + platforms: editingDraft.platforms, + backupNaming: backupNamingForStorage(editingDraft.backupNaming), + }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to update draft'); + } + const payload = (await response.json()) as ApiResponse; + const updatedDraft = payload.data; + if (updatedDraft && options?.closeAfterSave !== true) { + setEditingDraft(createEditorValues(updatedDraft)); + } + if (options?.closeAfterSave === true) { + setEditingDraft(null); + } + await loadDrafts(); + return { saved: true, draftId: editingDraft.id, message: 'Draft updated' }; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to update draft'); + return { saved: false }; + } finally { + setIsSavingEdit(false); + } + }, + [editingDraft, loadDrafts] + ); + + const handleSaveCreate = useCallback( + async (options?: { + closeAfterSave?: boolean; + }): Promise<{ saved: boolean; draftId?: string; message?: string }> => { + if (!creatingDraft) return { saved: false }; + if (creatingDraft.targets.length === 0) { + toast.error('Select at least one target platform'); + return { saved: false }; + } + + setIsSavingCreate(true); + try { + const isExistingDraft = creatingDraft.id.trim() !== ''; + const requestUrl = isExistingDraft ? `/api/drafts/${creatingDraft.id}` : '/api/drafts'; + const requestMethod = isExistingDraft ? 'PATCH' : 'POST'; + + const response = await fetch(requestUrl, { + method: requestMethod, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: creatingDraft.title, + description: creatingDraft.description, + tags: creatingDraft.tags, + labels: creatingDraft.labels, + visibility: creatingDraft.visibility, + targets: creatingDraft.targets, + platforms: creatingDraft.platforms, + backupNaming: backupNamingForStorage(creatingDraft.backupNaming), + }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error( + err?.message ?? (isExistingDraft ? 'Failed to update draft' : 'Failed to create draft') + ); + } + const payload = (await response.json()) as ApiResponse; + const createdDraft = payload.data; + if (!createdDraft) { + throw new Error(isExistingDraft ? 'Failed to update draft' : 'Failed to create draft'); + } + + setCreatingDraft(createEditorValues(createdDraft)); + setDrafts((prev) => [ + createdDraft, + ...prev.filter((draft) => draft.id !== createdDraft.id), + ]); + const saveMessage = isExistingDraft ? 'Draft updated' : 'Draft created'; + setCreateDraftSaved(true); + + if (options?.closeAfterSave === true) { + setCreatingDraft(null); + setCreateDraftSaved(false); + } + return { saved: true, draftId: createdDraft.id, message: saveMessage }; + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : creatingDraft.id.trim() !== '' + ? 'Failed to update draft' + : 'Failed to create draft' + ); + return { saved: false }; + } finally { + setIsSavingCreate(false); + } + }, + [creatingDraft] + ); + + const handleOpenCreateModal = useCallback(async () => { + setIsOpeningCreate(true); + try { + const response = await fetch('/api/drafts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ minimal: true }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to create draft'); + } + const payload = (await response.json()) as ApiResponse; + const d = payload.data; + if (!d) { + throw new Error('Failed to create draft'); + } + const initialTargets = connectedPlatforms.length > 0 ? [...connectedPlatforms] : []; + createModalBaselineTargetsRef.current = initialTargets; + setCreatingDraft({ + ...createEditorValues(d), + targets: initialTargets, + }); + setCreateDraftSaved(false); + shouldDeleteCreateDraftOnCancelRef.current = true; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create draft'); + } finally { + setIsOpeningCreate(false); + } + }, [connectedPlatforms]); + + const openExistingCreateDraft = useCallback( + async (draftId: string) => { + setIsOpeningCreate(true); + try { + const response = await fetch(`/api/drafts/${draftId}`, { cache: 'no-store' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to open draft'); + } + + const payload = (await response.json()) as ApiResponse; + const draft = payload.data; + if (!draft) { + throw new Error('Failed to open draft'); + } + + const draftWasMinimal = isMinimalCreateDraft(draft); + const initialTargets = + draft.targets.length > 0 + ? [...draft.targets] + : connectedPlatforms.length > 0 + ? [...connectedPlatforms] + : []; + createModalBaselineTargetsRef.current = initialTargets; + setCreatingDraft({ + ...createEditorValues(draft), + targets: initialTargets, + }); + setCreateDraftSaved(!draftWasMinimal); + shouldDeleteCreateDraftOnCancelRef.current = draftWasMinimal; + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to open draft'); + } finally { + setIsOpeningCreate(false); + } + }, + [connectedPlatforms] + ); + + useEffect(() => { + const shouldOpenCreate = + searchParams.get('openCreateDraft') === 'true' || searchParams.get('openWizard') === 'true'; + if (!shouldOpenCreate) { + handledOpenCreateQueryRef.current = false; + return; + } + if (creatingDraft || isOpeningCreate || handledOpenCreateQueryRef.current) return; + + handledOpenCreateQueryRef.current = true; + void handleOpenCreateModal(); + + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete('openCreateDraft'); + nextParams.delete('openWizard'); + const q = nextParams.toString(); + router.replace(q ? `${pathname}?${q}` : pathname); + }, [searchParams, creatingDraft, handleOpenCreateModal, isOpeningCreate, pathname, router]); + + useEffect(() => { + const createDraftId = searchParams.get('createDraftId'); + if (!createDraftId) { + handledCreateDraftIdRef.current = null; + return; + } + if (handledCreateDraftIdRef.current === createDraftId) return; + if (creatingDraft || isOpeningCreate) return; + + handledCreateDraftIdRef.current = createDraftId; + void openExistingCreateDraft(createDraftId); + + const nextParams = new URLSearchParams(searchParams.toString()); + nextParams.delete('createDraftId'); + const q = nextParams.toString(); + router.replace(q ? `${pathname}?${q}` : pathname); + }, [searchParams, creatingDraft, isOpeningCreate, openExistingCreateDraft, pathname, router]); + + const handleCloseCreateModal = useCallback(async () => { + if (creatingDraft?.id) { + if (!createDraftSaved && shouldDeleteCreateDraftOnCancelRef.current) { + const baselineTargets = createModalBaselineTargetsRef.current; + const hasTargetsChanged = + baselineTargets !== null && !draftTargetsEqual(creatingDraft.targets, baselineTargets); + const hasMeaningful = + creatingDraft.title.trim() !== '' || + creatingDraft.description.trim() !== '' || + creatingDraft.tags.length > 0 || + Boolean(creatingDraft.thumbnailR2Key || creatingDraft.thumbnailPreviewUrl) || + hasTargetsChanged; + if (hasMeaningful) { + const ok = window.confirm( + 'Discard draft? Unsaved changes will be lost and this draft will be deleted.' + ); + if (!ok) return; + } + try { + const res = await fetch(`/api/drafts/${creatingDraft.id}`, { method: 'DELETE' }); + if (!res.ok) { + const err = (await res.json().catch(() => null)) as { message?: string } | null; + toast.error(err?.message ?? 'Failed to discard draft'); + return; + } + await loadDrafts(); + } catch { + toast.error('Failed to discard draft'); + return; + } + } + } + createModalBaselineTargetsRef.current = null; + shouldDeleteCreateDraftOnCancelRef.current = false; + setCreatingDraft(null); + setCreateDraftSaved(false); + }, [creatingDraft, createDraftSaved, loadDrafts]); + + const hasDrafts = drafts.length > 0; + const headingDescription = useMemo( + () => + hasDrafts + ? `You have ${drafts.length} draft${drafts.length === 1 ? '' : 's'} ready to edit or upload.` + : "Uploads you've started but haven't published yet. Drafts help you prepare uploads before distributing them.", + [drafts.length, hasDrafts] + ); + + return ( +
+
+
+

Uploads

+

{headingDescription}

+
+ +
+ +
+ + +
+ {isLoading ? ( + Loading drafts... + ) : null} +
+ + {errorMessage ? ( +
+ Failed to load drafts: {errorMessage} +
+ ) : null} + + {!isLoading && !hasDrafts && !errorMessage ? ( +
+

No drafts yet

+

+ Create a draft to get started. You can come back later to upload and publish your + video when you're ready. +

+
+ ) : null} + + {hasDrafts ? ( + view === 'list' ? ( + { + void openEditDraft(draft); + }} + onDelete={handleDeleteDraft} + onDuplicate={handleDuplicateDraft} + isDeletingId={isDeletingId} + isDuplicatingId={isDuplicatingId} + /> + ) : ( + { + void openEditDraft(draft); + }} + onDelete={handleDeleteDraft} + onDuplicate={handleDuplicateDraft} + isDeletingId={isDeletingId} + isDuplicatingId={isDuplicatingId} + /> + ) + ) : null} +
+ + { + void handleCloseCreateModal(); + }} + onSave={handleSaveCreate} + onUploadComplete={loadDrafts} + isSaving={isSavingCreate} + canUseAiMetadata={canUseAiMetadata} + disableInteractionLock={searchParams.get('onboardingFlow') === 'true'} + /> + { + setEditingDraft(null); + clearEditDraftQuery(); + }} + onSave={handleSaveEdit} + onUploadComplete={loadDrafts} + onDelete={handleDeleteDraftById} + isSaving={isSavingEdit} + canUseAiMetadata={canUseAiMetadata} + /> +
+ ); +} + +interface UploadsRowActionsProps { + draft: Draft; + onDelete: (draft: Draft) => void; + onDuplicate: (draft: Draft) => void; + isDeletingId: string | null; + isDuplicatingId: string | null; +} + +const draftActionIconButtonClassName = + 'pointer-events-auto inline-flex shrink-0 h-12 w-12 items-center justify-center rounded-md border border-border bg-background text-foreground transition-colors hover:bg-muted disabled:opacity-60'; + +function UploadsRowActions({ + draft, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: UploadsRowActionsProps) { + const isDuplicating = isDuplicatingId === draft.id; + + return ( +
+ + +
+ ); +} + +/** True when the draft has a real upload timestamp (aligned with GET /api/drafts backfill). */ +function hasNonEmptyUsedInUploadAt(draft: Draft): boolean { + return typeof draft.usedInUploadAt === 'string' && draft.usedInUploadAt.trim() !== ''; +} + +function partitionDraftsByUploadStatus(drafts: Draft[]): { unused: Draft[]; used: Draft[] } { + const unused: Draft[] = []; + const used: Draft[] = []; + for (const draft of drafts) { + if (hasNonEmptyUsedInUploadAt(draft)) { + used.push(draft); + } else { + unused.push(draft); + } + } + return { unused, used }; +} + +function UsedIndicator({ used }: { used: boolean }) { + return ( + + {used ? 'Used in upload' : 'Ready to upload'} + + ); +} + +interface UploadsSectionProps { + title: string; + description: string; + used?: boolean; + children: ReactNode; +} + +function UploadsSection({ title, description, used = false, children }: UploadsSectionProps) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +interface UploadsCollectionProps { + drafts: Draft[]; + labelLibrary: DraftLabelDefinition[]; + onEdit: (draft: Draft) => void; + onDelete: (draft: Draft) => void; + onDuplicate: (draft: Draft) => void; + isDeletingId: string | null; + isDuplicatingId: string | null; +} + +function UploadsTable({ + drafts, + labelLibrary, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: UploadsCollectionProps) { + const { unused, used } = partitionDraftsByUploadStatus(drafts); + + return ( +
+ {unused.length > 0 ? ( + + + + ) : null} + {used.length > 0 ? ( + + + + ) : null} +
+ ); +} + +function UploadsMobileRow({ + draft, + used, + labelLibrary, + dimUsedRows = false, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: { + draft: Draft; + used: boolean; + labelLibrary: DraftLabelDefinition[]; + dimUsedRows?: boolean; + onEdit: (draft: Draft) => void; + onDelete: (draft: Draft) => void; + onDuplicate: (draft: Draft) => void; + isDeletingId: string | null; + isDuplicatingId: string | null; +}) { + const displayTitle = draft.title.trim() || 'Untitled draft'; + + return ( +
+
+ ); +} + +function UploadsLabelChips({ + labels, + labelLibrary, + className, +}: { + labels: string[]; + labelLibrary: DraftLabelDefinition[]; + className?: string; +}) { + if (labels.length === 0) return null; + return ( +
    + {labels.map((label) => ( +
  • + +
  • + ))} +
+ ); +} + +function UploadsTableContent({ + drafts, + labelLibrary, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, + dimUsedRows = false, +}: UploadsCollectionProps & { dimUsedRows?: boolean }) { + return ( + <> +
+ {drafts.map((draft) => ( + + ))} +
+
+ + + + + + + + + + + + {drafts.map((draft) => { + const displayTitle = draft.title.trim() || 'Untitled draft'; + const used = hasNonEmptyUsedInUploadAt(draft); + return ( + + + + + + + + ); + })} + +
+ Draft title + + Last edited + + Status + + Labels + + Actions +
+ + + + + + + + +
+
+
+
+ + ); +} + +function UploadsCards({ + drafts, + labelLibrary, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: UploadsCollectionProps) { + const { unused, used } = partitionDraftsByUploadStatus(drafts); + + return ( +
+ {unused.length > 0 ? ( + + + + ) : null} + {used.length > 0 ? ( + + + + ) : null} +
+ ); +} + +function UploadsCardsGrid({ + drafts, + labelLibrary, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, + dimUsedCards = false, +}: UploadsCollectionProps & { dimUsedCards?: boolean }) { + return ( +
+ {drafts.map((draft) => { + const displayTitle = draft.title.trim() || 'Untitled draft'; + const used = hasNonEmptyUsedInUploadAt(draft); + return ( +
+
+ ); + })} +
+ ); +} diff --git a/app/(dashboard)/profile/ProfileContent.tsx b/app/(dashboard)/profile/ProfileContent.tsx index 6a74e4ba..ed8c78e7 100644 --- a/app/(dashboard)/profile/ProfileContent.tsx +++ b/app/(dashboard)/profile/ProfileContent.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { ProfileAuthSection } from './ProfileAuthSection'; import { ProfileInformationSection } from './ProfileInformationSection'; import { ProfilePreferencesSection } from './ProfilePreferencesSection'; +import { ProfileDraftLabelsSection } from './ProfileDraftLabelsSection'; import { ProfileSecuritySection } from './ProfileSecuritySection'; import { ProfileOAuthFlash } from './ProfileOAuthFlash'; import type { UserAuthProvider } from '@/types'; @@ -123,6 +124,8 @@ export function ProfileContent({ oauthSuccess, oauthError }: ProfileContentProps + + {authProvider === 'password' && sessionUser.email ? ( diff --git a/app/(dashboard)/profile/ProfileDraftLabelsSection.tsx b/app/(dashboard)/profile/ProfileDraftLabelsSection.tsx new file mode 100644 index 00000000..5dc17f50 --- /dev/null +++ b/app/(dashboard)/profile/ProfileDraftLabelsSection.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { DraftLabelChip } from '@/components/drafts/DraftLabelChip'; +import { + MAX_DRAFT_LABEL_LIBRARY_SIZE, + defaultDraftLabelColorForName, + mergeUniqueDraftLabels, + parseDraftLabelInput, +} from '@/lib/draft-labels'; +import type { ApiResponse, DraftLabelDefinition } from '@/types'; + +const INPUT_CLASS = + 'mt-2 block w-full rounded-lg border border-border bg-background px-4 py-3 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'; + +/** + * Settings section for managing saved draft labels used in autocomplete. + * @returns Draft labels management UI for the profile page. + */ +export function ProfileDraftLabelsSection() { + const [library, setLibrary] = useState([]); + const [inputValue, setInputValue] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const loadLibrary = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetch('/api/drafts/labels', { cache: 'no-store' }); + if (!response.ok) { + throw new Error('Failed to load draft labels'); + } + const payload = (await response.json()) as ApiResponse; + setLibrary(Array.isArray(payload.data) ? payload.data : []); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load draft labels.'); + setLibrary([]); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadLibrary(); + }, [loadLibrary]); + + const addLabelFromInput = () => { + const parsed = parseDraftLabelInput(inputValue); + if (parsed.length === 0) return; + const mergedNames = mergeUniqueDraftLabels( + library.map((entry) => entry.name), + parsed + ); + if (mergedNames.length > MAX_DRAFT_LABEL_LIBRARY_SIZE) { + setError(`You can save at most ${MAX_DRAFT_LABEL_LIBRARY_SIZE} labels.`); + return; + } + const mergedLibrary: DraftLabelDefinition[] = [...library]; + for (const name of parsed) { + if (mergedLibrary.some((entry) => entry.name.toLowerCase() === name.toLowerCase())) { + continue; + } + mergedLibrary.push({ name, color: defaultDraftLabelColorForName(name) }); + } + setLibrary(mergedLibrary); + setInputValue(''); + setError(null); + }; + + const updateLabelColor = (label: string, color: string) => { + setLibrary((current) => + current.map((entry) => (entry.name === label ? { ...entry, color } : entry)) + ); + }; + + const removeLabel = (label: string) => { + setLibrary((current) => current.filter((existing) => existing.name !== label)); + }; + + const handleSave = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setIsSaving(true); + + try { + const response = await fetch('/api/drafts/labels', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels: library }), + }); + const payload = (await response.json().catch(() => null)) as ApiResponse< + DraftLabelDefinition[] + > & { + message?: string; + }; + + if (!response.ok) { + setError(payload?.message ?? 'Failed to save draft labels.'); + return; + } + + setLibrary(Array.isArray(payload.data) ? payload.data : library); + toast.success('Draft labels saved.'); + } catch { + setError('An unexpected error occurred. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+

Draft labels

+

+ Saved labels appear as suggestions when you tag drafts. Choose a color for each label. + Deleting a label here also removes it from every draft that uses it. +

+ + {isLoading ? ( +

Loading labels…

+ ) : ( +
+
+ +
+ setInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + addLabelFromInput(); + } + }} + placeholder="Type a label and press Enter" + className={INPUT_CLASS} + /> + +
+
+ + {library.length > 0 ? ( +
    + {library.map((entry) => ( +
  • + updateLabelColor(entry.name, color)} + onRemove={() => removeLabel(entry.name)} + /> +
  • + ))} +
+ ) : ( +

No saved labels yet.

+ )} + + {error ?

{error}

: null} + + +
+ )} +
+ ); +} diff --git a/app/(dashboard)/profile/connections/page.tsx b/app/(dashboard)/profile/connections/page.tsx index 308e2771..fb5e2f98 100644 --- a/app/(dashboard)/profile/connections/page.tsx +++ b/app/(dashboard)/profile/connections/page.tsx @@ -18,14 +18,19 @@ import { redirect } from 'next/navigation'; import { revalidatePath } from 'next/cache'; import { isTokenDecryptError } from '@/lib/crypto/token-encryption'; import { getCurrentUserIdFromCookies } from '@/lib/auth/get-current-user-id-from-cookies'; +import { getConnectedAccountsWithHealth } from '@/lib/platforms/connected-accounts-health'; +import { + isSermonAudioConnectionReady, + isSftpConnectionReady, + isSmbConnectionReady, + resolveConnectionStatus, +} from '@/lib/platforms/connection-status'; import { - getConnectedAccountsByUser, getConnectedAccountForUser, getConnectedAccountWithTokens, deleteConnectedAccount, } from '@/lib/repositories/connected-accounts'; import { clearDraftLivestreamYouTubeBroadcastLinksForUser } from '@/lib/repositories/livestreams'; -import { normalizeConnectedAccountSftpHostKeyFingerprint } from '@/lib/models/ConnectedAccount'; import { revokeFacebookAppAuthorization } from '@/lib/platforms/facebook-oauth'; import type { ConnectedAccountPublic } from '@/types'; import { ConnectButton } from './ConnectButton'; @@ -124,7 +129,7 @@ function sortPlatformsInSection( return [...platforms] .map((platform) => { const account = accounts.find((a) => a.platform === platform); - const status = getConnectionStatus(account); + const status = account ? resolveConnectionStatus(account) : 'not-connected'; return { platform, label: PLATFORM_META[platform].label, @@ -140,35 +145,6 @@ function sortPlatformsInSection( .map(({ platform }) => platform); } -/** True when an SFTP row has the fields required for backups (including a pinned host key). */ -function isSftpConnectionReady(account: ConnectedAccountPublic): boolean { - const fingerprint = account.sftpHostKeyFingerprint; - return ( - account.platform === 'sftp' && - Boolean(account.sftpHost?.trim()) && - Boolean(account.sftpRemotePath?.trim()) && - Boolean(account.sftpAuthMethod) && - fingerprint != null && - normalizeConnectedAccountSftpHostKeyFingerprint(fingerprint) != null - ); -} - -/** True when an SMB row has the fields required for backups. */ -function isSmbConnectionReady(account: ConnectedAccountPublic): boolean { - return ( - account.platform === 'smb' && - Boolean(account.smbHost?.trim()) && - Boolean(account.smbShare?.trim()) && - account.smbRemotePath != null && - account.smbRemotePath.trim() !== '' - ); -} - -/** True when a SermonAudio row has the broadcaster id required for uploads. */ -function isSermonAudioConnectionReady(account: ConnectedAccountPublic): boolean { - return account.platform === 'sermon_audio' && Boolean(account.platformUserId.trim()); -} - /** Build editable SMB settings from a connected account row (non-secret fields only). */ function toSmbExistingConnection( account: ConnectedAccountPublic @@ -272,35 +248,6 @@ function toFacebookExistingConnection( }; } -/** Derive connection status from tokenExpiry and whether a refresh token exists. */ -function getConnectionStatus( - account: ConnectedAccountPublic | undefined -): 'connected' | 'expired' | 'not-connected' { - if (!account) return 'not-connected'; - if (account.platform === 'sftp') { - return isSftpConnectionReady(account) ? 'connected' : 'expired'; - } - if (account.platform === 'smb') { - return isSmbConnectionReady(account) ? 'connected' : 'expired'; - } - if (account.platform === 'sermon_audio') { - return isSermonAudioConnectionReady(account) ? 'connected' : 'expired'; - } - const expiryMs = new Date(account.tokenExpiry).getTime(); - if (!Number.isNaN(expiryMs) && expiryMs > Date.now()) return 'connected'; - // YouTube, Google Drive, and Facebook use short-lived or schedulable tokens; a stored refresh token - // means the link can be renewed automatically before the next API call. - if ( - (account.platform === 'youtube' || - account.platform === 'google_drive' || - account.platform === 'facebook') && - account.hasRefreshToken - ) { - return 'connected'; - } - return 'expired'; -} - function StatusBadge({ status }: { status: 'connected' | 'expired' | 'not-connected' }) { if (status === 'connected') { return ( @@ -312,7 +259,7 @@ function StatusBadge({ status }: { status: 'connected' | 'expired' | 'not-connec if (status === 'expired') { return ( - Expired — reconnect + Reconnect required ); } @@ -481,7 +428,7 @@ function ConnectionPlatformRow({ openGoogleDriveBackupSetup = false, }: ConnectionPlatformRowProps) { const meta = PLATFORM_META[platform]; - const status = getConnectionStatus(account); + const status = resolveConnectionStatus(account); const sftpExistingConnection = account?.platform === 'sftp' ? toSftpExistingConnection(account) : undefined; const smbExistingConnection = @@ -664,7 +611,7 @@ export default async function ConnectionsPage({ searchParams }: PageProps) { let accounts: ConnectedAccountPublic[] = []; try { - accounts = await getConnectedAccountsByUser(userId); + accounts = await getConnectedAccountsWithHealth(userId); } catch (err) { console.error('[ConnectionsPage] Failed to fetch connected accounts:', err); } diff --git a/app/(dashboard)/template.tsx b/app/(dashboard)/template.tsx index f67020eb..f3801cb6 100644 --- a/app/(dashboard)/template.tsx +++ b/app/(dashboard)/template.tsx @@ -1,6 +1,7 @@ import Navbar from '@/components/layout/Navbar'; import Footer from '@/components/layout/Footer'; import DashboardShell from '@/components/dashboard/DashboardShell'; +import { DashboardNavProvider } from '@/components/dashboard/DashboardNavProvider'; import { getNavbarAuthStateFromCookies } from '@/lib/auth/get-current-user-id-from-cookies'; /** @@ -12,12 +13,12 @@ export default async function DashboardTemplate({ children }: { children: React. const { sessionUser, hasAdminRole } = await getNavbarAuthStateFromCookies(); return ( - <> +
{children}
- + ); } diff --git a/app/api/drafts/[id]/route.ts b/app/api/drafts/[id]/route.ts index 90989f62..1f4eceba 100644 --- a/app/api/drafts/[id]/route.ts +++ b/app/api/drafts/[id]/route.ts @@ -24,6 +24,8 @@ import { parseTagsFromRequestBody, resolveDraftTitleForStorage, } from '@/lib/draft-upload-metadata'; +import { parseDraftLabelsFromRequestBody } from '@/lib/draft-labels'; +import { upsertDraftLabelsInLibrary } from '@/lib/repositories/users'; import type { ApiResponse, ApiError, Draft } from '@/types'; /** @@ -177,10 +179,8 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id return NextResponse.json(errRes, { status: 400 }); } - const { title, description, visibility, targets, platforms, tags, backupNaming } = body as Record< - string, - unknown - >; + const { title, description, visibility, targets, platforms, tags, labels, backupNaming } = + body as Record; // At least one field must be provided if ( @@ -190,12 +190,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id targets === undefined && platforms === undefined && tags === undefined && + labels === undefined && backupNaming === undefined ) { const errRes: ApiError = { error: 'Bad Request', message: - 'At least one field (title, description, visibility, targets, tags, platforms, backupNaming) must be provided', + 'At least one field (title, description, visibility, targets, tags, labels, platforms, backupNaming) must be provided', statusCode: 400, }; return NextResponse.json(errRes, { status: 400 }); @@ -267,6 +268,19 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id } } + let parsedLabels: ReturnType | undefined; + if (labels !== undefined) { + parsedLabels = parseDraftLabelsFromRequestBody(labels); + if (parsedLabels.ok === false) { + const errRes: ApiError = { + error: 'Bad Request', + message: parsedLabels.error, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + } + let platformsPatchParse: ReturnType | undefined; if (platforms !== undefined) { platformsPatchParse = parseDraftPlatformsPatchBody(platforms); @@ -325,6 +339,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id ...(isPlatformUploadVisibility(visibility) ? { visibility } : {}), ...(parsedTargets?.ok === true ? { targets: parsedTargets.value } : {}), ...(parsedTags?.ok === true ? { tags: parsedTags.value } : {}), + ...(parsedLabels?.ok === true ? { labels: parsedLabels.value } : {}), ...(platformsPatchParse?.ok === true ? { platformsPatch: platformsPatchParse.value } : {}), ...(backupNamingParse?.ok === true ? { backupNaming: backupNamingParse.value } : {}), }); @@ -334,6 +349,18 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id return NextResponse.json(errRes, { status: 404 }); } + if (parsedLabels?.ok === true && parsedLabels.value.length > 0) { + try { + await upsertDraftLabelsInLibrary(userId, parsedLabels.value); + } catch (libraryErr) { + // Best-effort: draft is already updated; avoid 500 misleading the client about PATCH success. + console.error( + '[PATCH /api/drafts/:id] Failed to upsert draft labels in library', + libraryErr + ); + } + } + // Presign only if updated.thumbnailR2Key passes isDraftThumbnailFinalKeyForUser (see helper). const data = await draftResponseWithThumbnailPreview(updated, userId, id); const response: ApiResponse = { diff --git a/app/api/drafts/[id]/youtube-import/discard/route.ts b/app/api/drafts/[id]/youtube-import/discard/route.ts new file mode 100644 index 00000000..90207441 --- /dev/null +++ b/app/api/drafts/[id]/youtube-import/discard/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { discardBlockingDraftYoutubeImport } from '@/lib/youtube-import/discard-draft-import'; +import type { ApiError } from '@/types'; + +/** + * Clears active, staged, or failed YouTube import state for a draft so the user can + * start over on the same draft. + * @param req - Incoming POST request. + * @param context - Route params containing the draft id. + * @returns Empty success response when discard completes. + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { id: draftId } = await context.params; + const draft = await getDraftById(draftId); + if (!draft) { + const errRes: ApiError = { + error: 'Not Found', + message: 'Draft not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + if (draft.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'Forbidden: you do not own this draft', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + try { + await discardBlockingDraftYoutubeImport(draftId, userId); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`[POST /api/drafts/${draftId}/youtube-import/discard]`, error); + const errRes: ApiError = { + error: 'Internal Server Error', + message, + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/api/drafts/[id]/youtube-import/route.ts b/app/api/drafts/[id]/youtube-import/route.ts new file mode 100644 index 00000000..d35c8f98 --- /dev/null +++ b/app/api/drafts/[id]/youtube-import/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { getYoutubeImportJobForDraftEditor } from '@/lib/repositories/youtube-import-jobs'; +import { scheduleYoutubeImportJob } from '@/lib/youtube-import/schedule-import-job'; +import type { ApiError, ApiResponse, YoutubeImportJob } from '@/types'; + +/** + * Returns the YouTube import job tied to a draft: in-flight work, or a completed + * import whose video is staged but not yet distributed. + * @param req - Incoming GET request. + * @param context - Route params containing the draft id. + * @returns Import job snapshot for the draft editor. + */ +export async function GET( + req: NextRequest, + context: { params: Promise<{ id: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { id: draftId } = await context.params; + const draft = await getDraftById(draftId); + if (!draft) { + const errRes: ApiError = { + error: 'Not Found', + message: 'Draft not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + if (draft.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'Forbidden: you do not own this draft', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + const job = await getYoutubeImportJobForDraftEditor(draftId); + + if (job?.status === 'pending') { + scheduleYoutubeImportJob(job.id, userId); + } + + const response: ApiResponse = { data: job }; + return NextResponse.json(response, { status: 200 }); +} diff --git a/app/api/drafts/labels/route.ts b/app/api/drafts/labels/route.ts new file mode 100644 index 00000000..5a1ca2b2 --- /dev/null +++ b/app/api/drafts/labels/route.ts @@ -0,0 +1,234 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { + draftLabelsRemovedFromLibrary, + filterDraftLabelSuggestions, + parseDraftLabelLibraryFromRequestBody, +} from '@/lib/draft-labels'; +import { removeLabelsFromAllDraftsForUser } from '@/lib/repositories/drafts'; +import { + getDraftLabelLibrary, + mergeDraftLabelsInLibrary, + setDraftLabelLibrary, + upsertDraftLabelsInLibrary, +} from '@/lib/repositories/users'; +import type { ApiError, ApiResponse, DraftLabelDefinition } from '@/types'; + +/** + * Maps repository "profile not found" errors to an HTTP 404 response. + * @param err - Caught error from a labels repository call. + * @returns A 404 JSON response, or null when the error is not a known not-found case. + */ +function draftLabelsRepositoryErrorResponse(err: unknown): NextResponse | null { + const repoErr = err as { code?: number; message?: string }; + if (repoErr?.code === 404) { + const errRes: ApiError = { + error: 'Not Found', + message: repoErr.message ?? 'User profile not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + return null; +} + +/** + * Returns saved draft label suggestions for autocomplete. + * @param req - Incoming GET request with optional `q` query filter. + * @returns Matching labels from the user's library. + */ +export async function GET(req: NextRequest) { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + try { + const library = await getDraftLabelLibrary(userId); + const query = req.nextUrl.searchParams.get('q') ?? undefined; + const data = query ? filterDraftLabelSuggestions(library, query) : library; + const res: ApiResponse = { data }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + const notFound = draftLabelsRepositoryErrorResponse(err); + if (notFound) return notFound; + console.error('[GET /api/drafts/labels]', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to load draft labels', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} + +/** + * Merges labels into the user's saved draft label library. + * Accepts `{ labels: string[] }` for name-only upserts or `{ labels: DraftLabelDefinition[] }` + * to update colors. + * @param req - Incoming POST request with a `labels` array. + * @returns Updated label library. + */ +export async function POST(req: NextRequest) { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + const errRes: ApiError = { + error: 'Bad Request', + message: 'Invalid JSON body', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + const errRes: ApiError = { + error: 'Bad Request', + message: 'Request body must be a JSON object', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + const rawLabels = (body as { labels?: unknown }).labels; + if (!Array.isArray(rawLabels)) { + const errRes: ApiError = { + error: 'Bad Request', + message: 'labels must be an array', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + try { + const allStrings = rawLabels.every((item) => typeof item === 'string'); + let data: DraftLabelDefinition[]; + + if (allStrings) { + const parsed = parseDraftLabelLibraryFromRequestBody(rawLabels); + if (parsed.ok === false) { + const errRes: ApiError = { + error: 'Bad Request', + message: parsed.error, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + const names = parsed.value.map((entry) => entry.name); + data = await upsertDraftLabelsInLibrary(userId, names); + } else { + const parsed = parseDraftLabelLibraryFromRequestBody(rawLabels); + if (parsed.ok === false) { + const errRes: ApiError = { + error: 'Bad Request', + message: parsed.error, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + data = await mergeDraftLabelsInLibrary(userId, parsed.value); + } + + const res: ApiResponse = { data, message: 'Draft labels updated' }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + const notFound = draftLabelsRepositoryErrorResponse(err); + if (notFound) return notFound; + console.error('[POST /api/drafts/labels]', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to update draft labels', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} + +/** + * Replaces the user's saved draft label library (settings management). + * Removed labels are stripped from every draft owned by the user. + * @param req - Incoming PUT request with `{ labels: DraftLabelDefinition[] }`. + * @returns Updated label library. + */ +export async function PUT(req: NextRequest) { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + const errRes: ApiError = { + error: 'Bad Request', + message: 'Invalid JSON body', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + const errRes: ApiError = { + error: 'Bad Request', + message: 'Request body must be a JSON object', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + const parsed = parseDraftLabelLibraryFromRequestBody((body as { labels?: unknown }).labels); + if (parsed.ok === false) { + const errRes: ApiError = { + error: 'Bad Request', + message: parsed.error, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + try { + const previous = await getDraftLabelLibrary(userId); + const removed = draftLabelsRemovedFromLibrary(previous, parsed.value); + + if (removed.length > 0) { + await removeLabelsFromAllDraftsForUser(userId, removed); + } + + const data = await setDraftLabelLibrary(userId, parsed.value); + + const res: ApiResponse = { data, message: 'Draft labels saved' }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + const notFound = draftLabelsRepositoryErrorResponse(err); + if (notFound) return notFound; + console.error('[PUT /api/drafts/labels]', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to save draft labels', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/api/drafts/route.ts b/app/api/drafts/route.ts index b38d5610..5c2e86c2 100644 --- a/app/api/drafts/route.ts +++ b/app/api/drafts/route.ts @@ -21,6 +21,8 @@ import { parseTagsFromRequestBody, resolveDraftTitleForStorage, } from '@/lib/draft-upload-metadata'; +import { parseDraftLabelsFromRequestBody } from '@/lib/draft-labels'; +import { upsertDraftLabelsInLibrary } from '@/lib/repositories/users'; import type { ApiResponse, ApiError, Draft } from '@/types'; const BACKFILL_SCAN_MAX_ROWS = 5000; @@ -105,8 +107,17 @@ export async function POST(req: NextRequest) { return NextResponse.json(errRes, { status: 400 }); } - const { title, description, visibility, targets, platforms, tags, minimal, backupNaming } = - body as Record; + const { + title, + description, + visibility, + targets, + platforms, + tags, + labels, + minimal, + backupNaming, + } = body as Record; const isMinimal = minimal === true; @@ -162,6 +173,16 @@ export async function POST(req: NextRequest) { return NextResponse.json(errRes, { status: 400 }); } + const labelsParse = parseDraftLabelsFromRequestBody(labels); + if (labelsParse.ok === false) { + const errRes: ApiError = { + error: 'Bad Request', + message: labelsParse.error, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + const backupNamingParse = parseBackupNamingFromRequestBody(backupNaming); if (backupNamingParse.ok === false) { const errRes: ApiError = { @@ -194,11 +215,21 @@ export async function POST(req: NextRequest) { title: trimmedTitle, description: (description as string | undefined) ?? '', tags: tagsParse.value, + labels: labelsParse.value, ...(isPlatformUploadVisibility(visibility) ? { visibility } : {}), platforms: platformsParse.value, backupNaming: backupNamingParse.value, }); + if (labelsParse.value.length > 0) { + try { + await upsertDraftLabelsInLibrary(userId, labelsParse.value); + } catch (libraryErr) { + // Best-effort: draft is already persisted; avoid 500 + client retries that duplicate drafts. + console.error('[POST /api/drafts] Failed to upsert draft labels in library', libraryErr); + } + } + const response: ApiResponse = { data: draft, message: 'Draft created' }; return NextResponse.json(response, { status: 201 }); } catch (err) { diff --git a/app/api/livestreams/route.ts b/app/api/livestreams/route.ts index e78a722b..46b35e23 100644 --- a/app/api/livestreams/route.ts +++ b/app/api/livestreams/route.ts @@ -21,6 +21,8 @@ import { persistUserYouTubePlatformDefaults } from '@/lib/platforms/youtube-user import { reconcileLivestreamsFromYouTubeForUser } from '@/lib/livestreams/reconcile-user-lifecycle'; import { createLivestream, + getStreamedLivestreamsPage, + getYoutubeImportLivestreamsPage, listLivestreamsByUser, LivestreamDocumentTooLargeError, } from '@/lib/repositories/livestreams'; @@ -28,6 +30,24 @@ import type { ApiResponse, ApiError, Livestream } from '@/types'; const SCHEDULE_ONLY_FIELDS = ['keySlot', 'status'] as const; +const DEFAULT_STREAMED_LIMIT = 20; +const MAX_STREAMED_LIMIT = 100; +const MIN_STREAMED_LIMIT = 1; + +function parseLimitParam(raw: string | null, fallback: number): number { + if (raw == null) return fallback; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return fallback; + return Math.min(MAX_STREAMED_LIMIT, Math.max(MIN_STREAMED_LIMIT, parsed)); +} + +function parseOffsetParam(raw: string | null): number { + if (raw == null) return 0; + const parsed = Number.parseInt(raw, 10); + if (Number.isNaN(parsed)) return 0; + return Math.max(0, parsed); +} + function rejectScheduleOnlyFields(body: Record): ApiError | null { for (const field of SCHEDULE_ONLY_FIELDS) { if (field in body) { @@ -244,7 +264,31 @@ export async function GET(req: NextRequest) { } try { + const { searchParams } = new URL(req.url); + const statusFilter = searchParams.get('status'); + + if (statusFilter === 'streamed') { + const limit = parseLimitParam(searchParams.get('limit'), DEFAULT_STREAMED_LIMIT); + const offset = parseOffsetParam(searchParams.get('offset')); + const forYoutubeImport = searchParams.get('for') === 'youtube-import'; + + // Reconcile once when opening history; skip on later pages to avoid repeated YouTube API load. + if (offset === 0) { + await reconcileLivestreamsFromYouTubeForUser(userId); + } + + const page = forYoutubeImport + ? await getYoutubeImportLivestreamsPage(userId, { limit, offset }) + : await getStreamedLivestreamsPage(userId, { limit, offset }); + const response = { + data: page.livestreams, + meta: { total: page.total, limit, offset }, + }; + return NextResponse.json(response); + } + await reconcileLivestreamsFromYouTubeForUser(userId); + const livestreams = await listLivestreamsByUser(userId); const response: ApiResponse = { data: livestreams }; return NextResponse.json(response); diff --git a/app/api/platforms/connections/route.ts b/app/api/platforms/connections/route.ts index 05fb888c..f91453ae 100644 --- a/app/api/platforms/connections/route.ts +++ b/app/api/platforms/connections/route.ts @@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthenticatedUserId } from '@/lib/api/auth'; -import { getConnectedAccountsByUser } from '@/lib/repositories/connected-accounts'; +import { getConnectedAccountsWithHealth } from '@/lib/platforms/connected-accounts-health'; import type { ApiResponse, ApiError, ConnectedAccountPublic } from '@/types'; /** @@ -31,7 +31,7 @@ export async function GET(req: NextRequest) { } try { - const accounts = await getConnectedAccountsByUser(userId); + const accounts = await getConnectedAccountsWithHealth(userId); const res: ApiResponse = { data: accounts }; return NextResponse.json(res, { status: 200 }); } catch (err) { diff --git a/app/api/platforms/sermon-audio/social-connections/route.ts b/app/api/platforms/sermon-audio/social-connections/route.ts new file mode 100644 index 00000000..f6eb1807 --- /dev/null +++ b/app/api/platforms/sermon-audio/social-connections/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireSermonAudioConnection } from '@/lib/platforms/sermon-audio-api'; +import { + SermonAudioUpstreamHttpError, + isSermonAudioCredentialsFailure, + sermonAudioUpstreamApiErrorLabel, + sermonAudioUpstreamResponseStatus, +} from '@/lib/platforms/sermon-audio-http'; +import { fetchSermonAudioCrossPublishSocialConnections } from '@/lib/platforms/sermon-audio-social-connections'; +import type { ApiError, ApiResponse } from '@/types'; +import type { SermonAudioCrossPublishSocialConnections } from '@/lib/platforms/sermon-audio-social-connections'; + +/** + * Returns SermonAudio Cross Publish OAuth connection status for the authenticated broadcaster. + * Proxies undocumented `POST /v2/node/broadcasters/{id}/refresh_social`. + * @param req - Incoming GET request. + * @returns JSON connection flags per Cross Publish destination, or a structured error. + */ +export async function GET(req: NextRequest) { + const connection = await requireSermonAudioConnection(req); + if (connection.ok === false) { + return connection.response; + } + + try { + const connections = await fetchSermonAudioCrossPublishSocialConnections( + connection.apiKey, + connection.broadcasterId + ); + const res: ApiResponse = { data: connections }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + if (err instanceof SermonAudioUpstreamHttpError) { + if (isSermonAudioCredentialsFailure(err.status)) { + const errRes: ApiError = { + error: 'Bad Request', + message: + 'SermonAudio API key is invalid or revoked. Reconnect SermonAudio in account settings.', + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); + } + + const status = sermonAudioUpstreamResponseStatus(err.status); + const errRes: ApiError = { + error: sermonAudioUpstreamApiErrorLabel(status), + message: 'SermonAudio is temporarily unavailable. Try again in a few minutes.', + statusCode: status, + }; + return NextResponse.json(errRes, { status }); + } + + console.error('[GET /api/platforms/sermon-audio/social-connections] Unexpected error:', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to load SermonAudio social connections', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/api/uploads/[jobId]/complete/route.ts b/app/api/uploads/[jobId]/complete/route.ts index 0adcc254..c88621ba 100644 --- a/app/api/uploads/[jobId]/complete/route.ts +++ b/app/api/uploads/[jobId]/complete/route.ts @@ -44,8 +44,12 @@ * oversized objects are deleted from R2 (server-side enforcement layer 2) */ -import { NextRequest, NextResponse, after } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { + finalizeUploadJobAndDistribute, + UploadJobFinalizeNotFoundError, +} from '@/lib/api/finalize-upload-job'; import { completeMultipartUpload, abortMultipartUpload, @@ -55,14 +59,6 @@ import { R2ObjectNotFoundError, } from '@/lib/r2'; import { getUploadJobById, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; -import { getDraftById } from '@/lib/repositories/drafts'; -import { buildMetadataForPlatform } from '@/lib/draft-upload-metadata'; -import { ensurePlatformUploadsForJobTargets } from '@/lib/repositories/platform-uploads'; -import { - distributeCreatePlatformUploadInput, - runDistributionInBackground, -} from '@/lib/api/distribute'; -import type { ConnectedAccountPlatform } from '@/types'; const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB in bytes @@ -302,47 +298,18 @@ export async function POST( ); } - // --- Auto-distribute to the draft's target platforms --- - if (!job.draftId) { - // No draft linked — just mark as uploading (manual distribute later). - await updateUploadJobStatus(jobId, 'uploading'); - return NextResponse.json({ success: true, distributing: false }, { status: 200 }); - } - - const draft = await getDraftById(job.draftId); - if (!draft || draft.targets.length === 0) { - // Draft missing or has no targets — advance to uploading only. - await updateUploadJobStatus(jobId, 'uploading'); - return NextResponse.json({ success: true, distributing: false }, { status: 200 }); - } - - const targetPlatforms = [...new Set(draft.targets)] as ConnectedAccountPlatform[]; - - // Create platform_upload rows before advancing to distributing so a failure - // here leaves the job in pending (retryable), not stuck in distributing. - const platformUploads = await ensurePlatformUploadsForJobTargets( - targetPlatforms.map((platform) => distributeCreatePlatformUploadInput(jobId, draft, platform)) - ); - - const updated = await updateUploadJobStatus(jobId, 'distributing', null); - if (!updated) { - return NextResponse.json( - { error: 'Upload job no longer exists and could not be finalized' }, - { status: 404 } - ); - } - - const metadataByPlatformId = new Map>(); - for (const pu of platformUploads) { - metadataByPlatformId.set(pu.id, buildMetadataForPlatform(draft, pu.platform)); + try { + const { distributing } = await finalizeUploadJobAndDistribute(jobId, userId); + return NextResponse.json({ success: true, distributing }, { status: 200 }); + } catch (err) { + if (err instanceof UploadJobFinalizeNotFoundError) { + return NextResponse.json( + { error: 'Upload job no longer exists and could not be finalized' }, + { status: 404 } + ); + } + throw err; } - - // Schedule background distribution (runs after the response is sent). - after(() => - runDistributionInBackground(jobId, userId, job.r2Key!, platformUploads, metadataByPlatformId) - ); - - return NextResponse.json({ success: true, distributing: true }, { status: 200 }); } catch (error) { console.error('Upload complete error:', error); return NextResponse.json( diff --git a/app/api/youtube-import/[jobId]/cancel/route.ts b/app/api/youtube-import/[jobId]/cancel/route.ts new file mode 100644 index 00000000..cf854e2e --- /dev/null +++ b/app/api/youtube-import/[jobId]/cancel/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { + getYoutubeImportJobById, + updateYoutubeImportJobStatus, +} from '@/lib/repositories/youtube-import-jobs'; +import type { ApiError, YoutubeImportJobStatus } from '@/types'; + +const TERMINAL_YOUTUBE_IMPORT_STATUSES: readonly YoutubeImportJobStatus[] = [ + 'completed', + 'failed', + 'cancelled', +]; + +/** + * Cancels an in-progress YouTube import job. + * @param req - Incoming POST request. + * @param context - Route params containing the import job id. + * @returns Empty success response when cancellation is recorded. + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ jobId: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { jobId } = await context.params; + const job = await getYoutubeImportJobById(jobId); + + if (!job) { + const errRes: ApiError = { + error: 'Not Found', + message: 'YouTube import job not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + if (job.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'You do not have access to this import job', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + if (TERMINAL_YOUTUBE_IMPORT_STATUSES.includes(job.status)) { + const errRes: ApiError = { + error: 'Conflict', + message: `Import job is already in '${job.status}' state and cannot be cancelled`, + statusCode: 409, + }; + return NextResponse.json(errRes, { status: 409 }); + } + + await updateYoutubeImportJobStatus(jobId, { status: 'cancelled', errorMessage: null }); + + return NextResponse.json({ success: true }, { status: 200 }); +} diff --git a/app/api/youtube-import/[jobId]/queue-distribute/route.ts b/app/api/youtube-import/[jobId]/queue-distribute/route.ts new file mode 100644 index 00000000..27b2bd97 --- /dev/null +++ b/app/api/youtube-import/[jobId]/queue-distribute/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getYoutubeImportJobById } from '@/lib/repositories/youtube-import-jobs'; +import { queueYoutubeImportDistribute } from '@/lib/youtube-import/queue-import-distribute'; +import type { ApiError, ApiResponse, YoutubeImportJob } from '@/types'; + +/** + * Queues platform distribution for a YouTube import job. When staging has already + * finished, distribution starts immediately; otherwise it runs when import completes. + * @param req - Incoming POST request. + * @param context - Route params containing the import job id. + * @returns Updated import job row. + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ jobId: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { jobId } = await context.params; + + try { + const job = await queueYoutubeImportDistribute(jobId, userId); + const response: ApiResponse = { data: job }; + return NextResponse.json(response, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + if (message === 'YouTube import job not found') { + const errRes: ApiError = { + error: 'Not Found', + message, + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + if (message === 'You do not have access to this import job') { + const errRes: ApiError = { + error: 'Forbidden', + message, + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + if ( + message === 'This import job can no longer be uploaded' || + message === 'Import job is not ready to upload' + ) { + const errRes: ApiError = { + error: 'Conflict', + message, + statusCode: 409, + }; + return NextResponse.json(errRes, { status: 409 }); + } + + console.error(`[POST /api/youtube-import/${jobId}/queue-distribute]`, error); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to queue YouTube import upload', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} + +/** + * Returns the current import job row (for polling queue state). + * @param req - Incoming GET request. + * @param context - Route params containing the import job id. + * @returns Import job snapshot. + */ +export async function GET( + req: NextRequest, + context: { params: Promise<{ jobId: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { jobId } = await context.params; + const job = await getYoutubeImportJobById(jobId); + if (!job) { + const errRes: ApiError = { + error: 'Not Found', + message: 'YouTube import job not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + if (job.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'You do not have access to this import job', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + const response: ApiResponse = { data: job }; + return NextResponse.json(response, { status: 200 }); +} diff --git a/app/api/youtube-import/[jobId]/route.ts b/app/api/youtube-import/[jobId]/route.ts new file mode 100644 index 00000000..c881bcbd --- /dev/null +++ b/app/api/youtube-import/[jobId]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getYoutubeImportJobById } from '@/lib/repositories/youtube-import-jobs'; +import type { ApiError, ApiResponse, YoutubeImportJob } from '@/types'; + +/** + * Returns the current status of a YouTube import job for UI polling. + * @param req - Incoming GET request. + * @param context - Route params containing the import job id. + * @returns Import job status and progress fields. + */ +export async function GET( + req: NextRequest, + context: { params: Promise<{ jobId: string }> } +): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const { jobId } = await context.params; + const job = await getYoutubeImportJobById(jobId); + + if (!job) { + const errRes: ApiError = { + error: 'Not Found', + message: 'YouTube import job not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + if (job.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'You do not have access to this import job', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + const res: ApiResponse = { data: job }; + return NextResponse.json(res, { status: 200 }); +} diff --git a/app/api/youtube-import/[jobId]/run/route.ts b/app/api/youtube-import/[jobId]/run/route.ts new file mode 100644 index 00000000..ea80e463 --- /dev/null +++ b/app/api/youtube-import/[jobId]/run/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { executeYoutubeImportJobWorker } from '@/lib/youtube-import/execute-import-job'; +import { getYoutubeImportJobById } from '@/lib/repositories/youtube-import-jobs'; +import type { ApiError } from '@/types'; + +/** Allow long downloads/trims in hosted environments that honor route segment config. */ +export const maxDuration = 3600; + +/** + * Executes a pending YouTube import job for the authenticated owner. Prefer + * server-side scheduling from `/start`; this route remains for manual resume. + * @param req - Incoming POST request. + * @param context - Route params containing the import job id. + * @returns Terminal job row when the worker ran; 409 when already active. + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ jobId: string }> } +): Promise { + const { jobId } = await context.params; + const job = await getYoutubeImportJobById(jobId); + + if (!job) { + const errRes: ApiError = { + error: 'Not Found', + message: 'YouTube import job not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + const userId = await getAuthenticatedUserId(req); + if (userId == null || job.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'You do not have access to run this import job', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + try { + const result = await executeYoutubeImportJobWorker(jobId, userId); + + if (result.outcome === 'not_found') { + const errRes: ApiError = { + error: 'Not Found', + message: 'YouTube import job not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + if (result.outcome === 'already_running') { + return NextResponse.json({ accepted: false, status: result.status }, { status: 409 }); + } + + const finished = await getYoutubeImportJobById(jobId); + return NextResponse.json({ data: finished }, { status: 200 }); + } catch (error) { + console.error(`[POST /api/youtube-import/${jobId}/run] Import worker failed:`, error); + const message = error instanceof Error ? error.message : String(error); + const errRes: ApiError = { + error: 'Internal Server Error', + message, + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/api/youtube-import/active/route.ts b/app/api/youtube-import/active/route.ts new file mode 100644 index 00000000..65f11ec2 --- /dev/null +++ b/app/api/youtube-import/active/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getActiveYoutubeImportJobForUser } from '@/lib/repositories/youtube-import-jobs'; +import { scheduleYoutubeImportJob } from '@/lib/youtube-import/schedule-import-job'; +import type { ApiError } from '@/types'; + +/** + * Returns the authenticated user's active YouTube import job, if any. + * @param req - Incoming GET request. + * @returns Active import job or `{ job: null }`. + */ +export async function GET(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const job = await getActiveYoutubeImportJobForUser(userId); + + if (job?.status === 'pending') { + scheduleYoutubeImportJob(job.id, userId); + } + + return NextResponse.json({ job }, { status: 200 }); +} diff --git a/app/api/youtube-import/keyframes/route.ts b/app/api/youtube-import/keyframes/route.ts new file mode 100644 index 00000000..677f3da5 --- /dev/null +++ b/app/api/youtube-import/keyframes/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getDirectMediaUrl, probeNearbyKeyframes } from '@/lib/youtube-import/probe-keyframes'; +import type { ApiError, ApiResponse } from '@/types'; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; + +function badRequest(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Request', + message, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); +} + +function badGateway(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Gateway', + message, + statusCode: 502, + }; + return NextResponse.json(errRes, { status: 502 }); +} + +/** + * Returns nearby keyframe timestamps for a YouTube import trim slider. + * @param req - Incoming GET request with `youtubeVideoId` and `near` query params. + * @returns Keyframe timestamps near the requested scrub position. + */ +export async function GET(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const youtubeVideoId = req.nextUrl.searchParams.get('youtubeVideoId')?.trim() ?? ''; + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + return badRequest('youtubeVideoId must be a valid 11-character YouTube video id'); + } + + const nearRaw = req.nextUrl.searchParams.get('near'); + if (nearRaw == null || nearRaw.trim() === '') { + return badRequest('near is required'); + } + + const nearSeconds = Number(nearRaw); + if (!Number.isFinite(nearSeconds) || nearSeconds < 0) { + return badRequest('near must be a non-negative number'); + } + + try { + const { url } = await getDirectMediaUrl(youtubeVideoId); + const keyframeSeconds = await probeNearbyKeyframes(url, nearSeconds); + + const res: ApiResponse<{ keyframeSeconds: number[] }> = { data: { keyframeSeconds } }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + const message = + err instanceof Error && err.message.trim() !== '' + ? err.message.trim() + : 'Failed to probe nearby keyframes'; + return badGateway(message); + } +} diff --git a/app/api/youtube-import/preview/route.ts b/app/api/youtube-import/preview/route.ts new file mode 100644 index 00000000..9a054331 --- /dev/null +++ b/app/api/youtube-import/preview/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { + buildYoutubeImportPreviewStreamPath, + resolvePreviewDirectMediaUrl, +} from '@/lib/youtube-import/preview-media-url'; +import type { ApiError, ApiResponse } from '@/types'; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; + +function badRequest(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Request', + message, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); +} + +function badGateway(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Gateway', + message, + statusCode: 502, + }; + return NextResponse.json(errRes, { status: 502 }); +} + +/** + * Returns preview stream metadata for the import trim editor. + * @param req - Incoming GET request with `youtubeVideoId`. + * @returns Same-origin stream URL and upstream expiry timestamp. + */ +export async function GET(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const youtubeVideoId = req.nextUrl.searchParams.get('youtubeVideoId')?.trim() ?? ''; + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + return badRequest('youtubeVideoId must be a valid 11-character YouTube video id'); + } + + const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1'; + + try { + const { expiresAt } = await resolvePreviewDirectMediaUrl(userId, youtubeVideoId, { + forceRefresh, + }); + + const res: ApiResponse<{ streamUrl: string; expiresAt: number }> = { + data: { + streamUrl: buildYoutubeImportPreviewStreamPath(youtubeVideoId), + expiresAt, + }, + }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + const message = + err instanceof Error && err.message.trim() !== '' + ? err.message.trim() + : 'Failed to resolve preview media'; + return badGateway(message); + } +} diff --git a/app/api/youtube-import/preview/stream/route.ts b/app/api/youtube-import/preview/stream/route.ts new file mode 100644 index 00000000..9dfeccf6 --- /dev/null +++ b/app/api/youtube-import/preview/stream/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { fetchProxiedPreviewMedia } from '@/lib/youtube-import/proxy-preview-media'; +import { resolvePreviewDirectMediaUrl } from '@/lib/youtube-import/preview-media-url'; +import type { ApiError } from '@/types'; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; + +function badRequest(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Request', + message, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); +} + +function badGateway(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Gateway', + message, + statusCode: 502, + }; + return NextResponse.json(errRes, { status: 502 }); +} + +/** + * Streams proxied preview media for the import trim editor with HTTP Range support. + * @param req - Incoming GET request with `youtubeVideoId` and optional `refresh=1`. + * @returns Proxied media bytes from the YouTube CDN. + */ +export async function GET(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const youtubeVideoId = req.nextUrl.searchParams.get('youtubeVideoId')?.trim() ?? ''; + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + return badRequest('youtubeVideoId must be a valid 11-character YouTube video id'); + } + + const forceRefresh = req.nextUrl.searchParams.get('refresh') === '1'; + + try { + const { url } = await resolvePreviewDirectMediaUrl(userId, youtubeVideoId, { + forceRefresh, + }); + const rangeHeader = req.headers.get('range'); + return await fetchProxiedPreviewMedia(url, rangeHeader); + } catch (err) { + const message = + err instanceof Error && err.message.trim() !== '' + ? err.message.trim() + : 'Failed to stream preview media'; + return badGateway(message); + } +} diff --git a/app/api/youtube-import/resolve/route.ts b/app/api/youtube-import/resolve/route.ts new file mode 100644 index 00000000..f87a36b0 --- /dev/null +++ b/app/api/youtube-import/resolve/route.ts @@ -0,0 +1,182 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { + requireYouTubeConnection, + youtubeUpstreamErrorResponse, +} from '@/lib/platforms/youtube-api'; +import { getLivestreamById } from '@/lib/repositories/livestreams'; +import { + extractYouTubeVideoId, + fetchYouTubeVideoForImport, + mapYouTubeImportResolvedSource, + type YouTubeImportResolvedSource, +} from '@/lib/youtube-import/resolve-source'; +import { + buildYoutubeImportPreviewStreamPath, + resolvePreviewDirectMediaUrl, +} from '@/lib/youtube-import/preview-media-url'; +import type { ApiError, ApiResponse } from '@/types'; + +interface ResolveYouTubeImportRequestBody { + sourceUrl?: string; + livestreamId?: string; +} + +function badRequest(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Request', + message, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); +} + +/** + * Validates the resolve request body and returns exactly one source selector. + * @param body - Parsed JSON request body. + * @returns Normalized source fields or a 400 response. + */ +function parseResolveRequestBody( + body: unknown +): { ok: true; sourceUrl?: string; livestreamId?: string } | { ok: false; response: NextResponse } { + if (typeof body !== 'object' || body === null) { + return { ok: false, response: badRequest('Request body must be a JSON object') }; + } + + const req = body as ResolveYouTubeImportRequestBody; + const sourceUrl = typeof req.sourceUrl === 'string' ? req.sourceUrl.trim() : ''; + const livestreamId = typeof req.livestreamId === 'string' ? req.livestreamId.trim() : ''; + const hasSourceUrl = sourceUrl.length > 0; + const hasLivestreamId = livestreamId.length > 0; + + if (hasSourceUrl === hasLivestreamId) { + return { + ok: false, + response: badRequest('Provide exactly one of sourceUrl or livestreamId'), + }; + } + + return { + ok: true, + ...(hasSourceUrl ? { sourceUrl } : {}), + ...(hasLivestreamId ? { livestreamId } : {}), + }; +} + +/** + * Resolves a pasted YouTube URL or past livestream id to import metadata. + * @param req - Incoming POST request. + * @returns Resolved video metadata or a structured error. + */ +export async function POST(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + const connection = await requireYouTubeConnection(req); + if (connection.ok === false) { + return connection.response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequest('Invalid JSON in request body'); + } + + const parsedBody = parseResolveRequestBody(body); + if (parsedBody.ok === false) { + return parsedBody.response; + } + + let youtubeVideoId: string | null = null; + + if (parsedBody.livestreamId) { + const livestream = await getLivestreamById(parsedBody.livestreamId); + if (!livestream) { + const errRes: ApiError = { + error: 'Not Found', + message: 'Livestream not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + + if (livestream.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'You do not have access to this livestream', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + const broadcastId = livestream.youtubeBroadcastId?.trim() ?? ''; + if (!broadcastId) { + return badRequest('This livestream does not have a linked YouTube broadcast'); + } + + youtubeVideoId = broadcastId; + } else { + youtubeVideoId = extractYouTubeVideoId(parsedBody.sourceUrl ?? ''); + if (!youtubeVideoId) { + return badRequest('Could not parse a YouTube video id from sourceUrl'); + } + } + + try { + const videoResult = await fetchYouTubeVideoForImport( + connection.accessToken, + youtubeVideoId, + req.signal + ); + + if (videoResult.ok === false) { + if (videoResult.notFound) { + return badRequest(videoResult.details); + } + return youtubeUpstreamErrorResponse(videoResult.details); + } + + const mapped = mapYouTubeImportResolvedSource(videoResult.item); + if (mapped.ok === false) { + return badRequest(mapped.message); + } + + let previewExpiresAt: number; + try { + const previewMedia = await resolvePreviewDirectMediaUrl(userId, mapped.data.youtubeVideoId); + previewExpiresAt = previewMedia.expiresAt; + } catch (err) { + const message = + err instanceof Error && err.message.trim() !== '' + ? err.message.trim() + : 'Failed to resolve preview media'; + return youtubeUpstreamErrorResponse(message); + } + + const res: ApiResponse = { + data: { + ...mapped.data, + previewStreamUrl: buildYoutubeImportPreviewStreamPath(mapped.data.youtubeVideoId), + previewExpiresAt, + }, + }; + return NextResponse.json(res, { status: 200 }); + } catch (err) { + console.error('[POST /api/youtube-import/resolve] Unexpected error:', err); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to resolve YouTube import source', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/api/youtube-import/start/route.ts b/app/api/youtube-import/start/route.ts new file mode 100644 index 00000000..62a1b71d --- /dev/null +++ b/app/api/youtube-import/start/route.ts @@ -0,0 +1,208 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUserId } from '@/lib/api/auth'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { discardBlockingDraftYoutubeImport } from '@/lib/youtube-import/discard-draft-import'; +import { + YoutubeImportJobAlreadyActiveError, + createYoutubeImportJob, + getActiveYoutubeImportJobForUser, +} from '@/lib/repositories/youtube-import-jobs'; +import { + buildYouTubeWatchUrl, + extractYouTubeVideoId, + getYouTubeImportMaxDurationSeconds, +} from '@/lib/youtube-import/resolve-source'; +import { scheduleYoutubeImportJob } from '@/lib/youtube-import/schedule-import-job'; +import type { ApiError } from '@/types'; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; + +interface StartYoutubeImportRequestBody { + draftId: string; + youtubeVideoId: string; + livestreamId?: string; + sourceUrl?: string; + startSeconds: number; + endSeconds: number; +} + +function badRequest(message: string): NextResponse { + const errRes: ApiError = { + error: 'Bad Request', + message, + statusCode: 400, + }; + return NextResponse.json(errRes, { status: 400 }); +} + +/** + * Validates the start-import request body. + * @param body - Parsed JSON request body. + * @returns Normalized fields or a 400 response. + */ +function parseStartRequestBody(body: unknown): + | { + ok: true; + data: { + draftId: string; + youtubeVideoId: string; + livestreamId: string; + sourceUrl: string; + startSeconds: number; + endSeconds: number; + }; + } + | { ok: false; response: NextResponse } { + if (typeof body !== 'object' || body === null) { + return { ok: false, response: badRequest('Request body must be a JSON object') }; + } + + const req = body as StartYoutubeImportRequestBody; + const draftId = typeof req.draftId === 'string' ? req.draftId.trim() : ''; + const youtubeVideoId = typeof req.youtubeVideoId === 'string' ? req.youtubeVideoId.trim() : ''; + const livestreamId = typeof req.livestreamId === 'string' ? req.livestreamId.trim() : ''; + const sourceUrlRaw = typeof req.sourceUrl === 'string' ? req.sourceUrl.trim() : ''; + const startSeconds = req.startSeconds; + const endSeconds = req.endSeconds; + + if (!draftId) { + return { ok: false, response: badRequest('draftId is required') }; + } + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + return { ok: false, response: badRequest('youtubeVideoId must be a valid 11-character id') }; + } + if (!Number.isFinite(startSeconds) || startSeconds < 0) { + return { ok: false, response: badRequest('startSeconds must be a non-negative number') }; + } + if (!Number.isFinite(endSeconds) || endSeconds < 0) { + return { ok: false, response: badRequest('endSeconds must be a non-negative number') }; + } + if (endSeconds <= startSeconds) { + return { ok: false, response: badRequest('endSeconds must be greater than startSeconds') }; + } + + const clipDurationSeconds = endSeconds - startSeconds; + const maxClipDurationSeconds = getYouTubeImportMaxDurationSeconds(); + if (clipDurationSeconds > maxClipDurationSeconds) { + return { + ok: false, + response: badRequest(`Clip length exceeds the maximum of ${maxClipDurationSeconds} seconds`), + }; + } + + const sourceUrl = sourceUrlRaw || buildYouTubeWatchUrl(youtubeVideoId); + + if (sourceUrlRaw) { + const sourceVideoId = extractYouTubeVideoId(sourceUrlRaw); + if (!sourceVideoId) { + return { + ok: false, + response: badRequest('sourceUrl must be a valid YouTube URL or video id'), + }; + } + if (sourceVideoId !== youtubeVideoId) { + return { + ok: false, + response: badRequest('sourceUrl does not match youtubeVideoId'), + }; + } + } + + return { + ok: true, + data: { + draftId, + youtubeVideoId, + livestreamId, + sourceUrl, + startSeconds, + endSeconds, + }, + }; +} + +/** + * Starts a background YouTube import/trim job for the authenticated user. + * @param req - Incoming POST request. + * @returns Created import job id. + */ +export async function POST(req: NextRequest): Promise { + const userId = await getAuthenticatedUserId(req); + if (!userId) { + const errRes: ApiError = { + error: 'Unauthorized', + message: 'Not authenticated', + statusCode: 401, + }; + return NextResponse.json(errRes, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return badRequest('Invalid JSON in request body'); + } + + const parsed = parseStartRequestBody(body); + if (parsed.ok === false) { + return parsed.response; + } + + const draft = await getDraftById(parsed.data.draftId); + if (!draft) { + const errRes: ApiError = { + error: 'Not Found', + message: 'Draft not found', + statusCode: 404, + }; + return NextResponse.json(errRes, { status: 404 }); + } + if (draft.userId !== userId) { + const errRes: ApiError = { + error: 'Forbidden', + message: 'Forbidden: you do not own this draft', + statusCode: 403, + }; + return NextResponse.json(errRes, { status: 403 }); + } + + try { + await discardBlockingDraftYoutubeImport(parsed.data.draftId, userId); + + const job = await createYoutubeImportJob({ + userId, + draftId: parsed.data.draftId, + sourceUrl: parsed.data.sourceUrl, + youtubeVideoId: parsed.data.youtubeVideoId, + livestreamId: parsed.data.livestreamId || undefined, + startSeconds: parsed.data.startSeconds, + endSeconds: parsed.data.endSeconds, + }); + + scheduleYoutubeImportJob(job.id, userId); + + return NextResponse.json({ jobId: job.id }, { status: 201 }); + } catch (error) { + if (error instanceof YoutubeImportJobAlreadyActiveError) { + const activeJob = await getActiveYoutubeImportJobForUser(userId); + return NextResponse.json( + { + error: 'Conflict', + message: 'You already have an import in progress', + statusCode: 409, + activeJobId: activeJob?.id ?? null, + }, + { status: 409 } + ); + } + + console.error('[POST /api/youtube-import/start] Unexpected error:', error); + const errRes: ApiError = { + error: 'Internal Server Error', + message: 'Failed to start YouTube import job', + statusCode: 500, + }; + return NextResponse.json(errRes, { status: 500 }); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index c9965ffb..70c03a83 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,6 +23,7 @@ const inter = localFont({ src: [{ path: './fonts/InterVariable.woff2', weight: '100 900', style: 'normal' }], variable: '--font-inter', display: 'swap', + preload: false, }); const apfelGrotezk = localFont({ @@ -35,6 +36,7 @@ const apfelGrotezk = localFont({ ], variable: '--font-apfel-grotezk', display: 'swap', + preload: false, }); // --- Metadata --- @@ -58,6 +60,7 @@ export const metadata: Metadata = { apple: '/apple-touch-icon.png', }, manifest: '/site.webmanifest', + referrer: 'strict-origin-when-cross-origin', }; /** diff --git a/components/dashboard/DashboardNavProvider.tsx b/components/dashboard/DashboardNavProvider.tsx new file mode 100644 index 00000000..0cff9e88 --- /dev/null +++ b/components/dashboard/DashboardNavProvider.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; +import { usePathname } from 'next/navigation'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + DASHBOARD_ADMIN_NAV_ITEMS, + DASHBOARD_NAV_ITEMS, + DashboardNavListContainer, + resolveActiveDashboardNavHref, +} from '@/components/dashboard/dashboard-nav-shared'; + +interface DashboardNavContextValue { + /** Opens the mobile dashboard navigation drawer. */ + openMobileNav: () => void; + /** Closes the mobile dashboard navigation drawer. */ + closeMobileNav: () => void; + /** Whether the mobile drawer is currently open. */ + mobileNavOpen: boolean; +} + +const DashboardNavContext = createContext(null); + +/** + * Returns dashboard nav drawer controls when rendered inside {@link DashboardNavProvider}. + * @returns Drawer controls, or null outside the dashboard layout. + */ +export function useDashboardNav(): DashboardNavContextValue | null { + return useContext(DashboardNavContext); +} + +interface DashboardNavProviderProps { + children: ReactNode; + isAdmin?: boolean; +} + +/** + * Provides mobile dashboard drawer state and renders the slide-over nav on small screens. + * @param props - Provider props. + * @returns Provider wrapper with mobile drawer portal. + */ +export function DashboardNavProvider({ children, isAdmin = false }: DashboardNavProviderProps) { + const pathname = usePathname(); + const navItems = useMemo( + () => (isAdmin ? [...DASHBOARD_NAV_ITEMS, ...DASHBOARD_ADMIN_NAV_ITEMS] : DASHBOARD_NAV_ITEMS), + [isAdmin] + ); + const activeHref = resolveActiveDashboardNavHref(pathname, navItems); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [mobileNavPathname, setMobileNavPathname] = useState(null); + const isMobileNavOpen = mobileNavOpen && mobileNavPathname === pathname; + + const openMobileNav = useCallback(() => { + setMobileNavOpen(true); + setMobileNavPathname(pathname); + }, [pathname]); + + const closeMobileNav = useCallback(() => { + setMobileNavOpen(false); + setMobileNavPathname(null); + }, []); + + const handleMobileNavOpenChange = useCallback( + (open: boolean) => { + if (open) { + openMobileNav(); + } else { + closeMobileNav(); + } + }, + [closeMobileNav, openMobileNav] + ); + + const contextValue = useMemo( + () => ({ + openMobileNav, + closeMobileNav, + mobileNavOpen: isMobileNavOpen, + }), + [closeMobileNav, isMobileNavOpen, openMobileNav] + ); + + const drawerLinkClassName = + 'flex items-center border-l-2 px-4 py-2.5 text-base transition-colors rounded-r-md'; + const activeClassName = 'border-primary bg-primary/10 font-extrabold text-primary'; + const inactiveClassName = + 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'; + + return ( + + {children} + + + + Dashboard + Jump to a dashboard section. + + + + + + ); +} diff --git a/components/dashboard/DashboardQuickActions.tsx b/components/dashboard/DashboardQuickActions.tsx index 71f39479..585ed655 100644 --- a/components/dashboard/DashboardQuickActions.tsx +++ b/components/dashboard/DashboardQuickActions.tsx @@ -36,7 +36,7 @@ export function DashboardQuickActions() { throw new Error('Failed to create draft'); } - router.push(`/dashboard/drafts?createDraftId=${draft.id}`); + router.push(`/dashboard/uploads?createDraftId=${draft.id}`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Failed to create draft'); } finally { @@ -63,7 +63,7 @@ export function DashboardQuickActions() { {isCreatingDraft ? 'Creating…' : 'New draft'} diff --git a/components/dashboard/DashboardShell.tsx b/components/dashboard/DashboardShell.tsx index bfe0e15c..6c3cbd8a 100644 --- a/components/dashboard/DashboardShell.tsx +++ b/components/dashboard/DashboardShell.tsx @@ -1,34 +1,21 @@ 'use client'; -import Link from 'next/link'; +import { useMemo } from 'react'; import { usePathname } from 'next/navigation'; - -const NAV_ITEMS = [ - { label: 'Dashboard', href: '/dashboard', exact: true }, - { - label: 'Drafts', - href: '/dashboard/drafts', - exact: false, - tourIdDesktop: 'drafts-nav-link-desktop', - tourIdMobile: 'drafts-nav-link-mobile', - }, - { label: 'Livestreams', href: '/dashboard/livestreams', exact: false }, - { label: 'History', href: '/dashboard/history', exact: false }, -] as const; - -const ADMIN_NAV_ITEMS = [{ label: 'Users', href: '/dashboard/users', exact: true }] as const; - -function isActive(pathname: string, href: string, exact: boolean): boolean { - return exact ? pathname === href : pathname.startsWith(href); -} +import { + DASHBOARD_ADMIN_NAV_ITEMS, + DASHBOARD_NAV_ITEMS, + DashboardNavListContainer, + resolveActiveDashboardNavHref, +} from '@/components/dashboard/dashboard-nav-shared'; // ============================================================================= // DASHBOARD SHELL // ============================================================================= -// Client component providing responsive sidebar + mobile tab navigation shell. +// Client component providing responsive dashboard navigation shell. // // Desktop (≥ md): sticky left sidebar (w-56) with vertical nav. -// Mobile (< md): horizontally-scrollable tab bar above page content. +// Mobile (< md): drawer opened from the navbar menu button (see DashboardNavProvider). // // Active route is highlighted via usePathname(). // Rendered inside template.tsx so Navbar/Footer wrap the entire dashboard. @@ -46,76 +33,38 @@ export default function DashboardShell({ isAdmin?: boolean; }) { const pathname = usePathname(); - const navItems = isAdmin ? [...NAV_ITEMS, ...ADMIN_NAV_ITEMS] : NAV_ITEMS; + const navItems = useMemo( + () => (isAdmin ? [...DASHBOARD_NAV_ITEMS, ...DASHBOARD_ADMIN_NAV_ITEMS] : DASHBOARD_NAV_ITEMS), + [isAdmin] + ); + const activeHref = resolveActiveDashboardNavHref(pathname, navItems); + + const desktopLinkClassName = + 'flex items-center border-l-2 px-4 py-2 text-lg transition-colors rounded-r-md'; + const desktopActiveClassName = 'border-primary bg-primary/10 font-extrabold text-primary'; + const desktopInactiveClassName = + 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'; return (
- {/* ------------------------------------------------------------------ */} - {/* Desktop sidebar — hidden on mobile */} - {/* Outer aside stretches full page height so border-r reaches footer. */} - {/* Inner div is sticky so the nav stays in view while scrolling. */} - {/* ------------------------------------------------------------------ */} - {/* ------------------------------------------------------------------ */} - {/* Content column (contains mobile tabs + page content) */} - {/* ------------------------------------------------------------------ */}
- {/* Mobile tab bar — hidden on desktop */} - - - {/* Page content */}
{children}
diff --git a/components/dashboard/dashboard-nav-shared.tsx b/components/dashboard/dashboard-nav-shared.tsx new file mode 100644 index 00000000..914689df --- /dev/null +++ b/components/dashboard/dashboard-nav-shared.tsx @@ -0,0 +1,349 @@ +'use client'; + +import Link from 'next/link'; +import { ChevronDown } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; + +/** Child link nested under a dashboard nav section. */ +export interface DashboardNavChildItem { + label: string; + href: string; + exact?: boolean; +} + +/** Top-level dashboard sidebar / drawer link. */ +export interface DashboardNavItem { + label: string; + href: string; + exact?: boolean; + tourIdDesktop?: string; + tourIdMobile?: string; + children?: DashboardNavChildItem[]; +} + +/** Primary dashboard navigation tree (admin items appended at runtime). */ +export const DASHBOARD_NAV_ITEMS: DashboardNavItem[] = [ + { label: 'Dashboard', href: '/dashboard', exact: true }, + { + label: 'Uploads', + href: '/dashboard/uploads', + exact: false, + tourIdDesktop: 'uploads-nav-link-desktop', + tourIdMobile: 'uploads-nav-link-mobile', + children: [{ label: 'History', href: '/dashboard/uploads/history', exact: false }], + }, + { + label: 'Livestreams', + href: '/dashboard/livestreams', + exact: false, + children: [{ label: 'History', href: '/dashboard/livestreams/history', exact: false }], + }, +]; + +/** Admin-only dashboard nav link. */ +export const DASHBOARD_ADMIN_NAV_ITEMS: DashboardNavItem[] = [ + { label: 'Users', href: '/dashboard/users', exact: true }, +]; + +/** + * Two-line menu icon for the dashboard drawer trigger (long top line, short bottom line). + * @returns SVG icon element. + */ +export function DashboardNavMenuIcon() { + return ( + + + + + ); +} + +function isActive(pathname: string, href: string, exact: boolean): boolean { + return exact ? pathname === href : pathname.startsWith(href); +} + +function isChildActive(pathname: string, child: DashboardNavChildItem): boolean { + return isActive(pathname, child.href, child.exact ?? false); +} + +/** + * Returns whether a nav parent or any of its children matches the current route. + * @param pathname - Current app pathname. + * @param item - Nav item to evaluate. + * @returns True when the parent section should appear active. + */ +export function isDashboardNavParentActive(pathname: string, item: DashboardNavItem): boolean { + if (isActive(pathname, item.href, item.exact ?? false)) { + return true; + } + return item.children?.some((child) => isChildActive(pathname, child)) ?? false; +} + +/** + * Resolves the most specific active dashboard nav href for highlighting. + * @param pathname - Current app pathname. + * @param items - Nav items to search. + * @returns Best-matching href, if any. + */ +export function resolveActiveDashboardNavHref( + pathname: string, + items: DashboardNavItem[] +): string | null { + let bestMatch: { href: string; length: number } | null = null; + + for (const item of items) { + for (const child of item.children ?? []) { + if (isChildActive(pathname, child)) { + if (!bestMatch || child.href.length > bestMatch.length) { + bestMatch = { href: child.href, length: child.href.length }; + } + } + } + + if (isActive(pathname, item.href, item.exact ?? false)) { + if (!bestMatch || item.href.length > bestMatch.length) { + bestMatch = { href: item.href, length: item.href.length }; + } + } + } + + return bestMatch?.href ?? null; +} + +/** + * Returns whether a nav parent has an active child route (for auto-expanding accordions). + * @param pathname - Current app pathname. + * @param item - Nav item to evaluate. + * @returns True when a nested child is active. + */ +export function dashboardNavParentHasActiveChild( + pathname: string, + item: DashboardNavItem +): boolean { + return item.children?.some((child) => isChildActive(pathname, child)) ?? false; +} + +/** + * Tracks expanded dashboard nav parents, auto-expanding sections with active children. + * Mount inside a component keyed by pathname so manual toggles reset on navigation. + * @param pathname - Current app pathname. + * @param navItems - Nav items to evaluate. + * @returns Expanded parent hrefs and a toggle handler. + */ +export function useDashboardNavExpanded(pathname: string, navItems: DashboardNavItem[]) { + const [userToggles, setUserToggles] = useState>(() => new Map()); + + const expandedParents = useMemo(() => { + const next = new Set(); + for (const item of navItems) { + if (!item.children?.length) { + continue; + } + const userToggle = userToggles.get(item.href); + if (userToggle !== undefined) { + if (userToggle) { + next.add(item.href); + } + continue; + } + if (dashboardNavParentHasActiveChild(pathname, item)) { + next.add(item.href); + } + } + return next; + }, [pathname, navItems, userToggles]); + + const toggleExpanded = useCallback( + (href: string) => { + setUserToggles((prev) => { + const next = new Map(prev); + const item = navItems.find((navItem) => navItem.href === href); + const userToggle = prev.get(href); + const currentlyExpanded = + userToggle !== undefined + ? userToggle + : item + ? dashboardNavParentHasActiveChild(pathname, item) + : false; + next.set(href, !currentlyExpanded); + return next; + }); + }, + [pathname, navItems] + ); + + return { expandedParents, toggleExpanded }; +} + +interface NavLinkProps { + label: string; + href: string; + active: boolean; + tourId?: string; + nested?: boolean; + className: string; + onNavigate?: () => void; +} + +function NavLink({ + label, + href, + active, + tourId, + nested = false, + className, + onNavigate, +}: NavLinkProps) { + return ( + + {label} + + ); +} + +interface DashboardNavListProps { + navItems: DashboardNavItem[]; + pathname: string; + activeHref: string | null; + expandedParents: Set; + toggleExpanded: (href: string) => void; + linkClassName: string; + activeClassName: string; + inactiveClassName: string; + onNavigate?: () => void; + uploadsTourId?: 'desktop' | 'mobile'; +} + +type DashboardNavListContainerProps = Omit< + DashboardNavListProps, + 'expandedParents' | 'toggleExpanded' +>; + +/** + * Renders {@link DashboardNavList} with expansion state scoped to the current route. + * Remount with `key={pathname}` so chevron toggles reset when navigating. + * @param props - Nav list props excluding expansion state. + * @returns Navigation list UI. + */ +export function DashboardNavListContainer(props: DashboardNavListContainerProps) { + const { pathname, navItems } = props; + const { expandedParents, toggleExpanded } = useDashboardNavExpanded(pathname, navItems); + + return ( + + ); +} + +/** + * Renders the dashboard navigation tree for the sidebar or mobile drawer. + * @param props - Nav list props. + * @returns Navigation list UI. + */ +export function DashboardNavList({ + navItems, + pathname, + activeHref, + expandedParents, + toggleExpanded, + linkClassName, + activeClassName, + inactiveClassName, + onNavigate, + uploadsTourId, +}: DashboardNavListProps) { + return ( + <> + {navItems.map((item) => { + const { label, href, children } = item; + const tourId = + uploadsTourId === 'desktop' + ? item.tourIdDesktop + : uploadsTourId === 'mobile' + ? item.tourIdMobile + : undefined; + const parentActive = isDashboardNavParentActive(pathname, item); + const parentCurrent = activeHref === href; + const hasChildren = (children?.length ?? 0) > 0; + const isExpanded = expandedParents.has(href); + const submenuId = `${href.replace(/\//g, '-')}-submenu`; + + if (!hasChildren) { + return ( + + ); + } + + return ( +
+
+ + +
+ {isExpanded ? ( +
+ {children?.map((child) => ( + + ))} +
+ ) : null} +
+ ); + })} + + ); +} diff --git a/components/drafts/DraftLabelChip.tsx b/components/drafts/DraftLabelChip.tsx new file mode 100644 index 00000000..8ef1b243 --- /dev/null +++ b/components/drafts/DraftLabelChip.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { + draftLabelColorWithAlpha, + lookupDraftLabelColor, + normalizeDraftLabelColor, +} from '@/lib/draft-labels'; +import { cn } from '@/lib/utils'; +import type { DraftLabelDefinition } from '@/types'; +import { DraftLabelColorPicker } from '@/components/drafts/DraftLabelColorPicker'; + +interface DraftLabelChipProps { + /** Label text shown on the chip. */ + label: string; + /** Saved library used to resolve color when `color` is omitted. */ + library?: readonly DraftLabelDefinition[]; + /** Explicit chip color override. */ + color?: string; + /** Optional class names for the outer chip element. */ + className?: string; + /** When set, shows a remove button on the chip. */ + onRemove?: () => void; + /** When set, shows an inline color picker on the chip. */ + onColorChange?: (color: string) => void; + /** When true, color editing and remove are disabled. */ + disabled?: boolean; +} + +/** + * Renders a colored draft label chip with optional remove and color controls. + * @param props - Label text, color, and optional handlers. + * @returns Styled label chip. + */ +export function DraftLabelChip({ + label, + library = [], + color, + className, + onRemove, + onColorChange, + disabled = false, +}: DraftLabelChipProps) { + const resolvedColor = normalizeDraftLabelColor(color ?? lookupDraftLabelColor(library, label)); + + return ( + + {onColorChange ? ( + + ) : null} + {label} + {onRemove && !disabled ? ( + + ) : null} + + ); +} diff --git a/components/drafts/DraftLabelColorPicker.tsx b/components/drafts/DraftLabelColorPicker.tsx new file mode 100644 index 00000000..a5ab80a9 --- /dev/null +++ b/components/drafts/DraftLabelColorPicker.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useId, useRef, useState } from 'react'; +import { DRAFT_LABEL_COLOR_PRESETS, normalizeDraftLabelColor } from '@/lib/draft-labels'; +import { cn } from '@/lib/utils'; + +interface DraftLabelColorPickerProps { + /** Current hex color for the label. */ + color: string; + /** Called when the user selects a new color. */ + onChange: (color: string) => void; + /** Accessible name for the trigger button. */ + ariaLabel: string; + /** When true, the picker is disabled. */ + disabled?: boolean; +} + +/** + * Compact color picker for draft labels with preset swatches and a custom color input. + * @param props - Current color and change handler. + * @returns Popover color picker trigger and panel. + */ +export function DraftLabelColorPicker({ + color, + onChange, + ariaLabel, + disabled = false, +}: DraftLabelColorPickerProps) { + const panelId = useId(); + const containerRef = useRef(null); + const [open, setOpen] = useState(false); + const normalizedColor = normalizeDraftLabelColor(color); + + useEffect(() => { + if (!open) return; + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [open]); + + return ( +
+ + {open && !disabled ? ( + + ) : null} +
+ ); +} diff --git a/components/drafts/DraftLabelInput.tsx b/components/drafts/DraftLabelInput.tsx new file mode 100644 index 00000000..567438c0 --- /dev/null +++ b/components/drafts/DraftLabelInput.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useImperativeHandle, + useRef, + useState, + forwardRef, +} from 'react'; +import { DraftLabelChip } from '@/components/drafts/DraftLabelChip'; +import { + MAX_DRAFT_LABELS_PER_DRAFT, + mergeDraftLabelLibraryEntries, + mergeUniqueDraftLabels, + parseDraftLabelInput, +} from '@/lib/draft-labels'; +import { cn } from '@/lib/utils'; +import type { ApiResponse, DraftLabelDefinition } from '@/types'; + +interface DraftLabelInputProps { + /** Current labels on the draft. */ + labels: string[]; + /** Saved label definitions used for chip colors and autocomplete. */ + labelLibrary?: DraftLabelDefinition[]; + /** Called when the saved library changes (for example, after a color edit). */ + onLabelLibraryChange?: (library: DraftLabelDefinition[]) => void; + /** Called when the label list changes. */ + onChange: (labels: string[]) => void; + /** When true, editing is disabled. */ + disabled?: boolean; + /** Optional stable id prefix for the text input. */ + inputId?: string; +} + +/** Imperative handle for flushing pending label input before save. */ +export interface DraftLabelInputHandle { + /** Commits any pending text in the input as labels. */ + commitPending: () => void; +} + +/** + * Chip input for draft organizational labels with autocomplete from the saved library. + * @param props - Label value and change handlers. + * @returns Draft label editor with suggestion list. + */ +export const DraftLabelInput = forwardRef( + function DraftLabelInput( + { + labels, + labelLibrary, + onLabelLibraryChange, + onChange, + disabled = false, + inputId = 'draft-labels', + }, + ref + ) { + const listboxId = useId(); + const optionIdPrefix = useId(); + const containerRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const [fetchedLibrary, setFetchedLibrary] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [suggestionsOpen, setSuggestionsOpen] = useState(false); + const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); + const library = labelLibrary ?? fetchedLibrary; + + const loadSuggestions = useCallback( + async (query: string) => { + try { + const params = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''; + const response = await fetch(`/api/drafts/labels${params}`, { cache: 'no-store' }); + if (!response.ok) { + setSuggestions([]); + setSuggestionsOpen(false); + setActiveSuggestionIndex(-1); + return; + } + const payload = (await response.json()) as ApiResponse; + const next = Array.isArray(payload.data) ? payload.data : []; + if (query.trim()) { + setSuggestions(next); + setSuggestionsOpen(next.length > 0); + setActiveSuggestionIndex(next.length > 0 ? 0 : -1); + return; + } + if (!labelLibrary) { + setFetchedLibrary(next); + } + } catch { + setSuggestions([]); + setSuggestionsOpen(false); + setActiveSuggestionIndex(-1); + } + }, + [labelLibrary] + ); + + useEffect(() => { + if (disabled) return; + const trimmedQuery = inputValue.trim(); + if (!trimmedQuery && labelLibrary) { + return; + } + const handle = window.setTimeout( + () => { + void loadSuggestions(inputValue); + }, + trimmedQuery ? 200 : 0 + ); + return () => window.clearTimeout(handle); + }, [disabled, inputValue, labelLibrary, loadSuggestions]); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setSuggestionsOpen(false); + } + }; + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, []); + + const syncLibraryEntries = useCallback( + async (entries: DraftLabelDefinition[]) => { + if (entries.length === 0) return; + try { + const response = await fetch('/api/drafts/labels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels: entries }), + }); + if (!response.ok) return; + const payload = (await response.json()) as ApiResponse; + if (Array.isArray(payload.data)) { + if (!labelLibrary) { + setFetchedLibrary(payload.data); + } + onLabelLibraryChange?.(payload.data); + } + } catch { + // Library sync is best-effort; draft save also syncs label names. + } + }, + [labelLibrary, onLabelLibraryChange] + ); + + const upsertLabelNamesInLibrary = useCallback( + async (nextLabels: string[]) => { + if (nextLabels.length === 0) return; + try { + const response = await fetch('/api/drafts/labels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels: nextLabels }), + }); + if (!response.ok) return; + const payload = (await response.json()) as ApiResponse; + if (Array.isArray(payload.data)) { + if (!labelLibrary) { + setFetchedLibrary(payload.data); + } + onLabelLibraryChange?.(payload.data); + } + } catch { + // Library upsert is best-effort; draft save also syncs labels. + } + }, + [labelLibrary, onLabelLibraryChange] + ); + + const addLabels = useCallback( + (parsed: string[]) => { + if (parsed.length === 0) return; + const merged = mergeUniqueDraftLabels(labels, parsed); + if (merged.length > MAX_DRAFT_LABELS_PER_DRAFT) return; + const added = merged.filter((label) => !labels.includes(label)); + onChange(merged); + void upsertLabelNamesInLibrary(added.length > 0 ? added : parsed); + setInputValue(''); + setSuggestionsOpen(false); + }, + [labels, onChange, upsertLabelNamesInLibrary] + ); + + const updateLabelColor = useCallback( + (label: string, color: string) => { + const next = mergeDraftLabelLibraryEntries(library, [{ name: label, color }]); + if (labelLibrary) { + onLabelLibraryChange?.(next); + } else { + setFetchedLibrary(next); + } + void syncLibraryEntries([{ name: label, color }]); + }, + [labelLibrary, library, onLabelLibraryChange, syncLibraryEntries] + ); + + const commitInput = useCallback(() => { + addLabels(parseDraftLabelInput(inputValue)); + }, [addLabels, inputValue]); + + useImperativeHandle(ref, () => ({ commitPending: commitInput }), [commitInput]); + + const visibleSuggestions = suggestions.filter( + (suggestion) => !labels.some((label) => label.toLowerCase() === suggestion.name.toLowerCase()) + ); + + const highlightedOptionId = + suggestionsOpen && + activeSuggestionIndex >= 0 && + activeSuggestionIndex < visibleSuggestions.length + ? `${optionIdPrefix}-option-${activeSuggestionIndex}` + : undefined; + + return ( +
+ +

+ Organize drafts in VideoSphere. Press Enter or comma to add a label. Use the color dot to + customize each label. +

+
+ {labels.map((label) => ( + updateLabelColor(label, color)} + onRemove={() => onChange(labels.filter((existing) => existing !== label))} + disabled={disabled} + /> + ))} + {!disabled ? ( + setInputValue(event.target.value)} + onFocus={() => { + void loadSuggestions(inputValue); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + if ( + suggestionsOpen && + activeSuggestionIndex >= 0 && + visibleSuggestions[activeSuggestionIndex] + ) { + addLabels([visibleSuggestions[activeSuggestionIndex].name]); + return; + } + commitInput(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + if (visibleSuggestions.length === 0) return; + setSuggestionsOpen(true); + setActiveSuggestionIndex((index) => + index >= visibleSuggestions.length - 1 ? 0 : index + 1 + ); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (visibleSuggestions.length === 0) return; + setSuggestionsOpen(true); + setActiveSuggestionIndex((index) => + index <= 0 ? visibleSuggestions.length - 1 : index - 1 + ); + } else if (event.key === 'Escape') { + setSuggestionsOpen(false); + } else if (event.key === 'Backspace' && inputValue === '' && labels.length > 0) { + event.preventDefault(); + const lastLabel = labels[labels.length - 1]; + onChange(labels.slice(0, -1)); + setInputValue(lastLabel); + } + }} + onBlur={() => { + commitInput(); + }} + role="combobox" + aria-expanded={suggestionsOpen && visibleSuggestions.length > 0} + aria-controls={listboxId} + aria-activedescendant={highlightedOptionId} + aria-autocomplete="list" + placeholder={labels.length === 0 ? 'Add a label…' : ''} + disabled={disabled || labels.length >= MAX_DRAFT_LABELS_PER_DRAFT} + className="min-w-[8rem] flex-1 border-0 bg-transparent px-1 py-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + ) : null} +
+ {suggestionsOpen && visibleSuggestions.length > 0 && !disabled ? ( +
    + {visibleSuggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ ); + } +); diff --git a/components/drafts/DraftMetadataModal.tsx b/components/drafts/DraftMetadataModal.tsx index cf4d9bd7..1a5eb8a2 100644 --- a/components/drafts/DraftMetadataModal.tsx +++ b/components/drafts/DraftMetadataModal.tsx @@ -21,7 +21,12 @@ import { createSseParser } from '@/lib/ai/sse-utils'; import { validateDraftForUpload, type DraftUploadFieldKey } from '@/lib/draft-upload-validation'; import { mergeSermonAudioDefaultFields } from '@/lib/platforms/sermon-audio-event-types'; import type { SermonAudioLanguageOption } from '@/lib/platforms/sermon-audio-languages'; +import { getUsableConnectedPlatforms } from '@/lib/platforms/connection-status'; import { validateFacebookScheduledPublishTime } from '@/lib/platforms/facebook-schedule'; +import { + sermonAudioPublishTimestampToScheduleParts, + validateSermonAudioScheduledPublishTime, +} from '@/lib/platforms/sermon-audio-schedule'; import { validateSchedulePublishAtIso, getScheduleMaxLeadLabel } from '@/lib/schedule-bounds'; import { buildBackupFileName, @@ -55,12 +60,18 @@ import { import { UploadHistoryJobDiscard } from '@/components/uploads/UploadHistoryJobDiscard'; import { UploadHistoryPlatformActions } from '@/components/uploads/UploadHistoryPlatformActions'; import { SermonAudioSpeakerCombobox } from '@/components/drafts/SermonAudioSpeakerCombobox'; +import { SermonAudioCrossPublishFields } from '@/components/drafts/SermonAudioCrossPublishFields'; import { SermonAudioSeriesCombobox } from '@/components/drafts/SermonAudioSeriesCombobox'; import { SermonAudioBibleReferencesField } from '@/components/drafts/SermonAudioBibleReferencesField'; import { YouTubePlaylistCombobox } from '@/components/drafts/YouTubePlaylistCombobox'; import { SearchableSelect } from '@/components/drafts/SearchableSelect'; import { VimeoCategoryPicker } from '@/components/drafts/VimeoCategoryPicker'; import { YouTubeTimezoneSelect } from '@/components/drafts/YouTubeTimezoneSelect'; +import { YouTubeImportModal } from '@/components/youtube-import/YouTubeImportModal'; +import { + formatYoutubeImportStatusLabel, + isActiveYoutubeImportStatus, +} from '@/lib/youtube-import/import-job-ui'; import { Progress } from '@/components/ui/progress'; import { RequiredFieldMarker } from '@/components/ui/required-field-marker'; import { @@ -88,6 +99,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; +import { DraftLabelInput, type DraftLabelInputHandle } from '@/components/drafts/DraftLabelInput'; import { DraftModalCard } from '@/components/drafts/DraftModalCard'; import { DraftPlatformToggles } from '@/components/drafts/DraftPlatformToggles'; import type { @@ -98,6 +110,7 @@ import type { ConnectedAccountPlatform, ConnectedAccountPublic, Draft, + DraftLabelDefinition, DraftPlatforms, PerPlatformCopyOverrides, PerPlatformOverrides, @@ -108,6 +121,7 @@ import type { VimeoVideoLicense, YouTubeDraftFields, FacebookDraftFields, + YoutubeImportJob, } from '@/types'; import { DRAFT_THUMBNAIL_DISALLOWED_TYPE_MESSAGE, @@ -175,6 +189,8 @@ export interface DraftEditorValues { title: string; description: string; tags: string[]; + /** Organizational labels for grouping drafts in VideoSphere (not sent to platforms). */ + labels?: string[]; visibility: Draft['visibility']; targets: ConnectedAccountPlatform[]; platforms: DraftPlatforms; @@ -501,6 +517,10 @@ interface DraftMetadataModalProps { onDelete?: (draftId: string) => Promise; onChange: (next: DraftEditorValues) => void; canUseAiMetadata?: boolean; + /** Saved label library for chip colors in the label editor. */ + labelLibrary?: DraftLabelDefinition[]; + /** Called when the label library changes during editing (for example, color updates). */ + onLabelLibraryChange?: (library: DraftLabelDefinition[]) => void; /** Disable Dialog focus trap and scroll lock, e.g. when an onboarding tour overlay is active. */ disableInteractionLock?: boolean; } @@ -589,6 +609,8 @@ export function DraftMetadataModal({ onDelete, onChange, canUseAiMetadata = false, + labelLibrary, + onLabelLibraryChange, disableInteractionLock = false, }: DraftMetadataModalProps) { const router = useRouter(); @@ -606,6 +628,7 @@ export function DraftMetadataModal({ const [connectionsError, setConnectionsError] = useState(null); const [platformWarning, setPlatformWarning] = useState(null); const [tagInput, setTagInput] = useState(''); + const labelInputRef = useRef(null); const [videoFile, setVideoFile] = useState(null); const [uploading, setUploading] = useState(false); /** True when server cancel failed after XHR abort; keeps retry/clear UI until resolved. */ @@ -621,6 +644,12 @@ export function DraftMetadataModal({ const [uploadHistory, setUploadHistory] = useState([]); const [isLoadingUploadHistory, setIsLoadingUploadHistory] = useState(false); const [showUploadHistory, setShowUploadHistory] = useState(false); + const [showYouTubeImportModal, setShowYouTubeImportModal] = useState(false); + const [youtubeImportDraftId, setYoutubeImportDraftId] = useState(null); + const [draftYoutubeImport, setDraftYoutubeImport] = useState(null); + const [showClearYoutubeImportConfirm, setShowClearYoutubeImportConfirm] = useState(false); + const [showCancelYoutubeImportConfirm, setShowCancelYoutubeImportConfirm] = useState(false); + const [isYoutubeImportConfirmBusy, setIsYoutubeImportConfirmBusy] = useState(false); const [showUploadProgressModalHistory, setShowUploadProgressModalHistory] = useState(false); const [expandedUploadHistoryIds, setExpandedUploadHistoryIds] = useState>(new Set()); const [retryingUploadKey, setRetryingUploadKey] = useState(null); @@ -638,6 +667,11 @@ export function DraftMetadataModal({ const [fbScheduleTime, setFbScheduleTime] = useState(''); const [fbScheduleTimeZone, setFbScheduleTimeZone] = useState(''); const fbScheduleInitializedRef = useRef(false); + const [saScheduleExpanded, setSaScheduleExpanded] = useState(false); + const [saScheduleDate, setSaScheduleDate] = useState(''); + const [saScheduleTime, setSaScheduleTime] = useState(''); + const [saScheduleTimeZone, setSaScheduleTimeZone] = useState(''); + const saScheduleInitializedRef = useRef(false); const supportedTimeZones = useMemo(() => getSupportedTimeZones(), []); const [youtubeLanguages, setYoutubeLanguages] = useState>([]); const [youtubeCategories, setYoutubeCategories] = useState>( @@ -820,6 +854,7 @@ export function DraftMetadataModal({ const snapshotEditor = (editor: DraftEditorValues): DraftEditorValues => ({ ...editor, tags: [...editor.tags], + labels: [...(editor.labels ?? [])], targets: [...editor.targets], backupNaming: { ...normalizeBackupFileNameSettings(editor.backupNaming) }, platforms: { @@ -888,6 +923,28 @@ export function DraftMetadataModal({ } }; + const loadDraftYoutubeImport = useCallback(async (id: string, signal?: AbortSignal) => { + try { + const response = await fetch(`/api/drafts/${id}/youtube-import`, { + cache: 'no-store', + signal, + }); + if (signal?.aborted) return; + if (!response.ok) { + setDraftYoutubeImport(null); + return; + } + const payload = (await response.json()) as ApiResponse; + setDraftYoutubeImport(payload.data ?? null); + } catch (error) { + if (signal?.aborted) return; + const isAbortError = + (error instanceof DOMException || error instanceof Error) && error.name === 'AbortError'; + if (isAbortError) return; + setDraftYoutubeImport(null); + } + }, []); + /** Reconcile thumbnail fields with the server after distribute consumes and clears them. */ const syncDraftThumbnailFromServer = useCallback( async (id: string) => { @@ -1085,8 +1142,33 @@ export function DraftMetadataModal({ if (!draftId) return; const controller = new AbortController(); void loadUploadHistory(draftId, controller.signal); + void loadDraftYoutubeImport(draftId, controller.signal); return () => controller.abort(); - }, [draftId]); + }, [draftId, loadDraftYoutubeImport]); + + const draftYoutubeImportId = draftYoutubeImport?.id; + const draftYoutubeImportStatus = draftYoutubeImport?.status; + + useEffect(() => { + if ( + !draftId || + !draftYoutubeImportId || + draftYoutubeImportStatus == null || + !isActiveYoutubeImportStatus(draftYoutubeImportStatus) + ) { + return; + } + + const controller = new AbortController(); + const intervalId = window.setInterval(() => { + void loadDraftYoutubeImport(draftId, controller.signal); + }, 3000); + + return () => { + controller.abort(); + window.clearInterval(intervalId); + }; + }, [draftId, draftYoutubeImportId, draftYoutubeImportStatus, loadDraftYoutubeImport]); const needsLiveUploadHistoryUpdates = uploading || uploadComplete || showUploadHistory || showUploadModal; @@ -1191,7 +1273,7 @@ export function DraftMetadataModal({ } const payload = (await response.json()) as ApiResponse; const platforms = Array.isArray(payload.data) - ? payload.data.map((acc) => acc.platform) + ? getUsableConnectedPlatforms(payload.data) : []; setConnectedPlatforms(platforms); } catch (error) { @@ -2164,6 +2246,23 @@ export function DraftMetadataModal({ setFbScheduleTimeZone(tz); }, [value?.platforms.facebook?.scheduledPublishTime]); + const initSermonAudioScheduleFromStored = useCallback(() => { + const tz = getLocalTimeZone(); + const existing = value?.platforms.sermon_audio?.publishTimestamp; + if (existing !== undefined) { + const parts = sermonAudioPublishTimestampToScheduleParts(existing, tz); + if (parts) { + setSaScheduleDate(parts.dateStr); + setSaScheduleTime(parts.timeStr); + setSaScheduleTimeZone(tz); + return; + } + } + setSaScheduleDate(getDefaultScheduleDate(tz)); + setSaScheduleTime(getDefaultScheduleTime(tz)); + setSaScheduleTimeZone(tz); + }, [value?.platforms.sermon_audio?.publishTimestamp]); + useEffect(() => { if (!value?.targets.includes('youtube')) { youtubeDefaultsSeededRef.current = null; @@ -2332,6 +2431,18 @@ export function DraftMetadataModal({ ? validateFacebookScheduledPublishTime(facebookFields.scheduledPublishTime) : undefined; + const sermonAudioPublishTimestampValue = sermonAudioFields?.publishTimestamp; + const sermonAudioSchedulePastWarning = + sermonAudioPublishTimestampValue !== undefined && + sermonAudioPublishTimestampValue * 1000 < Date.now(); + const sermonAudioScheduleValidationMessage = + sermonAudioPublishTimestampValue !== undefined + ? validateSermonAudioScheduledPublishTime(sermonAudioPublishTimestampValue) + : undefined; + const sermonAudioShowsCrossPublish = + sermonAudioFields?.autoPublishOnProcessed !== false || + sermonAudioPublishTimestampValue !== undefined; + useEffect(() => { if (!value || facebookVideoState !== 'SCHEDULED') return; @@ -2373,6 +2484,7 @@ export function DraftMetadataModal({ useEffect(() => { scheduleInitializedRef.current = false; fbScheduleInitializedRef.current = false; + saScheduleInitializedRef.current = false; setShowMoreExpanded(false); setAgeRestrictionsExpanded(false); setScheduleExpanded(false); @@ -2382,6 +2494,10 @@ export function DraftMetadataModal({ setFbScheduleDate(''); setFbScheduleTime(''); setFbScheduleTimeZone(''); + setSaScheduleExpanded(false); + setSaScheduleDate(''); + setSaScheduleTime(''); + setSaScheduleTimeZone(''); }, [draftId]); useEffect(() => { @@ -2393,6 +2509,62 @@ export function DraftMetadataModal({ fbScheduleInitializedRef.current = true; }, [draftId, facebookVideoState, initFacebookScheduleFromStored, value?.targets]); + useEffect(() => { + if (!value?.targets.includes('sermon_audio')) return; + if (sermonAudioPublishTimestampValue === undefined) return; + if (saScheduleInitializedRef.current) return; + + initSermonAudioScheduleFromStored(); + saScheduleInitializedRef.current = true; + setSaScheduleExpanded(true); + }, [ + draftId, + initSermonAudioScheduleFromStored, + sermonAudioPublishTimestampValue, + value?.targets, + ]); + + useEffect(() => { + if (!value || !saScheduleExpanded) return; + + const hasCompleteScheduleInputs = + Boolean(saScheduleDate) && Boolean(saScheduleTime) && Boolean(saScheduleTimeZone); + + if (!hasCompleteScheduleInputs) { + if ( + saScheduleInitializedRef.current && + value.platforms.sermon_audio?.publishTimestamp !== undefined + ) { + updateSermonAudioFields({ publishTimestamp: undefined }); + } + return; + } + + let iso: string; + try { + iso = zonedDateTimeToUtcIso(saScheduleDate, saScheduleTime, saScheduleTimeZone); + } catch { + if (value.platforms.sermon_audio?.publishTimestamp !== undefined) { + updateSermonAudioFields({ publishTimestamp: undefined }); + } + return; + } + + const unixSec = Math.floor(new Date(iso).getTime() / 1000); + if (value.platforms.sermon_audio?.publishTimestamp === unixSec) return; + updateSermonAudioFields({ + publishTimestamp: unixSec, + autoPublishOnProcessed: false, + }); + }, [ + saScheduleDate, + saScheduleExpanded, + saScheduleTime, + saScheduleTimeZone, + updateSermonAudioFields, + value, + ]); + useEffect(() => { if (!value || !showMoreExpanded || !scheduleExpanded) return; if (!scheduleDate || !scheduleTime || !scheduleTimeZone) return; @@ -2447,6 +2619,32 @@ export function DraftMetadataModal({ updateYouTubeFields({ publishAt: undefined }); }; + const clearSermonAudioSchedule = () => { + setSaScheduleExpanded(false); + saScheduleInitializedRef.current = false; + setSaScheduleDate(''); + setSaScheduleTime(''); + setSaScheduleTimeZone(''); + updateSermonAudioFields({ + publishTimestamp: undefined, + autoPublishOnProcessed: undefined, + }); + }; + + const handleSermonAudioScheduleExpandedChange = (nextExpanded: boolean) => { + if (nextExpanded) { + if (!saScheduleInitializedRef.current) { + initSermonAudioScheduleFromStored(); + saScheduleInitializedRef.current = true; + } + updateSermonAudioFields({ autoPublishOnProcessed: false }); + setSaScheduleExpanded(true); + return; + } + + clearSermonAudioSchedule(); + }; + const clearSchedule = () => { setScheduleExpanded(false); scheduleInitializedRef.current = false; @@ -2501,9 +2699,18 @@ export function DraftMetadataModal({ }); }, [commitTagsFromInput]); + const commitLabelsBeforeSave = useCallback(() => { + labelInputRef.current?.commitPending(); + }, []); + + const commitMetadataInputsBeforeSave = useCallback(() => { + commitLabelsBeforeSave(); + commitTagsBeforeSave(); + }, [commitLabelsBeforeSave, commitTagsBeforeSave]); + const validateBeforeUpload = useCallback((): boolean => { if (!value) return false; - commitTagsBeforeSave(); + commitMetadataInputsBeforeSave(); const issues = validateDraftForUpload({ title: value.title, description: value.description, @@ -2532,7 +2739,7 @@ export function DraftMetadataModal({ } setUploadFieldErrors(new Set()); return true; - }, [commitTagsBeforeSave, value, vimeoSupportsUnlisted]); + }, [commitMetadataInputsBeforeSave, value, vimeoSupportsUnlisted]); const displayPlatforms = useMemo(() => { if (!value) return [] as ConnectedAccountPlatform[]; @@ -2566,6 +2773,14 @@ export function DraftMetadataModal({ return value.targets.filter((platform) => !connectedSet.has(platform)); }, [connectedPlatforms, value]); + const isYouTubeConnected = connectedPlatforms.includes('youtube'); + const youtubeImportActive = + draftYoutubeImport != null && isActiveYoutubeImportStatus(draftYoutubeImport.status); + const youtubeImportStaged = draftYoutubeImport?.status === 'completed'; + const youtubeImportFailed = draftYoutubeImport?.status === 'failed'; + const youtubeImportLocksFilePicker = youtubeImportActive || youtubeImportStaged; + const hasUploadableVideoSource = videoFile != null || youtubeImportStaged; + const connectionsResolvedSuccessfully = hasLoadedConnections && connectionsError === null; const canSave = @@ -2578,7 +2793,12 @@ export function DraftMetadataModal({ (!connectionsResolvedSuccessfully || disconnectedSelectedPlatforms.length === 0); /** Blocks video upload while thumbnail PUT/complete is in-flight so distribute reads thumbnailR2Key. */ const canUploadVideo = - canSave && !thumbnailUploading && !uploading && !cancelServerFailed && !isSaving; + canSave && + !thumbnailUploading && + !uploading && + !cancelServerFailed && + !isSaving && + hasUploadableVideoSource; const trimmedAiPrompt = aiPrompt.trim(); const hasAiPrompt = trimmedAiPrompt !== ''; const hasGeneratedMetadata = @@ -2834,7 +3054,7 @@ export function DraftMetadataModal({ currentUploadJobId === null && !cancelServerFailed && !(value.thumbnailR2Key || value.thumbnailPreviewUrl); - commitTagsBeforeSave(); + commitMetadataInputsBeforeSave(); if (isCreateDraftEmptyForConnect) { onClose(); router.push('/profile/connections'); @@ -2861,7 +3081,7 @@ export function DraftMetadataModal({ currentUploadJobId === null && !cancelServerFailed && !(value.thumbnailR2Key || value.thumbnailPreviewUrl); - commitTagsBeforeSave(); + commitMetadataInputsBeforeSave(); if (isCreateDraftEmptyForConnect) { onClose(); router.push('/profile/connections'); @@ -2874,7 +3094,11 @@ export function DraftMetadataModal({ }; const handleUploadVideo = async () => { - if (!value || !videoFile) { + if (!value) { + closeUploadModal(); + return; + } + if (!videoFile && !draftYoutubeImport) { closeUploadModal(); return; } @@ -2888,7 +3112,7 @@ export function DraftMetadataModal({ return; } - commitTagsBeforeSave(); + commitMetadataInputsBeforeSave(); const saveResult = await onSave({ closeAfterSave: false }); if (!saveResult.saved) { setUploadModalPhase('confirm'); @@ -2901,6 +3125,53 @@ export function DraftMetadataModal({ return; } + if (!videoFile && draftYoutubeImport) { + setUploading(true); + try { + const response = await fetch( + `/api/youtube-import/${draftYoutubeImport.id}/queue-distribute`, + { method: 'POST' } + ); + const payload = (await response.json().catch(() => null)) as + | ApiResponse + | { message?: string } + | null; + + if (!response.ok) { + throw new Error( + payload && 'message' in payload && payload.message + ? payload.message + : 'Failed to queue YouTube import upload' + ); + } + + const updatedJob = payload && 'data' in payload ? payload.data : null; + if (updatedJob) { + setDraftYoutubeImport(updatedJob); + } + + if (updatedJob && isActiveYoutubeImportStatus(updatedJob.status)) { + toast.success('Upload queued. It will start when the import finishes.'); + closeUploadModal(); + return; + } + + await loadUploadHistory(draftIdForUpload); + setUploadComplete(true); + setUploadModalPhase('complete'); + setShowUploadHistory(true); + await onUploadComplete?.(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : 'Failed to queue YouTube import upload' + ); + setUploadModalPhase('confirm'); + } finally { + setUploading(false); + } + return; + } + let activeUploadJobId: string | null = null; let uploadAbortedByUser = false; @@ -3053,6 +3324,87 @@ export function DraftMetadataModal({ } }; + const handleYouTubeImportComplete = async () => { + const draftIdForImport = youtubeImportDraftId ?? draftId; + if (!draftIdForImport) return; + await loadDraftYoutubeImport(draftIdForImport); + }; + + const handleDiscardDraftYoutubeImport = useCallback(async () => { + const draftIdForDiscard = draftId ?? value?.id; + if (!draftIdForDiscard) return; + + try { + const response = await fetch(`/api/drafts/${draftIdForDiscard}/youtube-import/discard`, { + method: 'POST', + }); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to clear YouTube import'); + } + setDraftYoutubeImport(null); + toast.success('YouTube import cleared'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to clear YouTube import'); + throw error; + } + }, [draftId, value?.id]); + + const handleOpenYouTubeImport = async () => { + if (!value) return; + if (uploadComplete || uploading || youtubeImportActive || youtubeImportStaged) return; + + commitMetadataInputsBeforeSave(); + const saveResult = await onSave({ closeAfterSave: false }); + if (!saveResult.saved) { + toast.error(saveResult.message ?? 'Please save the draft before importing.'); + return; + } + const draftIdForImport = saveResult.draftId ?? value.id; + if (!draftIdForImport) { + toast.error('Please save the draft before importing.'); + return; + } + setYoutubeImportDraftId(draftIdForImport); + setShowYouTubeImportModal(true); + }; + + const handleClearYoutubeImportConfirm = useCallback(async () => { + setIsYoutubeImportConfirmBusy(true); + try { + await handleDiscardDraftYoutubeImport(); + setShowClearYoutubeImportConfirm(false); + } finally { + setIsYoutubeImportConfirmBusy(false); + } + }, [handleDiscardDraftYoutubeImport]); + + const handleCancelActiveYoutubeImportConfirm = useCallback(async () => { + const importJobId = draftYoutubeImport?.id; + const draftIdForReload = draftId ?? value?.id; + if (!importJobId || !draftIdForReload) return; + + setIsYoutubeImportConfirmBusy(true); + try { + const response = await fetch(`/api/youtube-import/${importJobId}/cancel`, { + method: 'POST', + }); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to cancel YouTube import'); + } + setShowCancelYoutubeImportConfirm(false); + setDraftYoutubeImport(null); + await loadDraftYoutubeImport(draftIdForReload); + toast.success('YouTube import cancelled'); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to cancel YouTube import'); + throw error; + } finally { + setIsYoutubeImportConfirmBusy(false); + } + }, [draftId, draftYoutubeImport?.id, loadDraftYoutubeImport, value?.id]); + const handleCancelUpload = async () => { if (!currentUploadJobId) return; if (!uploading && !cancelServerFailed) return; // retry path only when stuck or in-flight @@ -3486,7 +3838,7 @@ export function DraftMetadataModal({ abortThumbnailUploadFlow(); void (async () => { if (await tryCloseModal()) { - router.push('/dashboard/history'); + router.push('/dashboard/uploads/history'); } })(); }; @@ -4251,20 +4603,108 @@ export function DraftMetadataModal({ type="checkbox" role="switch" aria-label="Auto-publish when processed" - checked={sermonAudioFields?.autoPublishOnProcessed !== false} - onChange={(event) => + checked={ + sermonAudioFields?.autoPublishOnProcessed !== false && + sermonAudioPublishTimestampValue === undefined + } + disabled={sermonAudioPublishTimestampValue !== undefined} + onChange={(event) => { + if (event.target.checked) { + clearSermonAudioSchedule(); + } updateSermonAudioFields({ autoPublishOnProcessed: event.target.checked, - }) - } + }); + }} className="peer sr-only" /> - +
- {/* TODO(sermon-audio-schedule): Contact the SermonAudio developer — POST /v2/node/sermons rejects ISO datetimes for publishDate (422: dates must be YYYY-MM-DD only). Schedule for Publication UI hidden until API supports date+time scheduling. */} - {/* TODO(sermon-audio-cross-publish): I will contact the SermonAudio developer about how to get Cross Publish working via the API. Cross Publish UI hidden until then. */} +
+ + {saScheduleExpanded ? ( +
+

Scheduled publish time

+
+ +
+ + setSaScheduleTimeZone(next)} + className={cn( + fieldBorderClass('sermon_audio.publishTimestamp'), + 'mt-1 w-full rounded-md border bg-background px-3' + )} + /> +
+
+
+ + {sermonAudioScheduleValidationMessage ? ( +

+ {sermonAudioScheduleValidationMessage} +

+ ) : sermonAudioSchedulePastWarning ? ( +

+ Scheduled time is in the past. The sermon will publish immediately after + processing. +

+ ) : ( +

+ You can schedule up to {getScheduleMaxLeadLabel('sermon_audio')} ahead. Times in + the past publish immediately. +

+ )} +
+
+ ) : null} +
+ {sermonAudioShowsCrossPublish ? ( + updateSermonAudioFields({ crossPublish: next })} + /> + ) : null} ); @@ -4466,6 +4906,15 @@ export function DraftMetadataModal({ {isLoadingPlatforms && displayPlatforms.length === 0 ? (

Loading connected platforms...

) : null} + + onChange({ ...value, labels })} + /> + ) : null} -
-
- - {showTitleSharedCheckbox ? ( - setUseSharedCopyField('title', useShared)} - hint="When checked, all selected targets share one title. Uncheck to set a title per platform." - /> - ) : null} -
- {showPerPlatformTitle ? ( -
- {selectedTitleTargetPlatforms.map((platform) => { - const platformFields = value.platforms[platform]; - const fieldKey = `title:${platform}` as DraftUploadFieldKey; - const platformTitle = platformFields?.titleOverride ?? value.title; - const showShortTitleUnderPlatform = - platform === 'sermon_audio' && needsSermonAudioShortTitle(platformTitle); - return ( -
- - { - clearUploadFieldError(fieldKey); - updateTitleOverridePlatformFields(platform, { - titleOverride: event.target.value, - }); - }} - aria-invalid={uploadFieldErrors.has(fieldKey)} - className={fieldBorderClass(fieldKey)} +
+ + {showTitleSharedCheckbox ? ( + setUseSharedCopyField('title', useShared)} + hint="When checked, all selected targets share one title. Uncheck to set a title per platform." + /> + ) : null} +
+ {showPerPlatformTitle ? ( +
+ {selectedTitleTargetPlatforms.map((platform) => { + const platformFields = value.platforms[platform]; + const fieldKey = `title:${platform}` as DraftUploadFieldKey; + const platformTitle = platformFields?.titleOverride ?? value.title; + const showShortTitleUnderPlatform = + platform === 'sermon_audio' && needsSermonAudioShortTitle(platformTitle); + return ( +
+
- ); - })} -
- ) : ( - <> - { - clearUploadFieldError('title'); - onChange({ ...value, title: event.target.value }); - }} - aria-invalid={uploadFieldErrors.has('title')} - className={fieldBorderClass('title')} + + { + clearUploadFieldError(fieldKey); + updateTitleOverridePlatformFields(platform, { + titleOverride: event.target.value, + }); + }} + aria-invalid={uploadFieldErrors.has(fieldKey)} + className={fieldBorderClass(fieldKey)} + /> + {showShortTitleUnderPlatform ? ( + updateSermonAudioFields({ displayTitle: next })} + fieldBorderClassName={fieldBorderClass('sermon_audio.displayTitle')} + /> + ) : null} +
+ ); + })} +
+ ) : ( + <> + { + clearUploadFieldError('title'); + onChange({ ...value, title: event.target.value }); + }} + aria-invalid={uploadFieldErrors.has('title')} + className={fieldBorderClass('title')} + /> + {showSermonAudioShortTitleUnderSharedTitle ? ( + updateSermonAudioFields({ displayTitle: next })} + fieldBorderClassName={fieldBorderClass('sermon_audio.displayTitle')} /> - {showSermonAudioShortTitleUnderSharedTitle ? ( - updateSermonAudioFields({ displayTitle: next })} - fieldBorderClassName={fieldBorderClass('sermon_audio.displayTitle')} - /> - ) : null} - - )} -
+ ) : null} + + )} {showDescriptionField ? (
@@ -5623,8 +6070,66 @@ export function DraftMetadataModal({ ) : null}

- Choose a video file, then upload it for this draft. + Choose a video file or import from YouTube, then upload when your draft is ready.

+ {draftYoutubeImport ? ( +
+
+ + YouTube import: {formatYoutubeImportStatusLabel(draftYoutubeImport.status)} + + {draftYoutubeImport.progressPercent}% +
+ {isActiveYoutubeImportStatus(draftYoutubeImport.status) ? ( + + ) : null} + {draftYoutubeImport.status === 'completed' ? ( +

+ Video is staged for this draft. Use Upload & Save when your metadata is + ready. +

+ ) : null} + {draftYoutubeImport.status === 'failed' ? ( +

+ {draftYoutubeImport.errorMessage?.trim() || 'The import failed.'} +

+ ) : null} + {draftYoutubeImport.distributeQueued && + isActiveYoutubeImportStatus(draftYoutubeImport.status) ? ( +

+ Upload queued — distribution will start when the import finishes. +

+ ) : null} + {youtubeImportActive ? ( +
+ +
+ ) : null} + {!youtubeImportActive && (youtubeImportFailed || youtubeImportStaged) ? ( +
+ +
+ ) : null} +
+ ) : null}
@@ -5684,7 +6208,7 @@ export function DraftMetadataModal({ type="button" data-tour="draft-save-button" onClick={() => { - commitTagsBeforeSave(); + commitMetadataInputsBeforeSave(); void (async () => { try { const r = await onSave({ closeAfterSave: true }); @@ -5705,7 +6229,7 @@ export function DraftMetadataModal({ if (!validateBeforeUpload()) return; openUploadConfirmModal(); }} - disabled={uploading || !canUploadVideo || !videoFile} + disabled={uploading || !canUploadVideo} className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-60" > {uploading ? 'Uploading...' : cancelServerFailed ? 'Pending upload' : 'Upload & Save'} @@ -5752,7 +6276,11 @@ export function DraftMetadataModal({ {uploadModalPhase === 'confirm' - ? 'This will save your latest draft changes and upload the selected video using this draft. Are you sure you want to continue?' + ? draftYoutubeImport && !videoFile + ? isActiveYoutubeImportStatus(draftYoutubeImport.status) + ? 'This will save your latest draft changes and queue an upload using the YouTube import in progress. Distribution starts when the import finishes. Continue?' + : 'This will save your latest draft changes and upload the imported YouTube video to your selected platforms. Continue?' + : 'This will save your latest draft changes and upload the selected video using this draft. Are you sure you want to continue?' : uploadModalPhase === 'clearPendingConfirm' ? 'Server cancellation is unavailable right now. Clear this pending upload locally and close anyway?' : uploadModalPhase === 'complete' @@ -5840,7 +6368,7 @@ export function DraftMetadataModal({ + ) : null} + + VideoSphere logo + VideoSphere + +
{/* --- Desktop nav --- */}
@@ -257,33 +296,27 @@ export default function Navbar({ Dashboard Profile - - {userLabel} - ) : showSignInLinks ? ( - + Log in ) : null} @@ -371,7 +404,7 @@ export default function Navbar({ setMobileMenuOpen(false)} > Dashboard @@ -379,16 +412,15 @@ export default function Navbar({ setMobileMenuOpen(false)} > Profile - {userLabel} @@ -396,7 +428,7 @@ export default function Navbar({ ) : showSignInLinks ? ( setMobileMenuOpen(false)} > Log in diff --git a/components/livestreams/LivestreamsListTable.tsx b/components/livestreams/LivestreamsListTable.tsx new file mode 100644 index 00000000..de3ce85d --- /dev/null +++ b/components/livestreams/LivestreamsListTable.tsx @@ -0,0 +1,419 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Copy, Loader2, Trash2 } from 'lucide-react'; +import type { Livestream, LivestreamStatus } from '@/types'; + +/** Maximum streamed livestreams shown on the main livestreams page before linking to history. */ +export const STREAMED_LIVESTREAM_PREVIEW_LIMIT = 4; + +/** + * Formats a scheduled start time for display in livestream lists. + * @param iso - ISO-8601 timestamp, if any. + * @returns Localized date/time string or an em dash when missing. + */ +export function formatScheduledDateTime(iso: string | undefined): string { + if (!iso) return '—'; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return '—'; + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date); +} + +/** + * Returns the display title for a livestream row. + * @param livestream - Livestream row. + * @returns Trimmed title or a fallback label. + */ +export function displayTitle(livestream: Livestream): string { + return livestream.title.trim() || 'Untitled livestream'; +} + +/** + * Maps a livestream status to a short badge label. + * @param status - Livestream lifecycle status. + * @returns Human-readable status label. + */ +export function statusBadgeLabel(status: LivestreamStatus): string { + switch (status) { + case 'draft': + return 'Draft'; + case 'scheduled': + return 'Scheduled'; + case 'live': + return 'Live'; + case 'ended': + return 'Ended'; + case 'failed': + return 'Failed'; + default: + return status; + } +} + +/** + * Builds an optional key-slot note for a livestream row. + * @param livestream - Livestream row. + * @returns Key-slot note text, or null when not applicable. + */ +export function formatKeySwapNote(livestream: Livestream): string | null { + if (livestream.keySlotStaleAt) { + return `Key: main → stale (never went live) at ${formatScheduledDateTime(livestream.keySlotStaleAt)}`; + } + if (livestream.keySwapPromotedAt && livestream.status === 'scheduled') { + return `Key: temp → promoted to main at ${formatScheduledDateTime(livestream.keySwapPromotedAt)}`; + } + if (livestream.keySlot === 'temp' && livestream.status === 'scheduled') { + return 'Key: temp (queued)'; + } + return null; +} + +interface LivestreamActionsProps { + livestream: Livestream; + onDelete: (livestream: Livestream) => void; + onDuplicate: (livestream: Livestream) => void; + isDeletingId: string | null; + isDuplicatingId: string | null; +} + +const livestreamActionIconButtonClassName = + 'pointer-events-auto inline-flex shrink-0 h-12 w-12 items-center justify-center rounded-md border border-border bg-background text-foreground transition-colors hover:bg-muted disabled:opacity-60'; + +function LivestreamActions({ + livestream, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: LivestreamActionsProps) { + const isDuplicating = isDuplicatingId === livestream.id; + + return ( +
+ + +
+ ); +} + +/** + * Renders a compact status badge for a livestream row. + * @param props - Component props. + * @param props.status - Livestream lifecycle status. + * @returns Status badge element. + */ +export function StatusBadge({ status }: { status: LivestreamStatus }) { + const label = statusBadgeLabel(status); + const className = + status === 'draft' + ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100' + : status === 'scheduled' + ? 'border-sky-500/30 bg-sky-500/10 text-sky-950 dark:text-sky-100' + : status === 'live' + ? 'border-amber-500/40 bg-amber-500/15 text-amber-950 dark:text-amber-100' + : status === 'failed' + ? 'border-destructive/40 bg-destructive/10 text-destructive' + : 'border-muted-foreground/30 bg-muted/40 text-muted-foreground'; + + return ( + + {label} + + ); +} + +interface LivestreamSectionProps { + title: string; + description: string; + live?: boolean; + streamed?: boolean; + children: ReactNode; +} + +/** + * Renders a grouped section on the livestreams dashboard list. + * @param props - Section props. + * @returns Section container with heading and content. + */ +export function LivestreamSection({ + title, + description, + live = false, + streamed = false, + children, +}: LivestreamSectionProps) { + const sectionClassName = live + ? 'border-amber-500/40 bg-amber-500/10' + : streamed + ? 'border-muted-foreground/30 bg-muted/20' + : 'border-border bg-background'; + + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + +export interface LivestreamsTableContentProps { + livestreams: Livestream[]; + showScheduledColumn: boolean; + onEdit: (livestream: Livestream) => void; + onDelete: (livestream: Livestream) => void; + onDuplicate: (livestream: Livestream) => void; + isDeletingId: string | null; + isDuplicatingId: string | null; + dimStreamedRows?: boolean; +} + +function LivestreamMobileRow({ + livestream, + showScheduledColumn, + dimStreamedRows = false, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, +}: Omit & { livestream: Livestream }) { + const title = displayTitle(livestream); + const keySwapNote = formatKeySwapNote(livestream); + + return ( +
+
+ ); +} + +/** + * Renders a responsive table of livestreams for dashboard list pages. + * @param props - Table props and row action handlers. + * @returns Livestream list table UI. + */ +export function LivestreamsTableContent({ + livestreams, + showScheduledColumn, + onEdit, + onDelete, + onDuplicate, + isDeletingId, + isDuplicatingId, + dimStreamedRows = false, +}: LivestreamsTableContentProps) { + return ( + <> +
+ {livestreams.map((livestream) => ( + + ))} +
+
+ + + + + {showScheduledColumn ? ( + + ) : null} + + + + + + {livestreams.map((livestream) => { + const title = displayTitle(livestream); + const keySwapNote = formatKeySwapNote(livestream); + return ( + + + {showScheduledColumn ? ( + + ) : null} + + + + ); + })} + +
+ Title + + Scheduled + + Status + + Actions +
+ + + + + + +
+
+
+
+ + ); +} diff --git a/components/livestreams/StreamedLivestreamsHistoryClient.tsx b/components/livestreams/StreamedLivestreamsHistoryClient.tsx new file mode 100644 index 00000000..28a70c21 --- /dev/null +++ b/components/livestreams/StreamedLivestreamsHistoryClient.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { + createLivestreamEditorValues, + LivestreamMetadataModal, + type LivestreamEditorValues, +} from '@/components/livestreams/LivestreamMetadataModal'; +import { LivestreamsTableContent } from '@/components/livestreams/LivestreamsListTable'; +import type { ApiResponse, ConnectedAccountPublic, Livestream } from '@/types'; +import { + toLivestreamConnectionSnapshots, + type LivestreamConnectionSnapshot, +} from '@/lib/livestreams/schedulable-platforms'; + +interface StreamedLivestreamsHistoryResponse extends ApiResponse { + meta?: { + total: number; + limit: number; + offset: number; + }; +} + +/** Must match GET /api/livestreams streamed pagination default `limit`. */ +const STREAMED_HISTORY_PAGE_SIZE = 20; + +/** + * Paginated list of past streamed livestreams with edit and delete actions. + * @returns Streamed livestream history UI. + */ +export function StreamedLivestreamsHistoryClient() { + const [livestreams, setLivestreams] = useState([]); + const [connectionSnapshots, setConnectionSnapshots] = useState( + [] + ); + const [hasLoadedConnections, setHasLoadedConnections] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [isDeletingId, setIsDeletingId] = useState(null); + const [isDuplicatingId, setIsDuplicatingId] = useState(null); + const [editingLivestream, setEditingLivestream] = useState(null); + const [isSavingEdit, setIsSavingEdit] = useState(false); + + const loadHistory = useCallback(async () => { + setIsLoading(true); + try { + const response = await fetch( + `/api/livestreams?status=streamed&limit=${STREAMED_HISTORY_PAGE_SIZE}&offset=${offset}`, + { cache: 'no-store' } + ); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to load streamed livestreams'); + } + + const payload = (await response.json()) as StreamedLivestreamsHistoryResponse; + const data = Array.isArray(payload.data) ? payload.data : []; + const totalCount = payload.meta?.total ?? 0; + const pageSize = STREAMED_HISTORY_PAGE_SIZE; + + if (totalCount === 0) { + setLivestreams([]); + setTotal(0); + if (offset > 0) setOffset(0); + return; + } + + setTotal(totalCount); + + const lastPageOffset = Math.max(0, Math.floor((totalCount - 1) / pageSize) * pageSize); + if (offset > lastPageOffset) { + setOffset(lastPageOffset); + return; + } + + setLivestreams(data); + + if (!hasLoadedConnections) { + const connectionsResponse = await fetch('/api/platforms/connections', { + cache: 'no-store', + }); + if (connectionsResponse.ok) { + const connectionsPayload = (await connectionsResponse.json()) as ApiResponse< + ConnectedAccountPublic[] + >; + setConnectionSnapshots( + toLivestreamConnectionSnapshots( + Array.isArray(connectionsPayload.data) ? connectionsPayload.data : [] + ) + ); + } + setHasLoadedConnections(connectionsResponse.ok); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load streamed livestreams'); + setLivestreams([]); + setTotal(0); + } finally { + setIsLoading(false); + } + }, [hasLoadedConnections, offset]); + + useEffect(() => { + void loadHistory(); + }, [loadHistory]); + + const armedLivestreamsForKeySlot = useMemo( + () => + livestreams.filter( + (row) => + (row.status === 'scheduled' || row.status === 'live') && + row.keySlot && + row.targets.includes('youtube') + ), + [livestreams] + ); + + const scheduledFacebookLivestreams = useMemo( + () => + livestreams.filter( + (row) => + (row.status === 'scheduled' || row.status === 'live') && row.targets.includes('facebook') + ), + [livestreams] + ); + + const openEditLivestream = useCallback(async (livestream: Livestream) => { + try { + const response = await fetch(`/api/livestreams/${livestream.id}`, { cache: 'no-store' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to load livestream'); + } + const payload = (await response.json()) as ApiResponse; + setEditingLivestream(createLivestreamEditorValues(payload.data ?? livestream)); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load livestream'); + } + }, []); + + const handleSaveEdit = useCallback( + async (options?: { + closeAfterSave?: boolean; + suppressErrorToast?: boolean; + values?: LivestreamEditorValues; + }): Promise<{ saved: boolean; livestreamId?: string; message?: string }> => { + const snapshot = options?.values ?? editingLivestream; + if (!snapshot) return { saved: false }; + + setIsSavingEdit(true); + try { + const response = await fetch(`/api/livestreams/${snapshot.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: snapshot.title, + description: snapshot.description, + tags: snapshot.tags, + targets: snapshot.targets, + platforms: snapshot.platforms, + thumbnailR2Key: snapshot.thumbnailR2Key, + scheduledStartTime: snapshot.scheduledStartTime, + scheduledStartTimeZone: snapshot.scheduledStartTimeZone, + }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to save livestream'); + } + if (options?.closeAfterSave !== false) { + setEditingLivestream(null); + } + await loadHistory(); + return { saved: true, livestreamId: snapshot.id }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to save livestream'; + if (!options?.suppressErrorToast) { + toast.error(message); + } + return { saved: false, message }; + } finally { + setIsSavingEdit(false); + } + }, + [editingLivestream, loadHistory] + ); + + const handleDeleteLivestream = useCallback( + async (livestream: Livestream) => { + if (isDeletingId) return; + setIsDeletingId(livestream.id); + try { + const response = await fetch(`/api/livestreams/${livestream.id}`, { method: 'DELETE' }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to delete livestream'); + } + toast.success('Livestream deleted'); + await loadHistory(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete livestream'); + } finally { + setIsDeletingId(null); + } + }, + [isDeletingId, loadHistory] + ); + + const handleDuplicateLivestream = useCallback( + async (livestream: Livestream) => { + if (isDuplicatingId) return; + setIsDuplicatingId(livestream.id); + try { + const response = await fetch('/api/livestreams', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: `${livestream.title} (copy)`, + description: livestream.description, + tags: livestream.tags, + visibility: livestream.visibility, + targets: livestream.targets, + platforms: livestream.platforms, + }), + }); + if (!response.ok) { + const err = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(err?.message ?? 'Failed to duplicate livestream'); + } + toast.success('Livestream duplicated'); + await loadHistory(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to duplicate livestream'); + } finally { + setIsDuplicatingId(null); + } + }, + [isDuplicatingId, loadHistory] + ); + + const canPrev = offset > 0; + const canNext = offset + livestreams.length < total; + + if (isLoading) { + return

Loading streamed livestreams…

; + } + + if (livestreams.length === 0 && total === 0) { + return ( +
+

No streamed livestreams yet

+

+ Past broadcasts will appear here after they end on YouTube. +

+
+ ); + } + + return ( + <> +
+

+ Showing{' '} + {livestreams.length === 0 + ? total === 0 + ? '0' + : '—' + : `${offset + 1}-${offset + livestreams.length}`}{' '} + of {total} +

+ + + +
+ + +
+
+ + setEditingLivestream(null)} + onSave={handleSaveEdit} + onScheduled={loadHistory} + onChange={setEditingLivestream} + armedLivestreamsForKeySlot={armedLivestreamsForKeySlot} + scheduledFacebookLivestreams={scheduledFacebookLivestreams} + onKeySlotChanged={loadHistory} + onFacebookChanged={loadHistory} + /> + + ); +} diff --git a/components/onboarding/OnboardingTour.tsx b/components/onboarding/OnboardingTour.tsx index 67bd1f5d..ef084911 100644 --- a/components/onboarding/OnboardingTour.tsx +++ b/components/onboarding/OnboardingTour.tsx @@ -28,7 +28,7 @@ type StepWithFnTarget = Omit & { target: Step['target'] | (() => HTMLElement | null); }; const WAIT_FOR_TARGET_STEP_IDS = new Set([ - 'drafts-nav-link', + 'uploads-nav-link', 'create-draft-button', 'first-connect-button', 'draft-platforms', @@ -161,19 +161,19 @@ export function OnboardingTour() { () => pathname === '/profile/connections' && hasOnboardingFlow, [hasOnboardingFlow, pathname] ); - const isDraftsWithFlow = useMemo( - () => pathname === '/dashboard/drafts' && hasOnboardingFlow, + const isUploadsWithFlow = useMemo( + () => pathname === '/dashboard/uploads' && hasOnboardingFlow, [hasOnboardingFlow, pathname] ); const run = useMemo( () => isReady && isOnboarding && - (pathname === '/dashboard' || isConnectionsWithFlow || isDraftsWithFlow), - [isReady, isOnboarding, isConnectionsWithFlow, isDraftsWithFlow, pathname] + (pathname === '/dashboard' || isConnectionsWithFlow || isUploadsWithFlow), + [isReady, isOnboarding, isConnectionsWithFlow, isUploadsWithFlow, pathname] ); - // Override the drafts-nav-link step with a function target that picks the + // Override the uploads-nav-link step with a function target that picks the // visible element. A CSS comma-selector uses DOM order, which always returns // the desktop sidebar link first — even on mobile where it lives inside a // `display:none` aside (zero bounding rect → Joyride raises the overlay but @@ -184,20 +184,25 @@ export function OnboardingTour() { const tourSteps = useMemo( () => onboardingSteps.map((step) => { - if (step.id !== 'drafts-nav-link') return step; + if (step.id !== 'uploads-nav-link') return step; return { ...step, target: (): HTMLElement | null => { - const mobile = document.querySelector( - '[data-tour="drafts-nav-link-mobile"]' + const mobileUploads = document.querySelector( + '[data-tour="uploads-nav-link-mobile"]' + ); + const mobileSectionsTrigger = document.querySelector( + '[data-tour="dashboard-sections-trigger-mobile"]' ); const desktop = document.querySelector( - '[data-tour="drafts-nav-link-desktop"]' + '[data-tour="uploads-nav-link-desktop"]' ); - // offsetParent is null when the element or any ancestor has display:none - if (mobile && mobile.offsetParent !== null) return mobile; + if (mobileUploads && mobileUploads.offsetParent !== null) return mobileUploads; + if (mobileSectionsTrigger && mobileSectionsTrigger.offsetParent !== null) { + return mobileSectionsTrigger; + } if (desktop && desktop.offsetParent !== null) return desktop; - return mobile ?? desktop ?? null; + return mobileUploads ?? mobileSectionsTrigger ?? desktop ?? null; }, }; }), @@ -361,13 +366,13 @@ export function OnboardingTour() { return; } - // When advancing from the drafts sidebar link, navigate to the drafts page. + // When advancing from the uploads sidebar link, navigate to the uploads page. if ( type === EVENTS.STEP_AFTER && action !== ACTIONS.PREV && - currentStepId === 'drafts-nav-link' + currentStepId === 'uploads-nav-link' ) { - router.push('/dashboard/drafts?onboardingFlow=true'); + router.push('/dashboard/uploads?onboardingFlow=true'); // Queue step advance to happen after navigation completes const nextStep = Math.max(0, Math.min(eventIndex + 1, onboardingSteps.length - 1)); pendingNavigationStepAdvanceRef.current = nextStep; @@ -426,7 +431,7 @@ export function OnboardingTour() { if ( pathname !== '/dashboard' && pathname !== '/profile/connections' && - pathname !== '/dashboard/drafts' + pathname !== '/dashboard/uploads' ) { return null; } diff --git a/components/onboarding/OnboardingTourGate.tsx b/components/onboarding/OnboardingTourGate.tsx index 11f3e8a1..d9aed6e9 100644 --- a/components/onboarding/OnboardingTourGate.tsx +++ b/components/onboarding/OnboardingTourGate.tsx @@ -16,7 +16,7 @@ function OnboardingTourGateContent() { // Keep this predicate aligned with OnboardingTour route/query checks. const isOnboardingRoute = pathname === '/dashboard' || - (pathname === '/dashboard/drafts' && searchParams.has('onboardingFlow')) || + (pathname === '/dashboard/uploads' && searchParams.has('onboardingFlow')) || (pathname === '/profile/connections' && searchParams.has('onboardingFlow')); if (!isOnboardingRoute) return null; diff --git a/components/onboarding/onboarding-steps.ts b/components/onboarding/onboarding-steps.ts index 90471f97..122babdf 100644 --- a/components/onboarding/onboarding-steps.ts +++ b/components/onboarding/onboarding-steps.ts @@ -34,17 +34,17 @@ export const onboardingSteps: Step[] = [ 'Click Connect to authorise VideoSphere to publish on your behalf. You can always add more platforms later from your profile.', }, { - id: 'drafts-nav-link', + id: 'uploads-nav-link', // NOTE: The actual target is overridden in OnboardingTour.tsx with a // visibility-checking function so the correct element is picked on both // desktop (sidebar) and mobile (tab bar). The string here is a fallback. - target: '[data-tour="drafts-nav-link-desktop"], [data-tour="drafts-nav-link-mobile"]', + target: '[data-tour="uploads-nav-link-desktop"], [data-tour="uploads-nav-link-mobile"]', skipBeacon: true, placement: 'auto', scrollOffset: 80, - title: 'Go to Drafts', + title: 'Go to Uploads', content: - "Now let's head to the Drafts section where you create and manage your video projects.", + "Now let's head to the Uploads section where you create and manage your video projects.", }, { id: 'create-draft-button', diff --git a/components/scheduling/ScheduleDateTimeFields.tsx b/components/scheduling/ScheduleDateTimeFields.tsx index 20f75d19..167688f4 100644 --- a/components/scheduling/ScheduleDateTimeFields.tsx +++ b/components/scheduling/ScheduleDateTimeFields.tsx @@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'; * @property timeStr - Selected wall-clock time (`HH:MM`, 24-hour storage). * @property onDateChange - Called when the calendar date changes or is cleared. * @property onTimeChange - Called when the time input changes. - * @property platform - Platform schedule window (`youtube` or `facebook`). + * @property platform - Platform schedule window (`youtube`, `facebook`, or `sermon_audio`). */ export interface ScheduleDateTimeFieldsProps { dateId: string; @@ -44,7 +44,7 @@ export interface ScheduleDateTimeFieldsProps { /** * Unified shadcn date and time fields for platform schedulers. - * Date uses a calendar popover; time uses a scroll-column picker with typed entry (profile 12h/24h preference). + * Date uses a calendar popover; time uses a single typable field plus scroll-column picker (profile 12h/24h preference). * @param props - Field ids, values, change handlers, and styling. * @returns Date and time field columns for a scheduler row. */ diff --git a/components/scheduling/ScheduleTimePicker.tsx b/components/scheduling/ScheduleTimePicker.tsx index b93f432f..c41072b4 100644 --- a/components/scheduling/ScheduleTimePicker.tsx +++ b/components/scheduling/ScheduleTimePicker.tsx @@ -1,18 +1,17 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { CheckIcon, ChevronDownIcon, ClockIcon } from 'lucide-react'; +import { ChevronDownIcon, ClockIcon } from 'lucide-react'; import { Input } from '@/components/ui/input'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { usePrefers12HourClock } from '@/hooks/useUserClockFormat'; import { buildScheduleTimeStr, formatScheduleTimeLabel, getScheduleHourOptions, normalizeScheduleTimeStr, - parseScheduleHourInput, - parseScheduleMinuteInput, + parseScheduleTimeInput, parseScheduleTimeParts, SCHEDULE_MINUTE_OPTIONS, to12HourParts, @@ -42,12 +41,6 @@ interface TimePickerColumnProps { selected: T; formatOption: (value: T) => string; onSelect: (value: T) => void; - /** When true, shows a text field above the list so values can be typed as well as selected. */ - editable?: boolean; - /** Parses typed digits on commit; return null to keep the current selection. */ - parseTypedValue?: (value: string) => T | null; - /** Maximum digit length while typing. */ - maxInputLength?: number; /** Optional class names for the column wrapper. */ className?: string; } @@ -58,27 +51,10 @@ function TimePickerColumn({ selected, formatOption, onSelect, - editable = false, - parseTypedValue, - maxInputLength = 2, className, }: TimePickerColumnProps) { const listRef = useRef(null); const selectedRef = useRef(null); - const [draftValue, setDraftValue] = useState(null); - const isEditing = draftValue !== null; - - const commitDraft = () => { - if (draftValue === null) { - return; - } - - const parsed = parseTypedValue?.(draftValue); - if (parsed !== null && parsed !== undefined) { - onSelect(parsed); - } - setDraftValue(null); - }; useEffect(() => { selectedRef.current?.scrollIntoView({ block: 'center' }); @@ -110,36 +86,10 @@ function TimePickerColumn({ }, []); return ( -
+

{label}

- {editable ? ( - { - setDraftValue(formatOption(selected)); - event.target.select(); - }} - onChange={(event) => { - setDraftValue(event.target.value.replace(/\D/g, '').slice(0, maxInputLength)); - }} - onBlur={commitDraft} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - commitDraft(); - event.currentTarget.blur(); - } - }} - /> - ) : null}
({ role="option" aria-selected={isSelected} className={cn( - 'flex w-full items-center gap-1 px-1 py-1 text-xs transition-colors hover:bg-muted sm:gap-2 sm:px-2 sm:py-1.5 sm:text-sm', + 'flex w-full items-center justify-center px-2 py-1.5 text-sm tabular-nums transition-colors hover:bg-muted', isSelected && 'bg-primary/10 font-medium text-primary' )} onClick={() => onSelect(option)} > - - {isSelected ? - - {formatOption(option)} - + {formatOption(option)} ); })} @@ -188,7 +133,7 @@ interface MeridiemToggleProps { */ function MeridiemToggle({ selected, onSelect, className }: MeridiemToggleProps) { return ( -
+

AM/PM

@@ -222,7 +167,7 @@ function MeridiemToggle({ selected, onSelect, className }: MeridiemToggleProps) /** * Scroll-column time picker using the signed-in user's saved 12h or 24h preference. - * Hour and minute columns support both scrolling/clicking and direct numeric entry. + * The main field accepts free-form typed times (for example `2:00 pm`); the popover keeps scroll columns. * @param props - Trigger id, value, change handler, and styling. * @returns Popover time picker trigger and panel. */ @@ -236,14 +181,44 @@ export function ScheduleTimePicker({ disabled = false, }: ScheduleTimePickerProps) { const [open, setOpen] = useState(false); + const [draftValue, setDraftValue] = useState(null); const use12Hour = usePrefers12HourClock(); const normalizedTime = normalizeScheduleTimeStr(timeStr); const parsed = parseScheduleTimeParts(normalizedTime) ?? { hour: 12, minute: 0 }; const hourOptions = useMemo(() => getScheduleHourOptions(use12Hour), [use12Hour]); const display12 = to12HourParts(parsed.hour); + const formattedTime = normalizedTime + ? formatScheduleTimeLabel(normalizedTime, { hour12: use12Hour }) + : ''; + const isEditing = draftValue !== null; const updateTime = (hour24: number, minute: number) => { onTimeChange(buildScheduleTimeStr(hour24, minute)); + setDraftValue(null); + }; + + const commitDraft = () => { + if (draftValue === null) { + return; + } + + const trimmed = draftValue.trim(); + if (trimmed === '') { + setDraftValue(null); + return; + } + + const fallbackPeriod = to12HourParts(parsed.hour).period; + const parsedInput = parseScheduleTimeInput(trimmed, { + hour12: use12Hour, + fallbackPeriod, + }); + + if (parsedInput) { + onTimeChange(buildScheduleTimeStr(parsedInput.hour, parsedInput.minute)); + } + + setDraftValue(null); }; return ( @@ -252,43 +227,90 @@ export function ScheduleTimePicker({ {label} - - - + { + setOpen(true); + setDraftValue(formattedTime); + event.target.select(); + }} + onClick={() => { + setOpen(true); + }} + onChange={(event) => { + setDraftValue(event.target.value); + }} + onBlur={commitDraft} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + commitDraft(); + event.currentTarget.blur(); + } + if (event.key === 'Escape') { + event.preventDefault(); + setDraftValue(null); + setOpen(false); + event.currentTarget.blur(); + } + }} + /> + + + +
+ { + event.preventDefault(); + }} + onPointerDown={(event) => { + event.preventDefault(); + }} >
@@ -296,13 +318,7 @@ export function ScheduleTimePicker({ label="Hour" options={hourOptions} selected={use12Hour ? display12.hour12 : parsed.hour} - editable - maxInputLength={2} formatOption={(value) => String(value).padStart(use12Hour ? 1 : 2, '0')} - parseTypedValue={(value) => { - const parsedHour = parseScheduleHourInput(value, use12Hour); - return parsedHour === null ? null : (parsedHour as (typeof hourOptions)[number]); - }} onSelect={(value) => { const hour24 = use12Hour ? to24HourFrom12(Number(value), display12.period) @@ -314,10 +330,7 @@ export function ScheduleTimePicker({ label="Minute" options={SCHEDULE_MINUTE_OPTIONS} selected={parsed.minute} - editable - maxInputLength={2} formatOption={(value) => String(value).padStart(2, '0')} - parseTypedValue={(value) => parseScheduleMinuteInput(value)} onSelect={(minute) => updateTime(parsed.hour, minute)} /> {use12Hour ? ( diff --git a/components/ui/GaussianNoiseBackground.tsx b/components/ui/GaussianNoiseBackground.tsx index f59940ee..989462c6 100644 --- a/components/ui/GaussianNoiseBackground.tsx +++ b/components/ui/GaussianNoiseBackground.tsx @@ -15,8 +15,10 @@ export const PAGE_SEEDS: Record = { '/invite': 721, // Dashboard '/dashboard': 137, - '/dashboard/drafts': 1088, - '/dashboard/history': 1201, + '/dashboard/uploads': 1088, + '/dashboard/uploads/history': 1201, + '/dashboard/livestreams': 1420, + '/dashboard/livestreams/history': 1533, // Profile '/profile': 256, '/profile/connections': 1345, diff --git a/components/ui/sheet.tsx b/components/ui/sheet.tsx new file mode 100644 index 00000000..938b952f --- /dev/null +++ b/components/ui/sheet.tsx @@ -0,0 +1,154 @@ +'use client'; + +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +/** Root component that controls sheet open and closed state. */ +const Sheet = DialogPrimitive.Root; + +/** Opens the sheet when activated. */ +const SheetTrigger = DialogPrimitive.Trigger; + +/** Closes the sheet when activated. */ +const SheetClose = DialogPrimitive.Close; + +/** Portals sheet content outside the DOM hierarchy. */ +const SheetPortal = DialogPrimitive.Portal; + +/** + * Full-screen dimmed backdrop shown behind an open sheet. + * @param props - Radix overlay props plus optional `className`. + * @param ref - Ref forwarded to the overlay element. + * @returns Sheet overlay element. + */ +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const sheetVariants = cva( + 'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out', + { + variants: { + side: { + top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top', + bottom: + 'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom', + left: 'inset-y-0 left-0 h-full w-3/4 max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left', + right: + 'inset-y-0 right-0 h-full w-3/4 max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right', + }, + }, + defaultVariants: { + side: 'right', + }, + } +); + +/** + * Props for {@link SheetContent}. + * @property side - Edge from which the panel slides in. Defaults to `right`. + */ +export interface SheetContentProps + extends + React.ComponentPropsWithoutRef, + VariantProps {} + +/** + * Sliding sheet panel rendered in a portal with overlay and close control. + * @param props - Sheet content props, including optional `side` placement. + * @param ref - Ref forwarded to the content element. + * @returns Sheet panel element. + */ +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = 'right', className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +SheetContent.displayName = DialogPrimitive.Content.displayName; + +/** + * Layout wrapper for sheet title and description. + * @param props - Standard div HTML attributes. + * @returns Sheet header container. + */ +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); +SheetHeader.displayName = 'SheetHeader'; + +/** + * Accessible title for the sheet dialog. + * @param props - Radix dialog title props plus optional `className`. + * @param ref - Ref forwarded to the title element. + * @returns Sheet title element. + */ +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetTitle.displayName = DialogPrimitive.Title.displayName; + +/** + * Accessible description for the sheet dialog. + * @param props - Radix dialog description props plus optional `className`. + * @param ref - Ref forwarded to the description element. + * @returns Sheet description element. + */ +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SheetDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +}; diff --git a/components/ui/slider.tsx b/components/ui/slider.tsx new file mode 100644 index 00000000..81c7e0c0 --- /dev/null +++ b/components/ui/slider.tsx @@ -0,0 +1,63 @@ +'use client'; + +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; + +import { cn } from '@/lib/utils'; + +/** Root range slider container. */ +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +/** Full-width track behind the selected range. */ +const SliderTrack = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SliderTrack.displayName = SliderPrimitive.Track.displayName; + +/** Highlighted segment between the two thumbs. */ +const SliderRange = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SliderRange.displayName = SliderPrimitive.Range.displayName; + +/** Draggable thumb control. */ +const SliderThumb = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SliderThumb.displayName = SliderPrimitive.Thumb.displayName; + +export { Slider, SliderTrack, SliderRange, SliderThumb }; diff --git a/components/youtube-import/TrimRangeSlider.tsx b/components/youtube-import/TrimRangeSlider.tsx new file mode 100644 index 00000000..45245be1 --- /dev/null +++ b/components/youtube-import/TrimRangeSlider.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { Slider, SliderRange, SliderThumb, SliderTrack } from '@/components/ui/slider'; +import type { YouTubePlayerHandle } from '@/components/youtube-import/YouTubePreviewPlayer'; +import { cn } from '@/lib/utils'; +import { formatVideoDuration } from '@/lib/format-video-duration'; + +/** Debounce interval before snapping trim handles to nearby keyframes. */ +const KEYFRAME_SNAP_DEBOUNCE_MS = 250; + +/** Debounce interval for preview seeks while dragging trim handles. */ +const PREVIEW_SEEK_THROTTLE_MS = 200; + +/** + * Props for {@link TrimRangeSlider}. + */ +export interface TrimRangeSliderProps { + /** Total source duration in seconds. */ + durationSeconds: number; + /** YouTube video id used for keyframe probing. */ + youtubeVideoId: string; + /** Current trim range in seconds. */ + value: { startSeconds: number; endSeconds: number }; + /** + * Called when the user adjusts either handle (raw while dragging, snapped after settle). + * @param value - Updated trim range. + */ + onChange: (value: { startSeconds: number; endSeconds: number }) => void; + /** Optional preview player handle to seek while trimming. */ + playerHandle?: YouTubePlayerHandle; +} + +/** + * Formats seconds as a YouTube-style duration label (`H:MM:SS` or `M:SS`). + * @param seconds - Duration in seconds. + * @returns Human-readable timestamp label. + */ +export function formatTrimSeconds(seconds: number): string { + return formatVideoDuration(seconds); +} + +/** + * Picks the keyframe timestamp closest to the dragged position. + * @param targetSeconds - Raw handle position in seconds. + * @param candidates - Nearby keyframe timestamps from the API. + * @returns Closest candidate, or the raw target when none are returned. + */ +export function pickClosestKeyframe(targetSeconds: number, candidates: number[]): number { + if (candidates.length === 0) { + return targetSeconds; + } + + return candidates.reduce((closest, candidate) => + Math.abs(candidate - targetSeconds) < Math.abs(closest - targetSeconds) ? candidate : closest + ); +} + +/** + * Two-handle trim slider with debounced snap-to-keyframe behavior. + * @param props - Slider configuration. + * @returns Trim range slider UI. + */ +export function TrimRangeSlider({ + durationSeconds, + youtubeVideoId, + value, + onChange, + playerHandle, +}: TrimRangeSliderProps) { + const [snappingHandle, setSnappingHandle] = useState<'start' | 'end' | null>(null); + const debounceTimerRef = useRef | null>(null); + const pendingSnapRef = useRef<{ handle: 'start' | 'end'; seconds: number } | null>(null); + const valueRef = useRef(value); + const onChangeRef = useRef(onChange); + const previewThrottleTimerRef = useRef | null>(null); + const pendingPreviewSecondsRef = useRef(null); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const runKeyframeSnap = useCallback( + async (handle: 'start' | 'end', rawSeconds: number) => { + setSnappingHandle(handle); + try { + const params = new URLSearchParams({ + youtubeVideoId, + near: String(rawSeconds), + }); + const response = await fetch(`/api/youtube-import/keyframes?${params.toString()}`); + if (!response.ok) { + return; + } + + const body: { data?: { keyframeSeconds?: number[] } } = await response.json(); + const candidates = body.data?.keyframeSeconds ?? []; + const snappedSeconds = pickClosestKeyframe(rawSeconds, candidates); + const current = valueRef.current; + + const nextValue = + handle === 'start' + ? { + startSeconds: Math.min(snappedSeconds, current.endSeconds), + endSeconds: current.endSeconds, + } + : { + startSeconds: current.startSeconds, + endSeconds: Math.max(snappedSeconds, current.startSeconds), + }; + + if ( + nextValue.startSeconds !== current.startSeconds || + nextValue.endSeconds !== current.endSeconds + ) { + onChangeRef.current(nextValue); + } + } catch (error) { + console.error('[TrimRangeSlider] Keyframe snap request failed:', error); + } finally { + setSnappingHandle((currentHandle) => (currentHandle === handle ? null : currentHandle)); + } + }, + [youtubeVideoId] + ); + + const scheduleKeyframeSnap = useCallback( + (handle: 'start' | 'end', seconds: number) => { + pendingSnapRef.current = { handle, seconds }; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + debounceTimerRef.current = setTimeout(() => { + const pending = pendingSnapRef.current; + pendingSnapRef.current = null; + if (!pending) { + return; + } + void runKeyframeSnap(pending.handle, pending.seconds); + }, KEYFRAME_SNAP_DEBOUNCE_MS); + }, + [runKeyframeSnap] + ); + + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + if (previewThrottleTimerRef.current) { + clearTimeout(previewThrottleTimerRef.current); + } + }; + }, []); + + const schedulePreviewAt = useCallback( + (seconds: number) => { + if (!playerHandle) { + return; + } + + pendingPreviewSecondsRef.current = seconds; + if (previewThrottleTimerRef.current) { + return; + } + + previewThrottleTimerRef.current = setTimeout(() => { + previewThrottleTimerRef.current = null; + const pendingSeconds = pendingPreviewSecondsRef.current; + if (pendingSeconds != null) { + playerHandle.previewAt(pendingSeconds); + } + }, PREVIEW_SEEK_THROTTLE_MS); + }, + [playerHandle] + ); + + const handleValueChange = useCallback( + ([startSeconds, endSeconds]: number[]) => { + const previous = valueRef.current; + const startMoved = Math.abs(startSeconds - previous.startSeconds); + const endMoved = Math.abs(endSeconds - previous.endSeconds); + const movedHandle: 'start' | 'end' = startMoved >= endMoved ? 'start' : 'end'; + const movedSeconds = movedHandle === 'start' ? startSeconds : endSeconds; + + onChangeRef.current({ startSeconds, endSeconds }); + schedulePreviewAt(movedSeconds); + scheduleKeyframeSnap(movedHandle, movedSeconds); + }, + [scheduleKeyframeSnap, schedulePreviewAt] + ); + + const disabled = durationSeconds <= 0; + + return ( +
+
+ {formatTrimSeconds(value.startSeconds)} + {formatTrimSeconds(value.endSeconds)} +
+ + + + + + + {snappingHandle === 'start' ? ( + + + {snappingHandle === 'end' ? ( + + +
+ ); +} diff --git a/components/youtube-import/YouTubeImportModal.tsx b/components/youtube-import/YouTubeImportModal.tsx new file mode 100644 index 00000000..10fe8d23 --- /dev/null +++ b/components/youtube-import/YouTubeImportModal.tsx @@ -0,0 +1,750 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; +import { CircleCheck, Loader2 } from 'lucide-react'; +import { formatVideoDuration } from '@/lib/format-video-duration'; +import { getLivestreamListThumbnailUrl } from '@/lib/livestreams/youtube-thumbnail-preview'; +import { formatScheduledDateTime } from '@/components/livestreams/LivestreamsListTable'; +import { TrimRangeSlider } from '@/components/youtube-import/TrimRangeSlider'; +import { + YouTubePreviewPlayer, + type YouTubePlayerHandle, +} from '@/components/youtube-import/YouTubePreviewPlayer'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { + formatYoutubeImportStatusLabel, + isActiveYoutubeImportStatus, +} from '@/lib/youtube-import/import-job-ui'; +import type { ApiResponse, Livestream, YoutubeImportJob } from '@/types'; + +/** Poll interval for in-flight import jobs (matches draft upload polling). */ +const IMPORT_JOB_POLL_INTERVAL_MS = 3000; + +/** Page size for streamed livestream history in the YouTube import source picker. */ +const LIVESTREAM_IMPORT_PAGE_SIZE = 2; + +/** + * Modal step identifiers for the YouTube import flow. + */ +type YouTubeImportModalStep = 'source' | 'editor' | 'progress'; + +/** + * Resolved YouTube source metadata from the resolve API. + */ +interface ResolvedYouTubeSource { + youtubeVideoId: string; + title: string; + durationSeconds: number; + thumbnailUrl: string; + previewStreamUrl: string; + previewExpiresAt: number; + sourceUrl?: string; + livestreamId?: string; +} + +interface LivestreamsListResponse extends ApiResponse { + meta?: { + total: number; + limit: number; + offset: number; + }; +} + +/** + * Props for {@link YouTubeImportModal}. + */ +export interface YouTubeImportModalProps { + /** Draft that will receive the imported video. */ + draftId: string; + /** Whether the modal is open. */ + open: boolean; + /** + * Called when the modal open state changes. + * @param open - Next open state. + */ + onOpenChange: (open: boolean) => void; + /** + * Called when import staging completes so the parent can refresh draft import state. + */ + onImportComplete: () => void | Promise; +} + +/** + * Modal for importing and trimming a YouTube video into a draft. + * @param props - Modal configuration. + * @returns YouTube import modal UI. + */ +export function YouTubeImportModal({ + draftId, + open, + onOpenChange, + onImportComplete, +}: YouTubeImportModalProps) { + const [step, setStep] = useState('source'); + const [resolvedSource, setResolvedSource] = useState(null); + const [trimRange, setTrimRange] = useState({ startSeconds: 0, endSeconds: 0 }); + const [jobId, setJobId] = useState(null); + const [jobStatus, setJobStatus] = useState(null); + + const [sourceUrlInput, setSourceUrlInput] = useState(''); + const [livestreams, setLivestreams] = useState([]); + const [livestreamsTotal, setLivestreamsTotal] = useState(0); + const [isLoadingLivestreams, setIsLoadingLivestreams] = useState(false); + const [isCheckingActiveJob, setIsCheckingActiveJob] = useState(false); + const [isResolving, setIsResolving] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [showCancelImportConfirm, setShowCancelImportConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [conflictActiveJobId, setConflictActiveJobId] = useState(null); + const [showCompletionSuccess, setShowCompletionSuccess] = useState(false); + + const playerRef = useRef(null); + const playerHandle = useMemo( + () => ({ + previewAt(seconds: number) { + playerRef.current?.previewAt(seconds); + }, + getCurrentTime() { + return playerRef.current?.getCurrentTime() ?? 0; + }, + }), + [] + ); + const completionHandledRef = useRef(false); + + const resetModalState = useCallback(() => { + setStep('source'); + setResolvedSource(null); + setTrimRange({ startSeconds: 0, endSeconds: 0 }); + setJobId(null); + setJobStatus(null); + setSourceUrlInput(''); + setLivestreams([]); + setLivestreamsTotal(0); + setErrorMessage(null); + setConflictActiveJobId(null); + setShowCompletionSuccess(false); + setIsResolving(false); + setIsStarting(false); + setIsCancelling(false); + setShowCancelImportConfirm(false); + completionHandledRef.current = false; + }, []); + + const handleDialogOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + const importInFlight = + step === 'progress' && jobStatus && isActiveYoutubeImportStatus(jobStatus.status); + if (!importInFlight) { + resetModalState(); + } + } + onOpenChange(nextOpen); + }, + [jobStatus, onOpenChange, resetModalState, step] + ); + + const loadLivestreams = useCallback(async (offset: number, options?: { append?: boolean }) => { + const append = options?.append ?? false; + setIsLoadingLivestreams(true); + try { + const response = await fetch( + `/api/livestreams?status=streamed&for=youtube-import&limit=${LIVESTREAM_IMPORT_PAGE_SIZE}&offset=${offset}`, + { cache: 'no-store' } + ); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to load past livestreams'); + } + + const payload = (await response.json()) as LivestreamsListResponse; + const rows = Array.isArray(payload.data) ? payload.data : []; + setLivestreams((current) => (append ? [...current, ...rows] : rows)); + setLivestreamsTotal(payload.meta?.total ?? rows.length); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to load past livestreams'); + if (!options?.append) { + setLivestreams([]); + setLivestreamsTotal(0); + } + } finally { + setIsLoadingLivestreams(false); + } + }, []); + + const resolveSource = useCallback( + async (body: { sourceUrl: string } | { livestreamId: string }) => { + setIsResolving(true); + setErrorMessage(null); + setConflictActiveJobId(null); + + try { + const response = await fetch('/api/youtube-import/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const payload = (await response.json().catch(() => null)) as + | ApiResponse<{ + youtubeVideoId: string; + title: string; + durationSeconds: number; + thumbnailUrl: string; + previewStreamUrl: string; + previewExpiresAt: number; + }> + | { message?: string } + | null; + + if (!response.ok || !payload || !('data' in payload) || !payload.data) { + throw new Error( + payload && 'message' in payload && payload.message + ? payload.message + : 'Failed to resolve YouTube source' + ); + } + + const resolved: ResolvedYouTubeSource = { + ...payload.data, + ...('sourceUrl' in body ? { sourceUrl: body.sourceUrl } : {}), + ...('livestreamId' in body ? { livestreamId: body.livestreamId } : {}), + }; + + setResolvedSource(resolved); + setTrimRange({ startSeconds: 0, endSeconds: resolved.durationSeconds }); + setStep('editor'); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : 'Failed to resolve YouTube source' + ); + } finally { + setIsResolving(false); + } + }, + [] + ); + + const handleUsePastedLink = useCallback(async () => { + const sourceUrl = sourceUrlInput.trim(); + if (!sourceUrl) { + setErrorMessage('Enter a YouTube URL to continue.'); + return; + } + await resolveSource({ sourceUrl }); + }, [resolveSource, sourceUrlInput]); + + const handleSelectLivestream = useCallback( + async (livestreamId: string) => { + await resolveSource({ livestreamId }); + }, + [resolveSource] + ); + + const handleStartImport = useCallback(async () => { + if (!resolvedSource) return; + + setIsStarting(true); + setErrorMessage(null); + setConflictActiveJobId(null); + + try { + const response = await fetch('/api/youtube-import/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + draftId, + youtubeVideoId: resolvedSource.youtubeVideoId, + livestreamId: resolvedSource.livestreamId, + sourceUrl: resolvedSource.sourceUrl, + startSeconds: trimRange.startSeconds, + endSeconds: trimRange.endSeconds, + }), + }); + + const payload = (await response.json().catch(() => null)) as { + jobId?: string; + activeJobId?: string | null; + message?: string; + } | null; + + if (response.status === 409) { + setConflictActiveJobId(payload?.activeJobId ?? null); + setErrorMessage(null); + return; + } + + if (!response.ok || !payload?.jobId) { + throw new Error(payload?.message ?? 'Failed to start YouTube import'); + } + + setJobId(payload.jobId); + setStep('progress'); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to start YouTube import'); + } finally { + setIsStarting(false); + } + }, [draftId, resolvedSource, trimRange.endSeconds, trimRange.startSeconds]); + + const handleWatchExistingImport = useCallback(() => { + if (!conflictActiveJobId) return; + setJobId(conflictActiveJobId); + setConflictActiveJobId(null); + setErrorMessage(null); + setStep('progress'); + }, [conflictActiveJobId]); + + const handleRetryAfterFailure = useCallback(() => { + setStep('source'); + setResolvedSource(null); + setJobId(null); + setJobStatus(null); + setErrorMessage(null); + setShowCompletionSuccess(false); + completionHandledRef.current = false; + void loadLivestreams(0); + }, [loadLivestreams]); + + const handleCancelImport = useCallback(async () => { + if (!jobId) return; + + setIsCancelling(true); + setErrorMessage(null); + try { + const response = await fetch(`/api/youtube-import/${jobId}/cancel`, { method: 'POST' }); + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { message?: string } | null; + throw new Error(payload?.message ?? 'Failed to cancel import'); + } + setShowCancelImportConfirm(false); + handleRetryAfterFailure(); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to cancel import'); + } finally { + setIsCancelling(false); + } + }, [handleRetryAfterFailure, jobId]); + + useEffect(() => { + if (!open) return; + + let cancelled = false; + setIsCheckingActiveJob(true); + + void (async () => { + try { + const response = await fetch('/api/youtube-import/active', { cache: 'no-store' }); + if (!response.ok || cancelled) return; + + const payload = (await response.json()) as { job: YoutubeImportJob | null }; + if (payload.job) { + setJobId(payload.job.id); + setJobStatus(payload.job); + setStep('progress'); + } + } catch { + if (!cancelled) { + setErrorMessage('Failed to check for an active import job'); + } + } finally { + if (!cancelled) { + setIsCheckingActiveJob(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open || step !== 'source' || jobId) return; + void loadLivestreams(0); + }, [jobId, loadLivestreams, open, step]); + + useEffect(() => { + if (!open || step !== 'progress' || !jobId) return; + + let disposed = false; + const controller = new AbortController(); + + const pollJob = async () => { + try { + const response = await fetch(`/api/youtube-import/${jobId}`, { + cache: 'no-store', + signal: controller.signal, + }); + if (!response.ok || disposed) return; + + const payload = (await response.json()) as ApiResponse; + if (!payload.data || disposed) return; + + setJobStatus(payload.data); + } catch { + if (controller.signal.aborted || disposed) return; + } + }; + + void pollJob(); + const intervalId = window.setInterval(() => { + void pollJob(); + }, IMPORT_JOB_POLL_INTERVAL_MS); + + return () => { + disposed = true; + controller.abort(); + window.clearInterval(intervalId); + }; + }, [jobId, open, step]); + + useEffect(() => { + if (!jobStatus || jobStatus.status !== 'completed' || completionHandledRef.current) { + return; + } + + completionHandledRef.current = true; + setShowCompletionSuccess(true); + void onImportComplete(); + }, [jobStatus, onImportComplete]); + + const canShowMoreLivestreams = livestreams.length < livestreamsTotal; + + return ( + + + + Import from YouTube + + {step === 'source' + ? 'Paste a YouTube link or choose a past livestream to import into this draft.' + : step === 'editor' + ? 'Preview the source and choose the section to import.' + : 'Your import is running in the background.'} + + + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + + {conflictActiveJobId ? ( +
+

You already have an import in progress.

+ +
+ ) : null} + + {isCheckingActiveJob ? ( +
+ + Checking for an in-progress import… +
+ ) : null} + + {step === 'source' && !isCheckingActiveJob ? ( +
+
+ +
+ setSourceUrlInput(event.target.value)} + placeholder="https://www.youtube.com/watch?v=…" + disabled={isResolving} + /> + +
+
+ +
+
+

Pick a past livestream

+

+ Completed broadcasts from your VideoSphere livestream history. +

+
+ + {isLoadingLivestreams ? ( +
+ + Loading livestreams… +
+ ) : livestreams.length === 0 ? ( +

+ No past YouTube livestreams are available to import. Only ended VideoSphere + livestreams that were scheduled on YouTube appear here. +

+ ) : ( +
    + {livestreams.map((livestream) => { + const thumbnailUrl = getLivestreamListThumbnailUrl(livestream); + return ( +
  • + +
  • + ); + })} +
+ )} + + {livestreamsTotal > 0 ? ( +
+

+ Showing {livestreams.length} of {livestreamsTotal} +

+ {canShowMoreLivestreams ? ( + + ) : null} +
+ ) : null} +
+
+ ) : null} + + {step === 'editor' && resolvedSource ? ( +
+
+

{resolvedSource.title}

+

+ Duration: {formatVideoDuration(resolvedSource.durationSeconds)} +

+
+ + } + /> + +

+ Preview uses the same yt-dlp media source as import, so scrubbing should match the + trimmed result. Private videos must still be accessible to your connected YouTube + account. +

+ + + + + + + +
+ ) : null} + + {step === 'progress' && jobStatus ? ( +
+ {showCompletionSuccess ? ( +
+
+ + Video staged for this draft +
+

+ Close this window and use Upload & Save{' '} + in the draft when your metadata is ready. +

+ + + +
+ ) : jobStatus.status === 'failed' ? ( +
+

+ {jobStatus.errorMessage?.trim() || 'The import failed.'} +

+ +
+ ) : ( + <> +
+
+ {formatYoutubeImportStatusLabel(jobStatus.status)} + {jobStatus.progressPercent}% +
+ +
+ + {isActiveYoutubeImportStatus(jobStatus.status) ? ( + + + + + ) : null} + + )} +
+ ) : null} +
+ + { + if (!open && !isCancelling) { + setShowCancelImportConfirm(false); + } + }} + > + + + Cancel YouTube import? + + This stops the current import. Any partial download will be discarded and you can + choose a different source afterward. + + + + Keep importing + { + event.preventDefault(); + void handleCancelImport(); + }} + disabled={isCancelling} + > + {isCancelling ? 'Cancelling…' : 'Cancel import'} + + + + +
+ ); +} diff --git a/components/youtube-import/YouTubePreviewPlayer.tsx b/components/youtube-import/YouTubePreviewPlayer.tsx new file mode 100644 index 00000000..1ab7d968 --- /dev/null +++ b/components/youtube-import/YouTubePreviewPlayer.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import type { ApiResponse } from '@/types'; + +/** Refresh preview URLs one minute before yt-dlp reports expiry. */ +const PREVIEW_URL_REFRESH_BUFFER_MS = 60_000; + +/** Empty WebVTT track — proxied preview has no captions; satisfies media caption lint. */ +const PREVIEW_NO_CAPTIONS_TRACK_SRC = + 'data:text/vtt;charset=utf-8,' + + encodeURIComponent('WEBVTT\n\nNOTE No captions for import preview\n'); + +/** + * Imperative handle for controlling YouTube preview playback from sibling components. + */ +export interface YouTubePlayerHandle { + /** + * Seeks the preview player to the given timestamp. + * @param seconds - Target playback position in seconds. + */ + previewAt(seconds: number): void; + /** + * Returns the player's current playback position in seconds. + * @returns Current time in seconds, or 0 when the player is not ready. + */ + getCurrentTime(): number; +} + +/** + * Props for {@link YouTubePreviewPlayer}. + */ +export interface YouTubePreviewPlayerProps { + /** YouTube video id to preview. */ + youtubeVideoId: string; + /** Same-origin proxied preview stream URL from resolve. */ + streamUrl: string; + /** Approximate Unix expiry for the proxied preview media URL. */ + previewExpiresAt: number; + /** + * Called once when the player is ready and duration is known. + * @param seconds - Total video duration in seconds. + */ + onDurationKnown?: (seconds: number) => void; + /** Optional ref receiving imperative playback controls. */ + playerRef?: React.RefObject; +} + +/** + * HTML5 preview player for import trim using a proxied yt-dlp media stream. + * @param props - Player configuration. + * @returns Preview player container element. + */ +export function YouTubePreviewPlayer({ + youtubeVideoId, + streamUrl, + previewExpiresAt, + onDurationKnown, + playerRef, +}: YouTubePreviewPlayerProps) { + const videoRef = useRef(null); + const onDurationKnownRef = useRef(onDurationKnown); + const pendingPreviewSecondsRef = useRef(null); + const refreshTimerRef = useRef | null>(null); + const [refreshKey, setRefreshKey] = useState(0); + const [expiresAt, setExpiresAt] = useState(previewExpiresAt); + const [playbackError, setPlaybackError] = useState(null); + + const streamSrc = useMemo(() => { + const url = new URL(streamUrl, 'http://localhost'); + if (refreshKey > 0) { + url.searchParams.set('refresh', '1'); + } + return `${url.pathname}${url.search}`; + }, [refreshKey, streamUrl]); + + useEffect(() => { + onDurationKnownRef.current = onDurationKnown; + }, [onDurationKnown]); + + useEffect(() => { + setRefreshKey(0); + setExpiresAt(previewExpiresAt); + setPlaybackError(null); + pendingPreviewSecondsRef.current = null; + }, [previewExpiresAt, streamUrl, youtubeVideoId]); + + const previewAt = useCallback((seconds: number) => { + const video = videoRef.current; + const clampedSeconds = Math.max(0, seconds); + if (!video || video.readyState < HTMLMediaElement.HAVE_METADATA) { + pendingPreviewSecondsRef.current = clampedSeconds; + return; + } + + video.currentTime = clampedSeconds; + }, []); + + useImperativeHandle( + playerRef, + () => ({ + previewAt(seconds: number) { + previewAt(seconds); + }, + getCurrentTime() { + return videoRef.current?.currentTime ?? 0; + }, + }), + [previewAt] + ); + + useEffect(() => { + const clearRefreshTimer = () => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + + const refreshPreview = () => { + void (async () => { + try { + const params = new URLSearchParams({ + youtubeVideoId, + refresh: '1', + }); + const response = await fetch(`/api/youtube-import/preview?${params.toString()}`, { + cache: 'no-store', + }); + if (!response.ok) { + throw new Error('Failed to refresh preview media'); + } + + const body = (await response.json()) as ApiResponse<{ + streamUrl: string; + expiresAt: number; + }>; + setExpiresAt(body.data.expiresAt); + setRefreshKey((current) => current + 1); + } catch (error) { + console.error('[YouTubePreviewPlayer] Failed to refresh preview media:', error); + } + })(); + }; + + const delayMs = expiresAt - Date.now() - PREVIEW_URL_REFRESH_BUFFER_MS; + if (delayMs <= 0) { + refreshPreview(); + return clearRefreshTimer; + } + + refreshTimerRef.current = setTimeout(refreshPreview, delayMs); + + return clearRefreshTimer; + }, [expiresAt, youtubeVideoId]); + + const handleLoadedMetadata = () => { + const video = videoRef.current; + if (!video) { + return; + } + + setPlaybackError(null); + + if (Number.isFinite(video.duration) && video.duration > 0) { + onDurationKnownRef.current?.(video.duration); + } + + const pendingSeconds = pendingPreviewSecondsRef.current; + if (pendingSeconds != null) { + pendingPreviewSecondsRef.current = null; + video.currentTime = pendingSeconds; + } + }; + + const handleVideoError = () => { + if (refreshKey === 0) { + setRefreshKey(1); + return; + } + + setPlaybackError( + 'Preview playback failed. Confirm the video is accessible on YouTube and try again.' + ); + }; + + return ( +
+
+ +
+ {playbackError ? ( +

+ {playbackError} +

+ ) : null} +
+ ); +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 00b6fb95..bcccf786 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -33,6 +33,7 @@ export default defineConfig({ { text: 'Code Quality', link: '/code-quality' }, { text: 'Testing', link: '/testing' }, { text: 'Deployment Guide', link: '/deployment-guide' }, + { text: 'Local Docker Testing', link: '/local-docker-testing' }, ], }, { diff --git a/docs/index.md b/docs/index.md index a64bdbc5..96576450 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ Use these docs to run a production deployment, configure platform integrations, ## Start Here - [Deployment Guide](/deployment-guide) — run the pre-built Docker image (Portainer or Compose) +- [Local Docker Testing](/local-docker-testing) — build and run the image on your machine - [Daily Dev Workflow](/daily-dev-workflow) — local development checklist and pnpm scripts - [MongoDB Data Model](/mongodb-data-model) - [Code Quality](/code-quality) diff --git a/docs/local-docker-testing.md b/docs/local-docker-testing.md new file mode 100644 index 00000000..2ba98fb9 --- /dev/null +++ b/docs/local-docker-testing.md @@ -0,0 +1,122 @@ +# Local Docker Image Testing + +Use this workflow to build the production Docker image on your machine and run it against your local `.env.local` — the same image CI publishes, without pushing to a registry. + +Works with **Docker** or **Podman** (commands below use `docker`; with Podman, `docker` is often an alias, or substitute `podman` directly). + +## When to use this + +- Before merging `Dockerfile` or production-runtime changes +- To reproduce production behavior locally (`pnpm dev` vs the standalone Next.js server) +- To smoke-test OAuth, uploads, or other features in the container + +For day-to-day development, prefer `pnpm dev` — see [Daily Dev Workflow](/daily-dev-workflow). + +## Prerequisites + +1. `.env.local` configured (copy from `.env.example`). Required at minimum: + - `MONGO_ROOT_PASSWORD` + - `JWT_SECRET` + - `TOKEN_ENCRYPTION_KEY` + - `NEXT_PUBLIC_APP_URL` — use `http://localhost:9624` when testing on the same machine +2. Google sign-in (optional): add `http://localhost:9624/api/auth/oauth/callback` as an authorized redirect URI in Google Cloud Console + +## 1. Build the image + +On **amd64** (typical PC / laptop): + +```bash +./scripts/docker-build-platform.sh linux/amd64 videosphere:amd64-test +``` + +On **arm64** (Apple Silicon, Raspberry Pi, Odroid): + +```bash +./scripts/docker-build-platform.sh linux/arm64 videosphere:arm64-test +``` + +The script defaults to `linux/arm64` and tag `videosphere:local-test` when no arguments are given. Podman tags the result as `localhost/videosphere:amd64-test`. + +Cross-arch builds (e.g. arm64 on amd64) need QEMU — see comments in `scripts/docker-build-platform.sh`. + +## 2. Start MongoDB + +Use Compose so Mongo gets a persistent volume and health checks: + +```bash +docker compose --env-file .env.local up -d mongo +``` + +Compose reads `MONGO_ROOT_PASSWORD` from `.env.local` for Mongo initialization. The container name is `videosphere-mongo` on network `videosphere_default`. + +## 3. Run the app container + +**Important:** Two details that are easy to get wrong: + +1. **`MONGODB_URI` in `.env.local` uses `localhost`** — correct for `pnpm dev`, wrong inside a container. Override it to use the Compose service hostname `videosphere-mongo`. +2. **Shell expansion for `-e MONGODB_URI=...`** — `${MONGO_ROOT_PASSWORD}` is expanded by your shell when you run `docker run`, not by Docker from `--env-file`. Source `.env.local` first so the password is in your shell. + +```bash +set -a +source .env.local +set +a + +docker run -d \ + --name videosphere-test \ + --network videosphere_default \ + -p 9624:9624 \ + --env-file .env.local \ + -e "MONGODB_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD}@videosphere-mongo:27017/videosphere?authSource=admin" \ + localhost/videosphere:amd64-test +``` + +Replace `amd64-test` with your tag if you built a different one. + +## 4. Verify + +1. Open [http://localhost:9624](http://localhost:9624) (or your `NEXT_PUBLIC_APP_URL`) +2. Sign in or complete first-run setup at `/setup` +3. Check logs: `docker logs -f videosphere-test` + +If auth fails with `MongoServerError: Authentication failed` in the logs, the `MONGODB_URI` override did not get the password — re-run step 3 after `source .env.local`. + +## 5. Clean up + +```bash +docker rm -f videosphere-test +docker compose --env-file .env.local down +``` + +To remove the built image: `docker rmi localhost/videosphere:amd64-test` + +Intermediate `` images from builds can be pruned with `docker image prune`. + +## Quick reference (copy-paste) + +```bash +# Build (amd64) +./scripts/docker-build-platform.sh linux/amd64 videosphere:amd64-test + +# Mongo +docker compose --env-file .env.local up -d mongo + +# App +set -a && source .env.local && set +a +docker run -d \ + --name videosphere-test \ + --network videosphere_default \ + -p 9624:9624 \ + --env-file .env.local \ + -e "MONGODB_URI=mongodb://${MONGO_ROOT_USER:-admin}:${MONGO_ROOT_PASSWORD}@videosphere-mongo:27017/videosphere?authSource=admin" \ + localhost/videosphere:amd64-test + +# Teardown +docker rm -f videosphere-test +docker compose --env-file .env.local down +``` + +## Related + +- [Deployment Guide](/deployment-guide) — production with pre-built registry images +- [SETUP.md](https://github.com/threehappypenguins/VideoSphere/blob/main/SETUP.md) — first-run setup and Mongo options +- [CONTRIBUTING.md](https://github.com/threehappypenguins/VideoSphere/blob/main/CONTRIBUTING.md) — multi-arch build verification before merge diff --git a/lib/api/distribute.ts b/lib/api/distribute.ts index 37941e7b..24944827 100644 --- a/lib/api/distribute.ts +++ b/lib/api/distribute.ts @@ -61,7 +61,10 @@ import { } from '@/lib/platforms/sermon-audio'; import { sermonAudioCrossPublishHasActiveSelection } from '@/lib/platforms/sermon-audio-cross-publish'; import { latestPlatformUploadsPerPlatform } from '@/lib/utils/platform-uploads'; -import { isPlatformUploadDistributionComplete } from '@/lib/uploads/status'; +import { + isPlatformUploadDistributionComplete, + resolveSermonAudioTerminalUploadStatus, +} from '@/lib/uploads/status'; export type { RetryabilityAssessment } from '@/lib/utils/retryability'; export { assessPlatformUploadRetryability } from '@/lib/utils/retryability'; @@ -142,7 +145,10 @@ export function distributeCreatePlatformUploadInput( } : {}), ...(platform === 'sermon_audio' - ? { sermonAudioAutoPublishOnProcessed: meta.autoPublishOnProcessed === true } + ? { + sermonAudioAutoPublishOnProcessed: + meta.autoPublishOnProcessed === true || meta.publishTimestamp !== undefined, + } : {}), }; } @@ -212,8 +218,7 @@ async function startSermonAudioAutoPublishForSuccessfulUploads( jobId: string, attemptResults: PlatformUpload[], metadataByPlatformId: Map, - saApiKeyByPlatformUploadId: ReadonlyMap, - saCustomThumbnailUploadedByPlatformUploadId: ReadonlyMap + saApiKeyByPlatformUploadId: ReadonlyMap ): Promise { const immediateFailureWrites: Promise[] = []; @@ -223,14 +228,14 @@ async function startSermonAudioAutoPublishForSuccessfulUploads( } const apiKey = saApiKeyByPlatformUploadId.get(upload.id); - const customThumbnailUploaded = - saCustomThumbnailUploadedByPlatformUploadId.get(upload.id) === true; const sermonID = upload.platformVideoId.trim(); if (!sermonID) continue; const meta = metadataByPlatformId.get(upload.id); - if (meta?.autoPublishOnProcessed !== true) continue; + const shouldPublishAfterProcessing = + meta?.autoPublishOnProcessed === true || meta?.publishTimestamp !== undefined; + if (!shouldPublishAfterProcessing) continue; if (!apiKey) { console.warn( @@ -261,22 +266,31 @@ async function startSermonAudioAutoPublishForSuccessfulUploads( await pollSermonAudioProcessing({ sermonID, tokens: { accessToken: apiKey }, - customThumbnailUploaded, }); await publishSermonAudio({ sermonID, tokens: { accessToken: apiKey }, + crossPublish: meta?.crossPublish, + defaultTitle: meta.fullTitle?.trim() || meta.title?.trim(), + defaultDescription: meta.description?.trim() || meta.moreInfoText?.trim(), + publishTimestamp: meta.publishTimestamp, }); + const terminalStatus = resolveSermonAudioTerminalUploadStatus(meta.publishTimestamp); + const scheduledAt = + terminalStatus === 'scheduled' && meta.publishTimestamp !== undefined + ? new Date(meta.publishTimestamp * 1000).toISOString() + : null; await updatePlatformUploadStatus( platformUploadId, - 'published', + terminalStatus, sermonID, platformUrl || undefined, - null + null, + scheduledAt ); const crossPublishActive = sermonAudioCrossPublishHasActiveSelection(meta?.crossPublish); console.log( - `[distribute] SermonAudio sermon ${sermonID} published after processing (job ${jobId}, platform_upload ${platformUploadId})` + + `[distribute] SermonAudio sermon ${sermonID} ${terminalStatus === 'scheduled' ? 'scheduled' : 'published'} after processing (job ${jobId}, platform_upload ${platformUploadId})` + (crossPublishActive ? '; Cross Publish enabled' : '') ); } catch (err) { @@ -391,7 +405,6 @@ async function runSinglePlatformUpload( platformUpload: PlatformUpload, metadata: PlatformUploadMetadata, saApiKeyByPlatformUploadId: Map, - saCustomThumbnailUploadedByPlatformUploadId: Map, sharedBackupMetadataSession: SharedBackupMetadataSession | null ): Promise { try { @@ -738,13 +751,6 @@ async function runSinglePlatformUpload( uploadResult.platformUrl, null ); - - if ( - platformUpload.platform === 'sermon_audio' && - uploadResult.sermonAudioCustomThumbnailUploaded === true - ) { - saCustomThumbnailUploadedByPlatformUploadId.set(platformUpload.id, true); - } } catch (error) { const detail = messageFromThrown(error); const marked = await updatePlatformUploadStatus( @@ -919,7 +925,6 @@ export async function runDistributionInBackground( const attemptPlatformUploadIds = new Set(platformUploads.map((p) => p.id)); const subsetRetry = options?.subsetRetry === true; const saApiKeyByPlatformUploadId = new Map(); - const saCustomThumbnailUploadedByPlatformUploadId = new Map(); let sharedBackupMetadataSession: SharedBackupMetadataSession | null = null; try { sharedBackupMetadataSession = await createSharedBackupMetadataSessionForJob( @@ -939,7 +944,6 @@ export async function runDistributionInBackground( platformUpload, meta, saApiKeyByPlatformUploadId, - saCustomThumbnailUploadedByPlatformUploadId, sharedBackupMetadataSession ); }) @@ -952,8 +956,7 @@ export async function runDistributionInBackground( jobId, attemptResults, metadataByPlatformId, - saApiKeyByPlatformUploadId, - saCustomThumbnailUploadedByPlatformUploadId + saApiKeyByPlatformUploadId ); const foundAttemptIds = new Set(attemptResults.map((u) => u.id)); diff --git a/lib/api/finalize-upload-job.ts b/lib/api/finalize-upload-job.ts new file mode 100644 index 00000000..ac40a044 --- /dev/null +++ b/lib/api/finalize-upload-job.ts @@ -0,0 +1,96 @@ +import { after } from 'next/server'; +import { + distributeCreatePlatformUploadInput, + runDistributionInBackground, +} from '@/lib/api/distribute'; +import { buildMetadataForPlatform } from '@/lib/draft-upload-metadata'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { ensurePlatformUploadsForJobTargets } from '@/lib/repositories/platform-uploads'; +import { getUploadJobById, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; +import type { ConnectedAccountPlatform } from '@/types'; + +/** + * Thrown when an upload job row disappears between finalize steps. + */ +export class UploadJobFinalizeNotFoundError extends Error { + constructor() { + super('Upload job no longer exists and could not be finalized'); + this.name = 'UploadJobFinalizeNotFoundError'; + } +} + +/** + * Thrown when distribution is requested but the upload job has no staged R2 object key. + */ +export class UploadJobMissingR2KeyError extends Error { + /** + * @param jobId - Upload job id missing an R2 key. + */ + constructor(jobId: string) { + super(`Upload job ${jobId} has no R2 object key and cannot be distributed`); + this.name = 'UploadJobMissingR2KeyError'; + } +} + +/** + * Finalizes an UploadJob whose R2 object is already fully present and + * verified, transitioning it to the distributing/completed state and + * kicking off distribution to the draft's target platforms. + * @param jobId - UploadJob id, already confirmed to exist and be owned + * by the caller. + * @param userId - Owning user id, for downstream authorization checks. + * @returns Whether distribution was actually started (mirrors the + * existing `distributing` field in the complete route's response). + */ +export async function finalizeUploadJobAndDistribute( + jobId: string, + userId: string +): Promise<{ distributing: boolean }> { + const job = await getUploadJobById(jobId); + if (!job) { + throw new UploadJobFinalizeNotFoundError(); + } + + // --- Auto-distribute to the draft's target platforms --- + if (!job.draftId) { + // No draft linked — just mark as uploading (manual distribute later). + await updateUploadJobStatus(jobId, 'uploading'); + return { distributing: false }; + } + + const draft = await getDraftById(job.draftId); + if (!draft || draft.targets.length === 0) { + // Draft missing or has no targets — advance to uploading only. + await updateUploadJobStatus(jobId, 'uploading'); + return { distributing: false }; + } + + const targetPlatforms = [...new Set(draft.targets)] as ConnectedAccountPlatform[]; + const r2Key = job.r2Key?.trim(); + if (!r2Key) { + throw new UploadJobMissingR2KeyError(jobId); + } + + // Create platform_upload rows before advancing to distributing so a failure + // here leaves the job in pending (retryable), not stuck in distributing. + const platformUploads = await ensurePlatformUploadsForJobTargets( + targetPlatforms.map((platform) => distributeCreatePlatformUploadInput(jobId, draft, platform)) + ); + + const updated = await updateUploadJobStatus(jobId, 'distributing', null); + if (!updated) { + throw new UploadJobFinalizeNotFoundError(); + } + + const metadataByPlatformId = new Map>(); + for (const pu of platformUploads) { + metadataByPlatformId.set(pu.id, buildMetadataForPlatform(draft, pu.platform)); + } + + // Schedule background distribution (runs after the response is sent). + after(() => + runDistributionInBackground(jobId, userId, r2Key, platformUploads, metadataByPlatformId) + ); + + return { distributing: true }; +} diff --git a/lib/connected-accounts/sftp-validation.ts b/lib/connected-accounts/sftp-validation.ts new file mode 100644 index 00000000..65879245 --- /dev/null +++ b/lib/connected-accounts/sftp-validation.ts @@ -0,0 +1,41 @@ +/** Minimum valid TCP port for SFTP connections. */ +const SFTP_PORT_MIN = 1; + +/** Maximum valid TCP port for SFTP connections. */ +const SFTP_PORT_MAX = 65535; + +/** SHA-256 host key fingerprints are stored as 64 lowercase hex characters. */ +export const SFTP_HOST_KEY_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/; + +/** + * Returns whether `port` is a valid SFTP TCP port. + * @param port - Candidate port number. + * @returns True when the port is an integer from 1 through 65535. + */ +export function isValidConnectedAccountSftpPort(port: number): boolean { + return Number.isInteger(port) && port >= SFTP_PORT_MIN && port <= SFTP_PORT_MAX; +} + +/** + * Normalizes a stored SFTP host key fingerprint to lowercase hex. + * @param value - Raw fingerprint string from persistence or API input. + * @returns Lowercase 64-character hex fingerprint, or null when invalid. + */ +export function normalizeConnectedAccountSftpHostKeyFingerprint(value: string): string | null { + const normalized = value.trim().toLowerCase(); + if (!SFTP_HOST_KEY_FINGERPRINT_PATTERN.test(normalized)) { + return null; + } + return normalized; +} + +/** + * Returns whether an optional SFTP host key fingerprint value is valid for persistence. + * @param value - Candidate fingerprint from schema validation. + * @returns True when absent, or a valid 64-character lowercase hex fingerprint. + */ +export function isOptionalSftpHostKeyFingerprint(value: unknown): boolean { + if (value == null) return true; + if (value === '') return false; + return typeof value === 'string' && SFTP_HOST_KEY_FINGERPRINT_PATTERN.test(value); +} diff --git a/lib/draft-labels.ts b/lib/draft-labels.ts new file mode 100644 index 00000000..f2dba884 --- /dev/null +++ b/lib/draft-labels.ts @@ -0,0 +1,405 @@ +import type { DraftLabelDefinition } from '@/types'; + +/** Maximum organizational labels stored on a single draft. */ +export const MAX_DRAFT_LABELS_PER_DRAFT = 20; + +/** Maximum characters per draft label. */ +export const MAX_DRAFT_LABEL_LENGTH = 50; + +/** Maximum labels kept in a user's saved label library. */ +export const MAX_DRAFT_LABEL_LIBRARY_SIZE = 200; + +/** Preset swatches for draft label colors. */ +export const DRAFT_LABEL_COLOR_PRESETS = [ + '#6366f1', + '#8b5cf6', + '#ec4899', + '#ef4444', + '#f97316', + '#eab308', + '#22c55e', + '#14b8a6', + '#3b82f6', + '#64748b', +] as const; + +/** Default label color when none is stored. */ +export const DEFAULT_DRAFT_LABEL_COLOR = DRAFT_LABEL_COLOR_PRESETS[9]; + +const DRAFT_LABEL_HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/; + +/** + * Normalizes a draft organizational label for storage and comparison. + * @param raw - Raw label text from the editor or API. + * @returns Trimmed label with collapsed internal whitespace. + */ +export function normalizeDraftLabel(raw: string): string { + return raw.trim().replace(/\s+/g, ' '); +} + +/** + * Validates and normalizes a draft label hex color. + * @param raw - Raw color from API or UI. + * @returns Normalized `#RRGGBB` color or the default label color. + */ +export function normalizeDraftLabelColor(raw: unknown): string { + if (typeof raw !== 'string') return DEFAULT_DRAFT_LABEL_COLOR; + const trimmed = raw.trim(); + if (!DRAFT_LABEL_HEX_COLOR_PATTERN.test(trimmed)) return DEFAULT_DRAFT_LABEL_COLOR; + return trimmed.toLowerCase(); +} + +/** + * Picks a stable preset color for a label name. + * @param name - Label text. + * @returns Hex color from {@link DRAFT_LABEL_COLOR_PRESETS}. + */ +export function defaultDraftLabelColorForName(name: string): string { + const normalized = normalizeDraftLabel(name).toLowerCase(); + if (!normalized) return DEFAULT_DRAFT_LABEL_COLOR; + let hash = 0; + for (let index = 0; index < normalized.length; index += 1) { + hash = (hash * 31 + normalized.charCodeAt(index)) >>> 0; + } + return DRAFT_LABEL_COLOR_PRESETS[hash % DRAFT_LABEL_COLOR_PRESETS.length]; +} + +/** + * Parses comma-separated draft label input (Enter/comma commit in the UI). + * @param raw - Raw text from the label input field. + * @returns Normalized label strings ready to store. + */ +export function parseDraftLabelInput(raw: string): string[] { + return raw.split(',').map(normalizeDraftLabel).filter(Boolean); +} + +/** + * Returns whether a label already exists in a list (case-insensitive). + * @param labels - Existing stored labels. + * @param candidate - Label to check for duplication. + * @returns True when an equivalent label is already present. + */ +export function draftLabelListIncludesEquivalent( + labels: readonly string[], + candidate: string +): boolean { + const normalizedCandidate = normalizeDraftLabel(candidate).toLowerCase(); + if (!normalizedCandidate) return false; + return labels.some( + (existing) => normalizeDraftLabel(existing).toLowerCase() === normalizedCandidate + ); +} + +/** + * Merges parsed labels into an existing list without case-insensitive duplicates. + * @param labels - Existing stored labels. + * @param parsed - Newly parsed labels to add. + * @returns Updated label list preserving first-seen casing. + */ +export function mergeUniqueDraftLabels( + labels: readonly string[], + parsed: readonly string[] +): string[] { + const merged = [...labels]; + for (const label of parsed) { + const normalized = normalizeDraftLabel(label); + if (!normalized) continue; + if (!draftLabelListIncludesEquivalent(merged, normalized)) { + merged.push(normalized); + } + } + return merged; +} + +/** + * Normalizes and deduplicates a label array from persisted JSON or API input. + * @param value - Raw label list. + * @returns Case-insensitively unique labels in first-seen order. + */ +export function normalizeDraftLabelList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + const merged: string[] = []; + for (const item of value) { + if (typeof item !== 'string') continue; + const normalized = normalizeDraftLabel(item); + if (!normalized) continue; + if (!draftLabelListIncludesEquivalent(merged, normalized)) { + merged.push(normalized); + } + } + return merged; +} + +/** + * Normalizes one saved draft label definition from storage or API input. + * @param value - Raw label definition or legacy string entry. + * @returns Parsed definition or null when invalid. + */ +export function normalizeDraftLabelDefinition(value: unknown): DraftLabelDefinition | null { + if (typeof value === 'string') { + const name = normalizeDraftLabel(value); + if (!name) return null; + return { name, color: defaultDraftLabelColorForName(name) }; + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (typeof record.name !== 'string') return null; + const name = normalizeDraftLabel(record.name); + if (!name) return null; + return { + name, + color: normalizeDraftLabelColor(record.color ?? defaultDraftLabelColorForName(name)), + }; +} + +/** + * Normalizes a user's saved draft label library from MongoDB or API input. + * Accepts legacy `string[]` entries and `{ name, color }` objects. + * @param value - Raw library value. + * @returns Deduplicated definitions preserving first-seen name casing. + */ +export function normalizeDraftLabelLibrary(value: unknown): DraftLabelDefinition[] { + if (!Array.isArray(value)) return []; + const merged: DraftLabelDefinition[] = []; + for (const item of value) { + const definition = normalizeDraftLabelDefinition(item); + if (!definition) continue; + const existingIndex = merged.findIndex( + (entry) => entry.name.toLowerCase() === definition.name.toLowerCase() + ); + if (existingIndex >= 0) { + merged[existingIndex] = { + name: merged[existingIndex].name, + color: definition.color, + }; + continue; + } + if (merged.length >= MAX_DRAFT_LABEL_LIBRARY_SIZE) continue; + merged.push(definition); + } + return merged; +} + +/** + * Merges label names into a saved library, preserving existing colors. + * @param library - Existing saved definitions. + * @param names - Label names to upsert. + * @returns Updated library with default colors for newly added names. + */ +export function upsertDraftLabelNamesInLibrary( + library: readonly DraftLabelDefinition[], + names: readonly string[] +): DraftLabelDefinition[] { + const merged = library.map((entry) => ({ ...entry })); + for (const rawName of names) { + const name = normalizeDraftLabel(rawName); + if (!name) continue; + const existingIndex = merged.findIndex( + (entry) => entry.name.toLowerCase() === name.toLowerCase() + ); + if (existingIndex >= 0) continue; + if (merged.length >= MAX_DRAFT_LABEL_LIBRARY_SIZE) continue; + merged.push({ name, color: defaultDraftLabelColorForName(name) }); + } + return merged; +} + +/** + * Merges incoming label definitions into a library by name (case-insensitive). + * Updates color when an incoming entry specifies one. + * @param library - Existing saved definitions. + * @param incoming - Definitions to merge. + * @returns Updated library. + */ +export function mergeDraftLabelLibraryEntries( + library: readonly DraftLabelDefinition[], + incoming: readonly DraftLabelDefinition[] +): DraftLabelDefinition[] { + const merged = library.map((entry) => ({ ...entry })); + for (const entry of incoming) { + const name = normalizeDraftLabel(entry.name); + if (!name) continue; + const color = normalizeDraftLabelColor(entry.color); + const existingIndex = merged.findIndex( + (existing) => existing.name.toLowerCase() === name.toLowerCase() + ); + if (existingIndex >= 0) { + merged[existingIndex] = { name: merged[existingIndex].name, color }; + continue; + } + if (merged.length >= MAX_DRAFT_LABEL_LIBRARY_SIZE) continue; + merged.push({ name, color }); + } + return merged; +} + +/** + * Builds a case-insensitive lookup map from label name to color. + * @param library - Saved label definitions. + * @returns Map keyed by lowercase label name. + */ +export function buildDraftLabelColorMap( + library: readonly DraftLabelDefinition[] +): Map { + const map = new Map(); + for (const entry of library) { + map.set(entry.name.toLowerCase(), entry.color); + } + return map; +} + +/** + * Resolves the display color for a label name from a saved library. + * @param library - Saved label definitions. + * @param name - Label name on a draft. + * @returns Hex color for the label chip. + */ +export function lookupDraftLabelColor( + library: readonly DraftLabelDefinition[], + name: string +): string { + const normalized = normalizeDraftLabel(name); + if (!normalized) return DEFAULT_DRAFT_LABEL_COLOR; + const match = library.find((entry) => entry.name.toLowerCase() === normalized.toLowerCase()); + return match?.color ?? defaultDraftLabelColorForName(normalized); +} + +/** + * Converts a hex color to an rgba string for chip backgrounds. + * @param hex - `#RRGGBB` color. + * @param alpha - Opacity from 0 to 1. + * @returns CSS rgba color string. + */ +export function draftLabelColorWithAlpha(hex: string, alpha: number): string { + const normalized = normalizeDraftLabelColor(hex); + const value = normalized.slice(1); + const red = Number.parseInt(value.slice(0, 2), 16); + const green = Number.parseInt(value.slice(2, 4), 16); + const blue = Number.parseInt(value.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +/** + * Filters a saved label library for autocomplete suggestions. + * @param library - User's saved draft labels. + * @param query - Optional case-insensitive substring filter. + * @param limit - Maximum suggestions to return. + * @returns Matching definitions sorted alphabetically (case-insensitive). + */ +export function filterDraftLabelSuggestions( + library: readonly DraftLabelDefinition[], + query?: string, + limit = 12 +): DraftLabelDefinition[] { + const trimmedQuery = query?.trim().toLowerCase() ?? ''; + const filtered = trimmedQuery + ? library.filter((entry) => entry.name.toLowerCase().includes(trimmedQuery)) + : [...library]; + return filtered + .slice() + .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) + .slice(0, limit); +} + +/** + * Validates a draft label list from API input. + * @param value - Raw request body field. + * @returns Parsed labels or a validation error message. + */ +export function parseDraftLabelsFromRequestBody( + value: unknown +): { ok: true; value: string[] } | { ok: false; error: string } { + if (value === undefined) return { ok: true, value: [] }; + if (!Array.isArray(value)) { + return { ok: false, error: 'labels must be an array of strings' }; + } + if (value.some((item) => typeof item !== 'string')) { + return { ok: false, error: 'labels must be an array of strings' }; + } + + const normalized = normalizeDraftLabelList(value); + if (normalized.length > MAX_DRAFT_LABELS_PER_DRAFT) { + return { + ok: false, + error: `labels must contain at most ${MAX_DRAFT_LABELS_PER_DRAFT} items`, + }; + } + + for (const label of normalized) { + if (label.length > MAX_DRAFT_LABEL_LENGTH) { + return { + ok: false, + error: `each label must be at most ${MAX_DRAFT_LABEL_LENGTH} characters`, + }; + } + } + + return { ok: true, value: normalized }; +} + +/** + * Validates a user's saved draft label library from API input. + * Accepts legacy string entries and `{ name, color }` objects. + * @param value - Raw request body field. + * @returns Parsed library or a validation error message. + */ +export function parseDraftLabelLibraryFromRequestBody( + value: unknown +): { ok: true; value: DraftLabelDefinition[] } | { ok: false; error: string } { + if (value === undefined) { + return { ok: false, error: 'labels must be provided' }; + } + if (!Array.isArray(value)) { + return { ok: false, error: 'labels must be an array' }; + } + if ( + value.some( + (item) => + typeof item !== 'string' && + (item === null || typeof item !== 'object' || Array.isArray(item)) + ) + ) { + return { ok: false, error: 'labels must contain only strings or label objects' }; + } + if (value.length > MAX_DRAFT_LABEL_LIBRARY_SIZE) { + return { + ok: false, + error: `labels must contain at most ${MAX_DRAFT_LABEL_LIBRARY_SIZE} items`, + }; + } + + const normalized = normalizeDraftLabelLibrary(value); + if (normalized.length > MAX_DRAFT_LABEL_LIBRARY_SIZE) { + return { + ok: false, + error: `labels must contain at most ${MAX_DRAFT_LABEL_LIBRARY_SIZE} items`, + }; + } + + for (const entry of normalized) { + if (entry.name.length > MAX_DRAFT_LABEL_LENGTH) { + return { + ok: false, + error: `each label must be at most ${MAX_DRAFT_LABEL_LENGTH} characters`, + }; + } + } + + return { ok: true, value: normalized }; +} + +/** + * Returns labels removed from a library after a settings update. + * @param previous - Library before the update. + * @param next - Library after the update. + * @returns Labels present in `previous` but not in `next` (case-insensitive). + */ +export function draftLabelsRemovedFromLibrary( + previous: readonly DraftLabelDefinition[], + next: readonly DraftLabelDefinition[] +): string[] { + const nextNames = next.map((entry) => entry.name); + return previous + .map((entry) => entry.name) + .filter((label) => !draftLabelListIncludesEquivalent(nextNames, label)); +} diff --git a/lib/draft-upload-metadata.ts b/lib/draft-upload-metadata.ts index 639a694d..03d763ac 100644 --- a/lib/draft-upload-metadata.ts +++ b/lib/draft-upload-metadata.ts @@ -7,6 +7,7 @@ * backup targets (`platforms.sftp` / `platforms.smb`) are carried through as empty objects until fields exist. */ +import { normalizeDraftLabelList } from '@/lib/draft-labels'; import type { PlatformUploadMetadata } from '@/lib/platforms/types'; import { normalizeBackupFileNameSettings } from '@/lib/backup-filename'; import { formatSermonAudioKeywordsFromTags } from '@/lib/platforms/sermon-audio-tags'; @@ -364,8 +365,9 @@ function normalizeFacebookFields(f: Record): FacebookDraftField } function resolveSermonAudioAutoPublishOnProcessed( - fields: Pick | undefined + fields: Pick | undefined ): boolean { + if (fields?.publishTimestamp !== undefined) return false; return fields?.autoPublishOnProcessed !== false; } @@ -387,7 +389,15 @@ function normalizeSermonAudioFields(sa: Record): SermonAudioDra const languageCode = trimStr(sa.languageCode); const autoPublishOnProcessed = typeof sa.autoPublishOnProcessed === 'boolean' ? sa.autoPublishOnProcessed : undefined; - const publishDate = trimStr(sa.publishDate); + const publishTimestamp = + typeof sa.publishTimestamp === 'number' && Number.isFinite(sa.publishTimestamp) + ? Math.floor(sa.publishTimestamp) + : typeof sa.publishDate === 'string' && sa.publishDate.trim() !== '' + ? (() => { + const parsed = Date.parse(sa.publishDate.trim()); + return Number.isNaN(parsed) ? undefined : Math.floor(parsed / 1000); + })() + : undefined; const crossPublish = normalizeSermonAudioCrossPublishSettings(sa.crossPublish); return { @@ -403,7 +413,7 @@ function normalizeSermonAudioFields(sa: Record): SermonAudioDra ...(displayTitle !== undefined ? { displayTitle } : {}), ...(languageCode !== undefined ? { languageCode } : {}), ...(autoPublishOnProcessed !== undefined ? { autoPublishOnProcessed } : {}), - ...(publishDate !== undefined ? { publishDate } : {}), + ...(publishTimestamp !== undefined ? { publishTimestamp } : {}), ...(crossPublish !== undefined ? { crossPublish } : {}), }; } @@ -487,6 +497,8 @@ export interface DraftDocumentStored { description: string; visibility: PlatformUploadVisibility; tags: string[]; + /** Organizational draft labels (VideoSphere-only; not sent to platforms). */ + labels: string[]; platforms: DraftPlatforms; backupNaming?: BackupFileNameSettings; /** R2 key for draft thumbnail; omitted when unset. */ @@ -511,6 +523,7 @@ export function stringifyDraftDocumentForStorage(d: DraftDocumentStored): string description: d.description, visibility: d.visibility, tags: d.tags, + ...(d.labels.length > 0 ? { labels: d.labels } : {}), platforms: d.platforms, ...(d.backupNaming !== undefined ? { backupNaming: d.backupNaming } : {}), ...(typeof d.thumbnailR2Key === 'string' && d.thumbnailR2Key.trim() !== '' @@ -534,6 +547,7 @@ function emptyDraftDocument(): DraftDocumentStored { description: '', visibility: DEFAULT_DRAFT_VISIBILITY, tags: [], + labels: [], platforms: {}, backupNaming: normalizeBackupFileNameSettings(undefined), }; @@ -564,6 +578,7 @@ export function draftDocumentFromRow(row: Record): DraftDocumen description: typeof o.description === 'string' ? o.description : '', visibility: visibilityFromRow(o.visibility), tags: tagsFromDocumentObject(o), + labels: normalizeDraftLabelList(o.labels), platforms: normalizeDraftPlatforms(o.platforms), backupNaming: normalizeBackupFileNameSettings(o.backupNaming), ...(thumbKey !== undefined ? { thumbnailR2Key: thumbKey } : {}), @@ -875,9 +890,10 @@ export function mergeDraftPlatformsPatch(base: DraftPlatforms, patch: unknown): sa.autoPublishOnProcessed = typeof p.autoPublishOnProcessed === 'boolean' ? p.autoPublishOnProcessed : undefined; } - if ('publishDate' in p) { - const s = p.publishDate; - sa.publishDate = typeof s === 'string' && s.trim() !== '' ? s.trim() : undefined; + if ('publishTimestamp' in p) { + const ts = p.publishTimestamp; + sa.publishTimestamp = + typeof ts === 'number' && Number.isFinite(ts) ? Math.floor(ts) : undefined; } if ('crossPublish' in p) { sa.crossPublish = normalizeSermonAudioCrossPublishSettings(p.crossPublish); @@ -1122,7 +1138,7 @@ export function buildMetadataForPlatform( ...(sa?.languageCode?.trim() ? { languageCode: sa.languageCode.trim() } : {}), acceptCopyright: true, autoPublishOnProcessed: resolveSermonAudioAutoPublishOnProcessed(sa), - // TODO(sermon-audio-schedule): Include publishDate when SermonAudio API supports scheduling. + ...(sa?.publishTimestamp !== undefined ? { publishTimestamp: sa.publishTimestamp } : {}), ...(sa?.crossPublish !== undefined ? { crossPublish: sa.crossPublish } : {}), }; } diff --git a/lib/format-video-duration.ts b/lib/format-video-duration.ts new file mode 100644 index 00000000..7d70192e --- /dev/null +++ b/lib/format-video-duration.ts @@ -0,0 +1,19 @@ +/** + * Formats a duration in seconds using YouTube-style `H:MM:SS` or `M:SS` labels. + * @param seconds - Duration in seconds (fractional values are floored). + * @returns Human-readable duration string. + */ +export function formatVideoDuration(seconds: number): string { + const safeSeconds = Math.max(0, Math.floor(seconds)); + const hours = Math.floor(safeSeconds / 3600); + const minutes = Math.floor((safeSeconds % 3600) / 60); + const remainingSeconds = safeSeconds % 60; + const paddedSeconds = remainingSeconds.toString().padStart(2, '0'); + + if (hours > 0) { + const paddedMinutes = minutes.toString().padStart(2, '0'); + return `${hours}:${paddedMinutes}:${paddedSeconds}`; + } + + return `${minutes}:${paddedSeconds}`; +} diff --git a/lib/livestreams/livestream-list-filters.ts b/lib/livestreams/livestream-list-filters.ts new file mode 100644 index 00000000..c4c2591f --- /dev/null +++ b/lib/livestreams/livestream-list-filters.ts @@ -0,0 +1,103 @@ +import type { Livestream } from '@/types'; + +const STREAMED_LIVESTREAM_STATUSES = ['ended', 'failed'] as const; + +/** + * Returns whether a livestream belongs in the streamed/history sections. + * @param livestream - Livestream row to evaluate. + * @returns True when the broadcast has ended or failed. + */ +export function isStreamedLivestream(livestream: Pick): boolean { + return livestream.status === 'ended' || livestream.status === 'failed'; +} + +/** + * Returns whether a livestream can be selected as a YouTube import source. + * @param livestream - Livestream row to evaluate. + * @returns True when the row is linked to a YouTube broadcast and has finished. + */ +export function isYoutubeImportLivestream( + livestream: Pick< + Livestream, + 'status' | 'targets' | 'youtubeBroadcastId' | 'youtubeLifecycleStatus' + > +): boolean { + if (!livestream.targets.includes('youtube')) { + return false; + } + + if (!livestream.youtubeBroadcastId?.trim()) { + return false; + } + + if (isStreamedLivestream(livestream)) { + return true; + } + + return ( + livestream.status === 'live' && + livestream.youtubeLifecycleStatus?.trim().toLowerCase() === 'complete' + ); +} + +/** + * Builds a MongoDB filter for streamed livestream history pages. + * @param userId - Owner user id. + * @returns Query filter for ended/failed rows. + */ +export function buildStreamedLivestreamsMongoFilter(userId: string): Record { + return { + userId, + status: { $in: STREAMED_LIVESTREAM_STATUSES }, + }; +} + +/** + * Builds a MongoDB filter for YouTube import source picker pages. + * @param userId - Owner user id. + * @returns Query filter for importable YouTube-linked rows. + */ +export function buildYoutubeImportLivestreamsMongoFilter(userId: string): Record { + return { + userId, + hasYoutubeTarget: true, + youtubeBroadcastId: { $ne: '' }, + $or: [ + { status: { $in: STREAMED_LIVESTREAM_STATUSES } }, + { status: 'live', youtubeLifecycleStatus: /^complete$/i }, + ], + }; +} + +/** + * Filters livestreams to streamed rows, preserving repository sort order. + * @param livestreams - Livestreams ordered most recently updated first. + * @returns Streamed livestreams in the same order. + */ +export function filterStreamedLivestreams(livestreams: readonly Livestream[]): Livestream[] { + return livestreams.filter(isStreamedLivestream); +} + +/** + * Filters livestreams to rows importable from YouTube, preserving repository sort order. + * @param livestreams - Livestreams ordered most recently updated first. + * @returns YouTube-importable livestreams in the same order. + */ +export function filterYoutubeImportLivestreams(livestreams: readonly Livestream[]): Livestream[] { + return livestreams.filter(isYoutubeImportLivestream); +} + +/** + * Returns a page slice from an already-filtered livestream list. + * @param livestreams - Ordered livestream rows. + * @param offset - Number of rows to skip. + * @param limit - Maximum rows to return. + * @returns Page slice. + */ +export function paginateLivestreams( + livestreams: readonly Livestream[], + offset: number, + limit: number +): Livestream[] { + return livestreams.slice(offset, offset + limit); +} diff --git a/lib/livestreams/livestream-query-fields.ts b/lib/livestreams/livestream-query-fields.ts new file mode 100644 index 00000000..f52c03ed --- /dev/null +++ b/lib/livestreams/livestream-query-fields.ts @@ -0,0 +1,78 @@ +import type { ConnectedAccountPlatform, LivestreamStatus } from '@/types'; + +const LIVESTREAM_STATUSES = new Set([ + 'draft', + 'scheduled', + 'live', + 'ended', + 'failed', +]); + +/** + * Top-level MongoDB query fields denormalized from a livestream document payload. + */ +export interface LivestreamMongoQueryFields { + /** Livestream lifecycle status. */ + status: LivestreamStatus; + /** Whether YouTube is in the distribution target list. */ + hasYoutubeTarget: boolean; + /** Linked YouTube broadcast id, or empty when unset. */ + youtubeBroadcastId: string; + /** YouTube lifecycle status string, or empty when unset. */ + youtubeLifecycleStatus: string; +} + +/** + * Derives indexed query fields from parsed livestream document payload fields. + * @param doc - Parsed document payload fields. + * @returns Top-level MongoDB query fields. + */ +export function livestreamQueryFieldsFromStoredDocument(doc: { + status: LivestreamStatus; + targets: readonly ConnectedAccountPlatform[]; + youtubeBroadcastId?: string; + youtubeLifecycleStatus?: string; +}): LivestreamMongoQueryFields { + return { + status: doc.status, + hasYoutubeTarget: doc.targets.includes('youtube'), + youtubeBroadcastId: doc.youtubeBroadcastId?.trim() ?? '', + youtubeLifecycleStatus: doc.youtubeLifecycleStatus?.trim() ?? '', + }; +} + +/** + * Derives indexed query fields from a stored livestream JSON document string. + * @param documentJson - Serialized livestream document column. + * @returns Top-level MongoDB query fields. + */ +export function livestreamQueryFieldsFromDocumentJson( + documentJson: string +): LivestreamMongoQueryFields { + const parsed = JSON.parse(documentJson) as { + status?: unknown; + targets?: unknown; + youtubeBroadcastId?: unknown; + youtubeLifecycleStatus?: unknown; + }; + + const status = + typeof parsed.status === 'string' && LIVESTREAM_STATUSES.has(parsed.status as LivestreamStatus) + ? (parsed.status as LivestreamStatus) + : 'draft'; + + const targets = Array.isArray(parsed.targets) + ? parsed.targets.filter( + (target): target is ConnectedAccountPlatform => typeof target === 'string' + ) + : []; + + return livestreamQueryFieldsFromStoredDocument({ + status, + targets, + youtubeBroadcastId: + typeof parsed.youtubeBroadcastId === 'string' ? parsed.youtubeBroadcastId : undefined, + youtubeLifecycleStatus: + typeof parsed.youtubeLifecycleStatus === 'string' ? parsed.youtubeLifecycleStatus : undefined, + }); +} diff --git a/lib/livestreams/schedulable-platforms.ts b/lib/livestreams/schedulable-platforms.ts index 2ef86c3c..428b3a34 100644 --- a/lib/livestreams/schedulable-platforms.ts +++ b/lib/livestreams/schedulable-platforms.ts @@ -1,3 +1,4 @@ +import { isUsablePlatformConnection } from '@/lib/platforms/connection-status'; import { isFacebookLivestreamSchedulingEnabled } from '@/lib/livestreams/facebook-livestream-feature'; import type { ConnectedAccountPlatform, ConnectedAccountPublic } from '@/types'; @@ -91,19 +92,21 @@ export function getSchedulableLivestreamPlatforms( export function toLivestreamConnectionSnapshots( connections: ConnectedAccountPublic[] ): LivestreamConnectionSnapshot[] { - return connections.map( - ({ - platform, - hasYoutubeMainStreamKey, - hasYoutubeTempStreamKey, - facebookTargetType, - facebookPageId, - }) => ({ - platform, - hasYoutubeMainStreamKey, - hasYoutubeTempStreamKey, - ...(facebookTargetType != null ? { facebookTargetType } : {}), - ...(facebookPageId != null ? { facebookPageId } : {}), - }) - ); + return connections + .filter(isUsablePlatformConnection) + .map( + ({ + platform, + hasYoutubeMainStreamKey, + hasYoutubeTempStreamKey, + facebookTargetType, + facebookPageId, + }) => ({ + platform, + hasYoutubeMainStreamKey, + hasYoutubeTempStreamKey, + ...(facebookTargetType != null ? { facebookTargetType } : {}), + ...(facebookPageId != null ? { facebookPageId } : {}), + }) + ); } diff --git a/lib/livestreams/youtube-thumbnail-preview.ts b/lib/livestreams/youtube-thumbnail-preview.ts index c0cc0623..f8718949 100644 --- a/lib/livestreams/youtube-thumbnail-preview.ts +++ b/lib/livestreams/youtube-thumbnail-preview.ts @@ -1,3 +1,5 @@ +import type { Livestream } from '@/types'; + /** * Builds a cache-busted preview URL for a YouTube-hosted thumbnail image. * YouTube often reuses the same CDN path when a custom thumbnail is replaced. @@ -33,3 +35,30 @@ export function livestreamYouTubeThumbnailCacheKey(livestream: { }): string { return livestream.platforms.youtube?.thumbnailUpdatedAt?.trim() || livestream.$updatedAt; } + +/** + * Resolves a thumbnail URL for livestream list rows (R2 preview, stored YouTube URL, or broadcast id fallback). + * @param livestream - Livestream row from the API. + * @returns Thumbnail image URL when one can be derived. + */ +export function getLivestreamListThumbnailUrl(livestream: Livestream): string | undefined { + const r2Preview = livestream.thumbnailPreviewUrl?.trim(); + if (r2Preview) { + return r2Preview; + } + + const youtubeThumbnailUrl = livestream.platforms.youtube?.thumbnailUrl?.trim(); + if (youtubeThumbnailUrl) { + return youtubeThumbnailPreviewUrl( + youtubeThumbnailUrl, + livestreamYouTubeThumbnailCacheKey(livestream) + ); + } + + const broadcastId = livestream.youtubeBroadcastId?.trim(); + if (broadcastId) { + return `https://i.ytimg.com/vi/${broadcastId}/hqdefault.jpg`; + } + + return undefined; +} diff --git a/lib/models/ConnectedAccount.ts b/lib/models/ConnectedAccount.ts index 3c4d3c51..fb876159 100644 --- a/lib/models/ConnectedAccount.ts +++ b/lib/models/ConnectedAccount.ts @@ -1,47 +1,26 @@ import mongoose, { Schema } from 'mongoose'; +import { + isOptionalSftpHostKeyFingerprint, + isValidConnectedAccountSftpPort, + normalizeConnectedAccountSftpHostKeyFingerprint, +} from '@/lib/connected-accounts/sftp-validation'; import { CONNECTED_ACCOUNT_PLATFORMS, type ConnectedAccountPlatform, type SftpAuthMethod, } from '@/types'; +export { + isValidConnectedAccountSftpPort, + normalizeConnectedAccountSftpHostKeyFingerprint, +} from '@/lib/connected-accounts/sftp-validation'; + /** Minimum valid TCP port for SFTP connections. */ const SFTP_PORT_MIN = 1; /** Maximum valid TCP port for SFTP connections. */ const SFTP_PORT_MAX = 65535; -/** SHA-256 host key fingerprints are stored as 64 lowercase hex characters. */ -const SFTP_HOST_KEY_FINGERPRINT_PATTERN = /^[0-9a-f]{64}$/; - -/** - * Returns whether `port` is a valid SFTP TCP port. - * @param port - Candidate port number. - * @returns True when the port is an integer from 1 through 65535. - */ -export function isValidConnectedAccountSftpPort(port: number): boolean { - return Number.isInteger(port) && port >= SFTP_PORT_MIN && port <= SFTP_PORT_MAX; -} - -/** - * Normalizes a stored SFTP host key fingerprint to lowercase hex. - * @param value - Raw fingerprint string from persistence or API input. - * @returns Lowercase 64-character hex fingerprint, or null when invalid. - */ -export function normalizeConnectedAccountSftpHostKeyFingerprint(value: string): string | null { - const normalized = value.trim().toLowerCase(); - if (!SFTP_HOST_KEY_FINGERPRINT_PATTERN.test(normalized)) { - return null; - } - return normalized; -} - -function isOptionalSftpHostKeyFingerprint(value: unknown): boolean { - if (value == null) return true; - if (value === '') return false; - return typeof value === 'string' && SFTP_HOST_KEY_FINGERPRINT_PATTERN.test(value); -} - /** * Raw MongoDB document shape for the `connected_accounts` collection. * diff --git a/lib/models/Livestream.ts b/lib/models/Livestream.ts index b4d1f30c..84596a9d 100644 --- a/lib/models/Livestream.ts +++ b/lib/models/Livestream.ts @@ -1,28 +1,49 @@ import mongoose, { Schema } from 'mongoose'; +import type { LivestreamStatus } from '@/types'; /** * Raw MongoDB document shape for the `livestreams` collection. * * `document` is intentionally stored as a JSON string to preserve the * existing payload shape used by repository mapping and API contracts. + * Top-level query fields mirror common list filters so pagination does not + * require loading every row into memory. */ export interface LivestreamDocument { _id: string; userId: string; document: string; + status?: LivestreamStatus; + hasYoutubeTarget?: boolean; + youtubeBroadcastId?: string; + youtubeLifecycleStatus?: string; createdAt: Date; updatedAt: Date; } +const LIVESTREAM_STATUS_VALUES = ['draft', 'scheduled', 'live', 'ended', 'failed'] as const; + const LivestreamSchema = new Schema( { _id: { type: String }, userId: { type: String, required: true, index: true, trim: true }, document: { type: String, required: true }, + status: { type: String, enum: LIVESTREAM_STATUS_VALUES, index: true }, + hasYoutubeTarget: { type: Boolean, default: false, index: true }, + youtubeBroadcastId: { type: String, default: '', trim: true }, + youtubeLifecycleStatus: { type: String, default: '', trim: true }, }, { timestamps: true } ); +LivestreamSchema.index({ userId: 1, status: 1, updatedAt: -1 }); +LivestreamSchema.index({ + userId: 1, + hasYoutubeTarget: 1, + youtubeBroadcastId: 1, + updatedAt: -1, +}); + export const LivestreamModel = (mongoose.models.Livestream as mongoose.Model | undefined) || mongoose.model('Livestream', LivestreamSchema, 'livestreams'); diff --git a/lib/models/PlatformUpload.ts b/lib/models/PlatformUpload.ts index 51f5decc..bbceb1d8 100644 --- a/lib/models/PlatformUpload.ts +++ b/lib/models/PlatformUpload.ts @@ -40,7 +40,15 @@ const PlatformUploadSchema = new Schema( }, status: { type: String, - enum: ['pending', 'uploading', 'completed', 'unpublished', 'published', 'failed'], + enum: [ + 'pending', + 'uploading', + 'completed', + 'unpublished', + 'published', + 'scheduled', + 'failed', + ], default: 'pending', index: true, }, diff --git a/lib/models/UserProfile.ts b/lib/models/UserProfile.ts index 75dce5e3..0e8e4792 100644 --- a/lib/models/UserProfile.ts +++ b/lib/models/UserProfile.ts @@ -1,5 +1,11 @@ import mongoose, { Schema } from 'mongoose'; -import type { PlatformDefaults, UserAuthProvider, UserPreferences, UserRole } from '@/types'; +import type { + PlatformDefaults, + UserAuthProvider, + UserPreferences, + UserRole, + DraftLabelDefinition, +} from '@/types'; /** * Raw MongoDB document shape for the `user_profiles` collection. @@ -24,6 +30,8 @@ export interface UserProfileDocument { platformDefaults?: PlatformDefaults; /** Cross-device UI preferences. */ preferences?: UserPreferences; + /** Saved draft organizational labels for autocomplete. */ + draftLabelLibrary?: DraftLabelDefinition[]; createdAt: Date; updatedAt: Date; } @@ -48,6 +56,15 @@ const UserProfileSchema = new Schema( totpEnabled: { type: Boolean, default: false }, platformDefaults: { type: Schema.Types.Mixed, default: {} }, preferences: { type: Schema.Types.Mixed, default: {} }, + draftLabelLibrary: { + type: [ + { + name: { type: String, required: true, trim: true }, + color: { type: String, required: true, trim: true }, + }, + ], + default: [], + }, }, { timestamps: true } ); diff --git a/lib/models/YoutubeImportJob.ts b/lib/models/YoutubeImportJob.ts new file mode 100644 index 00000000..a93937d0 --- /dev/null +++ b/lib/models/YoutubeImportJob.ts @@ -0,0 +1,76 @@ +import mongoose, { Schema } from 'mongoose'; +import type { YoutubeImportJobStatus } from '@/types'; + +/** + * Raw MongoDB document shape for the `youtube_import_jobs` collection. + */ +export interface YoutubeImportJobDocument { + _id: string; + userId: string; + draftId: string; + sourceUrl: string; + youtubeVideoId: string; + livestreamId: string; + startSeconds: number; + endSeconds: number; + status: YoutubeImportJobStatus; + progressPercent: number; + errorMessage: string; + r2Key: string; + uploadJobId: string; + distributeQueued: boolean; + createdAt: Date; + updatedAt: Date; +} + +const ACTIVE_YOUTUBE_IMPORT_JOB_STATUSES: YoutubeImportJobStatus[] = [ + 'pending', + 'downloading', + 'trimming', + 'uploading', +]; + +const YoutubeImportJobSchema = new Schema( + { + _id: { type: String }, + userId: { type: String, required: true, index: true, trim: true }, + draftId: { type: String, required: true, trim: true }, + sourceUrl: { type: String, required: true, trim: true }, + youtubeVideoId: { type: String, required: true, trim: true }, + livestreamId: { type: String, default: '' }, + startSeconds: { type: Number, required: true, min: 0 }, + endSeconds: { type: Number, required: true, min: 0 }, + status: { + type: String, + enum: ['pending', 'downloading', 'trimming', 'uploading', 'completed', 'failed', 'cancelled'], + default: 'pending', + index: true, + }, + progressPercent: { type: Number, default: 0, min: 0, max: 100 }, + errorMessage: { type: String, default: '' }, + r2Key: { type: String, default: '' }, + uploadJobId: { type: String, default: '' }, + distributeQueued: { type: Boolean, default: false }, + }, + { timestamps: true } +); + +YoutubeImportJobSchema.index( + { userId: 1 }, + { + unique: true, + partialFilterExpression: { + status: { $in: ACTIVE_YOUTUBE_IMPORT_JOB_STATUSES }, + }, + } +); + +YoutubeImportJobSchema.index({ draftId: 1, createdAt: -1 }); + +export const YoutubeImportJobModel = + (mongoose.models.YoutubeImportJob as mongoose.Model | undefined) || + mongoose.model( + 'YoutubeImportJob', + YoutubeImportJobSchema, + 'youtube_import_jobs' + ); diff --git a/lib/platforms/connected-accounts-health.ts b/lib/platforms/connected-accounts-health.ts new file mode 100644 index 00000000..280649d0 --- /dev/null +++ b/lib/platforms/connected-accounts-health.ts @@ -0,0 +1,53 @@ +import { + accountNeedsOAuthHealthProbe, + getConnectionStatus, +} from '@/lib/platforms/connection-status'; +import { refreshTokenIfNeeded } from '@/lib/platforms/token-refresh'; +import { + getConnectedAccountForUser, + getConnectedAccountsByUser, + getConnectedAccountWithTokens, +} from '@/lib/repositories/connected-accounts'; +import type { ConnectedAccountPublic } from '@/types'; + +/** + * Loads the user's connected accounts and verifies OAuth refresh health when needed. + * Revoked refresh tokens are cleared during verification via {@link refreshTokenIfNeeded}. + * @param userId - Authenticated user id. + * @returns Public account rows annotated with `connectionStatus`. + */ +export async function getConnectedAccountsWithHealth( + userId: string +): Promise { + const accounts = await getConnectedAccountsByUser(userId); + const results: ConnectedAccountPublic[] = []; + + for (const account of accounts) { + let publicAccount = account; + let connectionStatus = getConnectionStatus(account); + + if (connectionStatus === 'connected' && accountNeedsOAuthHealthProbe(account)) { + const withTokens = await getConnectedAccountWithTokens(userId, account.platform); + if (withTokens && withTokens.id === account.id) { + try { + await refreshTokenIfNeeded(withTokens); + publicAccount = (await getConnectedAccountForUser(account.id, userId)) ?? publicAccount; + connectionStatus = getConnectionStatus(publicAccount); + } catch { + publicAccount = (await getConnectedAccountForUser(account.id, userId)) ?? { + ...account, + hasRefreshToken: false, + }; + connectionStatus = getConnectionStatus(publicAccount); + } + } + } + + results.push({ + ...publicAccount, + connectionStatus: connectionStatus === 'connected' ? 'connected' : 'expired', + }); + } + + return results; +} diff --git a/lib/platforms/connection-status.ts b/lib/platforms/connection-status.ts new file mode 100644 index 00000000..083c6863 --- /dev/null +++ b/lib/platforms/connection-status.ts @@ -0,0 +1,136 @@ +import { normalizeConnectedAccountSftpHostKeyFingerprint } from '@/lib/connected-accounts/sftp-validation'; +import type { ConnectedAccountPlatform, ConnectedAccountPublic } from '@/types'; + +/** + * Whether a connected account row can be used for uploads and distribution. + */ +export type PlatformConnectionStatus = 'connected' | 'expired' | 'not-connected'; + +/** OAuth platforms whose access tokens can be renewed with a stored refresh token. */ +export const OAUTH_REFRESH_PLATFORMS = ['youtube', 'google_drive', 'facebook'] as const; + +/** + * True when an SFTP row has the fields required for backups (including a pinned host key). + * @param account - Public connected account row. + * @returns Whether SFTP backups can run with this row. + */ +export function isSftpConnectionReady(account: ConnectedAccountPublic): boolean { + const fingerprint = account.sftpHostKeyFingerprint; + return ( + account.platform === 'sftp' && + Boolean(account.sftpHost?.trim()) && + Boolean(account.sftpRemotePath?.trim()) && + Boolean(account.sftpAuthMethod) && + fingerprint != null && + normalizeConnectedAccountSftpHostKeyFingerprint(fingerprint) != null + ); +} + +/** + * True when an SMB row has the fields required for backups. + * @param account - Public connected account row. + * @returns Whether SMB backups can run with this row. + */ +export function isSmbConnectionReady(account: ConnectedAccountPublic): boolean { + return ( + account.platform === 'smb' && + Boolean(account.smbHost?.trim()) && + Boolean(account.smbShare?.trim()) && + account.smbRemotePath != null && + account.smbRemotePath.trim() !== '' + ); +} + +/** + * True when a SermonAudio row has the broadcaster id required for uploads. + * @param account - Public connected account row. + * @returns Whether SermonAudio uploads can run with this row. + */ +export function isSermonAudioConnectionReady(account: ConnectedAccountPublic): boolean { + return account.platform === 'sermon_audio' && Boolean(account.platformUserId.trim()); +} + +/** + * Derives connection status from token expiry, refresh-token presence, and platform-specific fields. + * Does not call remote OAuth providers. + * @param account - Public connected account row, if any. + * @returns Static connection status for UI and API filtering. + */ +export function getConnectionStatus( + account: ConnectedAccountPublic | undefined +): PlatformConnectionStatus { + if (!account) return 'not-connected'; + if (account.platform === 'sftp') { + return isSftpConnectionReady(account) ? 'connected' : 'expired'; + } + if (account.platform === 'smb') { + return isSmbConnectionReady(account) ? 'connected' : 'expired'; + } + if (account.platform === 'sermon_audio') { + return isSermonAudioConnectionReady(account) ? 'connected' : 'expired'; + } + const expiryMs = new Date(account.tokenExpiry).getTime(); + if (!Number.isNaN(expiryMs) && expiryMs > Date.now()) return 'connected'; + if ( + (account.platform === 'youtube' || + account.platform === 'google_drive' || + account.platform === 'facebook') && + account.hasRefreshToken + ) { + return 'connected'; + } + return 'expired'; +} + +/** + * Resolves the effective connection status, preferring a server-verified value when present. + * @param account - Public connected account row, if any. + * @returns Connection status suitable for UI badges and platform toggles. + */ +export function resolveConnectionStatus( + account: ConnectedAccountPublic | undefined +): PlatformConnectionStatus { + if (!account) return 'not-connected'; + if (account.connectionStatus != null) return account.connectionStatus; + return getConnectionStatus(account); +} + +/** + * True when the account row is healthy enough for uploads, imports, and distribution. + * @param account - Public connected account row. + * @returns Whether the platform should appear as connected in client UI. + */ +export function isUsablePlatformConnection(account: ConnectedAccountPublic): boolean { + return resolveConnectionStatus(account) === 'connected'; +} + +/** + * Returns platform ids for accounts that passed connection health checks. + * @param accounts - Connected account rows from the connections API. + * @returns Platforms that can be selected for distribution. + */ +export function getUsableConnectedPlatforms( + accounts: ConnectedAccountPublic[] +): ConnectedAccountPlatform[] { + return accounts.filter(isUsablePlatformConnection).map((account) => account.platform); +} + +/** + * True when an OAuth account should be probed because the access token is expired + * but a refresh token is still stored. + * @param account - Public connected account row. + * @returns Whether a refresh attempt is needed to verify health. + */ +export function accountNeedsOAuthHealthProbe(account: ConnectedAccountPublic): boolean { + if ( + account.platform !== 'youtube' && + account.platform !== 'google_drive' && + account.platform !== 'facebook' + ) { + return false; + } + if (!account.hasRefreshToken) return false; + const expiryMs = Date.parse(account.tokenExpiry); + if (!Number.isNaN(expiryMs) && expiryMs > Date.now()) return false; + return true; +} diff --git a/lib/platforms/oauth-refresh-errors.ts b/lib/platforms/oauth-refresh-errors.ts new file mode 100644 index 00000000..d5525caf --- /dev/null +++ b/lib/platforms/oauth-refresh-errors.ts @@ -0,0 +1,24 @@ +/** + * Detects OAuth refresh failures that mean the stored grant is no longer valid. + * @param details - Provider error payload or message from a failed refresh. + * @returns True when the user must reconnect the platform account. + */ +export function isOAuthRefreshTokenRevokedError(details: unknown): boolean { + let text = ''; + if (typeof details === 'string') { + text = details; + } else if (details != null) { + try { + text = JSON.stringify(details); + } catch { + text = String(details); + } + } + + const normalized = text.toLowerCase(); + return ( + normalized.includes('invalid_grant') || + normalized.includes('token has been expired or revoked') || + normalized.includes('token has been revoked') + ); +} diff --git a/lib/platforms/sermon-audio-cross-publish.ts b/lib/platforms/sermon-audio-cross-publish.ts index bd0b9e21..ca205607 100644 --- a/lib/platforms/sermon-audio-cross-publish.ts +++ b/lib/platforms/sermon-audio-cross-publish.ts @@ -73,6 +73,17 @@ export const SERMON_AUDIO_CROSS_PUBLISH_DESTINATIONS: readonly SermonAudioCrossP supportsVideoMetadata: false, supportsPrivacy: false, }, + { + id: 'instagram', + label: 'Instagram', + options: [ + { id: 'postLink', label: 'Post link to sermon' }, + { id: 'uploadVideoPreview', label: 'Upload video preview to Instagram' }, + ], + supportsLinkMessage: true, + supportsVideoMetadata: false, + supportsPrivacy: false, + }, ] as const; const YOUTUBE_PRIVACY_VALUES = new Set([ @@ -155,6 +166,14 @@ export function normalizeSermonAudioCrossPublishPlatformSettings( } } + // SermonAudio dashboard: link post must be enabled before video options on Facebook, X, and Instagram. + if (destId === 'facebook' && out.postLink !== true) { + delete out.uploadFullVideo; + } + if ((destId === 'x' || destId === 'instagram') && out.postLink !== true) { + delete out.uploadVideoPreview; + } + return Object.keys(out).length > 0 ? out : undefined; } @@ -184,33 +203,39 @@ export function normalizeSermonAudioCrossPublishSettings( } /** SermonAudio `SocialEnum` value for a Cross Publish destination. */ -export type SermonAudioSocialSharingPlatform = 'google' | 'facebook' | 'twitter'; +export type SermonAudioSocialSharingPlatform = 'google' | 'facebook' | 'twitter' | 'instagram'; -/** One Cross Publish destination in the sermon create `socialSharing` array. */ -export interface SermonAudioSocialSharingCreateEntry { +/** One Cross Publish destination in `socialSharingSettings.platforms`. */ +export interface SermonAudioSocialSharingSettingsPlatformEntry { /** SermonAudio social platform id (`google` = YouTube). */ platform: SermonAudioSocialSharingPlatform; - /** Required by SA (`message` on `SocialSharingSettingsPlatformAPIParam`). */ + /** Post message (`message` on `SocialSharingSettingsPlatformAPIParam`). */ message: string; - /** YouTube video title (`title` on `SocialSharingSettingsPlatformAPIParam`). */ + /** YouTube video title when uploading full video to YouTube. */ title?: string; - /** YouTube visibility (`privacy` on `SocialSharingSettingsPlatformAPIParam`). */ + /** YouTube visibility when uploading full video to YouTube. */ privacy?: string; - /** When true, attach the configured preview clip (X video preview). */ + /** When true, include video with the cross-post (Facebook full video or X preview clip); when false, link only. */ useVideoClip?: boolean; } -/** Cross Publish fields merged into the sermon create POST body. */ -export interface SermonAudioSocialSharingCreateFields { - /** Per-destination Cross Publish options (array form used by the live SA API). */ - socialSharing: SermonAudioSocialSharingCreateEntry[]; - /** Preview clip range (seconds) when X video preview is enabled. */ - social_sharing_video_clip?: { start: number; end: number }; +/** + * Cross Publish payload nested under `socialSharingSettings` on sermon PATCH/PUT. + * Matches the SermonAudio dashboard and OpenAPI `SocialSharingSettingsAPIParam`. + */ +export interface SermonAudioSocialSharingSettings { + /** Per-destination Cross Publish options for selected platforms. */ + platforms: SermonAudioSocialSharingSettingsPlatformEntry[]; + /** When true, cross-post to YouTube (`google` platform entry). */ + google?: boolean; + /** When true, cross-post to Facebook. */ + facebook?: boolean; + /** When true, cross-post to X/Twitter. */ + twitter?: boolean; + /** When true, cross-post to Instagram. */ + instagram?: boolean; } -/** Default preview clip length (seconds) for X Cross Publish video preview. */ -export const SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS = 120; - function platformHasCrossPublishSelection( destId: SermonAudioCrossPublishTarget, settings: SermonAudioCrossPublishPlatformSettings | undefined @@ -218,6 +243,9 @@ function platformHasCrossPublishSelection( if (!settings) return false; const dest = SERMON_AUDIO_CROSS_PUBLISH_DESTINATIONS.find((entry) => entry.id === destId); if (!dest) return false; + if (destId === 'facebook' || destId === 'x' || destId === 'instagram') { + return settings.postLink === true; + } return dest.options.some((option) => settings[option.id] === true); } @@ -236,78 +264,83 @@ export function sermonAudioCrossPublishHasActiveSelection( } /** - * Builds Cross Publish fields for the SermonAudio sermon create body (`socialSharing` array + - * optional `social_sharing_video_clip`). Matches the live API shape used by transferupload and - * the SermonAudio dashboard; the public OpenAPI spec documents the nested `platforms` form but - * not this array form. + * Builds Cross Publish `socialSharingSettings` for SermonAudio sermon publish (PATCH/PUT). * @param settings - Normalized Cross Publish settings from draft metadata. * @param options - Optional draft defaults when Cross Publish text fields are empty. - * @returns SermonAudio body fields, or `undefined` when Cross Publish is off or empty. + * @returns Nested Cross Publish settings, or `undefined` when Cross Publish is off or empty. */ -export function buildSermonAudioSocialSharingCreateFields( +export function buildSermonAudioSocialSharingSettings( settings: SermonAudioCrossPublishSettings | undefined, options?: { defaultTitle?: string; defaultDescription?: string } -): SermonAudioSocialSharingCreateFields | undefined { +): SermonAudioSocialSharingSettings | undefined { if (!sermonAudioCrossPublishHasActiveSelection(settings)) return undefined; const defaultTitle = options?.defaultTitle?.trim() ?? ''; const defaultDescription = options?.defaultDescription?.trim() ?? ''; - const socialSharing: SermonAudioSocialSharingCreateEntry[] = []; - let usesVideoClip = false; + const platforms: SermonAudioSocialSharingSettingsPlatformEntry[] = []; + const toggles: Pick< + SermonAudioSocialSharingSettings, + 'google' | 'facebook' | 'twitter' | 'instagram' + > = {}; for (const dest of SERMON_AUDIO_CROSS_PUBLISH_DESTINATIONS) { const platformSettings = settings?.[dest.id]; if (!platformHasCrossPublishSelection(dest.id, platformSettings)) continue; if (dest.id === 'youtube') { - socialSharing.push({ + platforms.push({ platform: 'google', title: platformSettings?.title?.trim() || defaultTitle, message: platformSettings?.description?.trim() || defaultDescription || defaultTitle, privacy: platformSettings?.privacy ?? 'public', }); + toggles.google = true; continue; } if (dest.id === 'facebook') { const postLink = platformSettings?.postLink === true; + if (!postLink) continue; + const uploadFullVideo = platformSettings?.uploadFullVideo === true; const customMessage = platformSettings?.linkMessage?.trim() ?? ''; - const message = postLink - ? customMessage || defaultTitle - : uploadFullVideo - ? defaultDescription || defaultTitle - : defaultTitle; + const message = customMessage || defaultTitle; - socialSharing.push({ platform: 'facebook', message }); + platforms.push({ + platform: 'facebook', + message, + useVideoClip: uploadFullVideo, + }); + toggles.facebook = true; continue; } - const postLink = platformSettings?.postLink === true; - const uploadVideoPreview = platformSettings?.uploadVideoPreview === true; - const customMessage = platformSettings?.linkMessage?.trim() ?? ''; - const message = postLink ? customMessage || defaultTitle : defaultTitle; + if (dest.id === 'x' || dest.id === 'instagram') { + const postLink = platformSettings?.postLink === true; + if (!postLink) continue; - if (uploadVideoPreview) usesVideoClip = true; + const uploadVideoPreview = platformSettings?.uploadVideoPreview === true; + const customMessage = platformSettings?.linkMessage?.trim() ?? ''; + const message = customMessage || defaultTitle; - socialSharing.push({ - platform: 'twitter', - message, - ...(uploadVideoPreview ? { useVideoClip: true } : {}), - }); + platforms.push({ + platform: dest.id === 'x' ? 'twitter' : 'instagram', + message, + useVideoClip: uploadVideoPreview, + }); + if (dest.id === 'x') { + toggles.twitter = true; + } else { + toggles.instagram = true; + } + continue; + } } - if (socialSharing.length === 0) return undefined; + if (platforms.length === 0) return undefined; return { - socialSharing, - ...(usesVideoClip - ? { - social_sharing_video_clip: { - start: 0, - end: SERMON_AUDIO_CROSS_PUBLISH_VIDEO_CLIP_END_SECONDS, - }, - } - : {}), + platforms, + ...toggles, }; } diff --git a/lib/platforms/sermon-audio-schedule.ts b/lib/platforms/sermon-audio-schedule.ts index 0ab04054..679bdc1c 100644 --- a/lib/platforms/sermon-audio-schedule.ts +++ b/lib/platforms/sermon-audio-schedule.ts @@ -1,82 +1,18 @@ -import { utcIsoToZonedScheduleParts, zonedDateTimeToUtcIso } from '@/lib/youtube-schedule'; +export { validateSermonAudioScheduledPublishTime } from '@/lib/schedule-bounds'; -/** - * Parses a SermonAudio `GMT` offset label from `Intl` into an ISO 8601 offset (`±HH:MM`). - * @param raw - Value from `timeZoneName: 'longOffset'` (for example `GMT-04:00`). - * @returns Offset suffix such as `-04:00`. - */ -function parseLongOffsetToIsoOffset(raw: string): string { - if (raw === 'GMT' || raw === 'UTC') return '+00:00'; - if (!raw.startsWith('GMT')) return '+00:00'; - - const body = raw.slice(3); - if (/^[+-]\d{2}:\d{2}$/.test(body)) return body; - if (/^[+-]\d{1}:\d{2}$/.test(body)) return `${body[0]}0${body.slice(1)}`; - if (/^[+-]\d{2}$/.test(body)) return `${body}:00`; - if (/^[+-]\d{1}$/.test(body)) return `${body[0]}0${body[1]}:00`; - return '+00:00'; -} - -/** - * Returns the ISO 8601 offset for an instant in an IANA timezone. - * @param utcIso - UTC ISO 8601 timestamp. - * @param timeZone - IANA timezone name. - * @returns Offset suffix such as `-04:00`. - */ -export function formatTimeZoneOffsetForInstant(utcIso: string, timeZone: string): string { - const date = new Date(utcIso); - const formatter = new Intl.DateTimeFormat('en-US', { - timeZone, - timeZoneName: 'longOffset', - }); - const raw = - formatter.formatToParts(date).find((part) => part.type === 'timeZoneName')?.value ?? ''; - return parseLongOffsetToIsoOffset(raw); -} - -/** - * Formats wall-clock date/time in an IANA timezone as SermonAudio `publishDate`. - * Includes a timezone offset for scheduling precision. - * @param dateStr - Calendar date (`YYYY-MM-DD`). - * @param timeStr - Clock time (`HH:MM`). - * @param timeZone - IANA timezone name. - * @returns SermonAudio publish datetime (for example `2026-07-01T09:00:00-04:00`). - * @throws When the wall-clock date/time does not exist in `timeZone`. - */ -export function formatSermonAudioPublishDate( - dateStr: string, - timeStr: string, - timeZone: string -): string { - const utcIso = zonedDateTimeToUtcIso(dateStr, timeStr, timeZone); - const offset = formatTimeZoneOffsetForInstant(utcIso, timeZone); - return `${dateStr}T${timeStr}:00${offset}`; -} - -/** - * Parses a stored SermonAudio `publishDate` into a UTC ISO string for schedule pickers. - * @param publishDate - SermonAudio publish datetime string. - * @returns UTC ISO 8601 timestamp, or null when invalid. - */ -export function sermonAudioPublishDateToUtcIso(publishDate: string): string | null { - const trimmed = publishDate.trim(); - if (!trimmed) return null; - const parsed = Date.parse(trimmed); - if (Number.isNaN(parsed)) return null; - return new Date(parsed).toISOString(); -} +import { utcIsoToZonedScheduleParts } from '@/lib/youtube-schedule'; /** - * Parses a stored SermonAudio `publishDate` into schedule picker parts. - * @param publishDate - SermonAudio publish datetime string. + * Parses a stored SermonAudio `publishTimestamp` into schedule picker parts. + * @param publishTimestamp - Unix timestamp in seconds. * @param timeZone - IANA timezone used to display wall-clock values. * @returns Date and time strings for the picker, or null when invalid. */ -export function sermonAudioPublishDateToScheduleParts( - publishDate: string, +export function sermonAudioPublishTimestampToScheduleParts( + publishTimestamp: number, timeZone: string ): { dateStr: string; timeStr: string } | null { - const utcIso = sermonAudioPublishDateToUtcIso(publishDate); - if (!utcIso) return null; + if (!Number.isFinite(publishTimestamp)) return null; + const utcIso = new Date(Math.floor(publishTimestamp) * 1000).toISOString(); return utcIsoToZonedScheduleParts(utcIso, timeZone); } diff --git a/lib/platforms/sermon-audio-social-connections.ts b/lib/platforms/sermon-audio-social-connections.ts new file mode 100644 index 00000000..7d1a51b6 --- /dev/null +++ b/lib/platforms/sermon-audio-social-connections.ts @@ -0,0 +1,131 @@ +import { + SERMONAUDIO_API_BASE, + assertSermonAudioHttpOk, + resolveSermonAudioApiUrl, + sermonAudioJsonHeaders, + SermonAudioUpstreamHttpError, +} from '@/lib/platforms/sermon-audio-http'; +import type { SermonAudioCrossPublishTarget } from '@/types'; + +/** SermonAudio dashboard page where Cross Publish OAuth connections are managed. */ +export const SERMONAUDIO_SOCIAL_CONNECTIONS_DASHBOARD_URL = + 'https://www.sermonaudio.com/dashboard/account/connections/'; + +/** OAuth connection status for one Cross Publish destination. */ +export interface SermonAudioSocialConnectionPlatformStatus { + /** When true, the platform is linked for Cross Publish in the SermonAudio dashboard. */ + connected: boolean; + /** Connected account or page name from SermonAudio, when available. */ + displayName?: string; +} + +/** + * Cross Publish OAuth connection status keyed by VideoSphere destination id. + * Populated from SermonAudio `POST .../refresh_social` (`google` → YouTube, `twitter` → X). + */ +export interface SermonAudioCrossPublishSocialConnections { + /** YouTube (`google` in SermonAudio refresh_social response). */ + youtube: SermonAudioSocialConnectionPlatformStatus; + /** Facebook. */ + facebook: SermonAudioSocialConnectionPlatformStatus; + /** X / Twitter (`twitter` in SermonAudio refresh_social response). */ + x: SermonAudioSocialConnectionPlatformStatus; + /** Instagram. */ + instagram: SermonAudioSocialConnectionPlatformStatus; +} + +function parseHasOAuth(value: unknown): boolean { + if (!value || typeof value !== 'object') return false; + return (value as Record).hasOAUTH === true; +} + +function parseConnectionDisplayName(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + const pageName = typeof record.pageName === 'string' ? record.pageName.trim() : ''; + const username = typeof record.username === 'string' ? record.username.trim() : ''; + return name || pageName || username || undefined; +} + +/** + * Parses SermonAudio `refresh_social` JSON into Cross Publish connection flags. + * @param body - Raw upstream JSON body. + * @returns Connection status for YouTube, Facebook, X, and Instagram. + */ +export function parseSermonAudioRefreshSocialResponse( + body: unknown +): SermonAudioCrossPublishSocialConnections { + const raw = body && typeof body === 'object' ? (body as Record) : {}; + + return { + youtube: { + connected: parseHasOAuth(raw.google), + displayName: parseConnectionDisplayName(raw.google), + }, + facebook: { + connected: parseHasOAuth(raw.facebook), + displayName: parseConnectionDisplayName(raw.facebook), + }, + x: { + connected: parseHasOAuth(raw.twitter), + displayName: parseConnectionDisplayName(raw.twitter), + }, + instagram: { + connected: parseHasOAuth(raw.instagram), + displayName: parseConnectionDisplayName(raw.instagram), + }, + }; +} + +/** + * Builds the SermonAudio `refresh_social` URL for a broadcaster. + * @param broadcasterId - SermonAudio broadcaster id. + * @returns Resolved HTTPS URL. + */ +export function sermonAudioRefreshSocialUrl(broadcasterId: string): string { + const path = + `${SERMONAUDIO_API_BASE}/v2/node/broadcasters/${encodeURIComponent(broadcasterId)}` + + '/refresh_social?cacheLanguage=en&cacheMax=181&cacheDomain=www.sermonaudio.com'; + const url = resolveSermonAudioApiUrl(path); + if (!url) { + throw new SermonAudioUpstreamHttpError('Invalid SermonAudio refresh_social URL', 500); + } + return url; +} + +/** + * Fetches Cross Publish OAuth connection status from SermonAudio. + * Uses undocumented `POST .../refresh_social` (dashboard parity; works with API key). + * @param apiKey - SermonAudio API key for the connected account. + * @param broadcasterId - SermonAudio broadcaster id. + * @returns Connection status per Cross Publish destination. + */ +export async function fetchSermonAudioCrossPublishSocialConnections( + apiKey: string, + broadcasterId: string +): Promise { + const response = await fetch(sermonAudioRefreshSocialUrl(broadcasterId), { + method: 'POST', + headers: sermonAudioJsonHeaders(apiKey), + cache: 'no-store', + redirect: 'error', + }); + + await assertSermonAudioHttpOk(response, 'SermonAudio refresh_social failed'); + const body: unknown = await response.json(); + return parseSermonAudioRefreshSocialResponse(body); +} + +/** + * Returns whether a Cross Publish destination is OAuth-connected in SermonAudio. + * @param connections - Parsed connection status from `fetchSermonAudioCrossPublishSocialConnections`. + * @param destinationId - Cross Publish destination id from draft metadata. + * @returns True when the platform is linked in the SermonAudio dashboard. + */ +export function sermonAudioCrossPublishDestinationConnected( + connections: SermonAudioCrossPublishSocialConnections, + destinationId: SermonAudioCrossPublishTarget +): boolean { + return connections[destinationId].connected; +} diff --git a/lib/platforms/sermon-audio.ts b/lib/platforms/sermon-audio.ts index 527459d6..312802aa 100644 --- a/lib/platforms/sermon-audio.ts +++ b/lib/platforms/sermon-audio.ts @@ -3,7 +3,7 @@ import { MAX_DRAFT_THUMBNAIL_BYTES, } from '@/lib/draft-thumbnail'; import { formatSermonAudioLocalDate } from '@/lib/platforms/sermon-audio-event-types'; -import { buildSermonAudioSocialSharingCreateFields } from '@/lib/platforms/sermon-audio-cross-publish'; +import { buildSermonAudioSocialSharingSettings } from '@/lib/platforms/sermon-audio-cross-publish'; import { SERMONAUDIO_API_BASE, resolveSermonAudioUploadUrl, @@ -17,6 +17,7 @@ import type { PlatformUploadResult, PlatformUploadTokens, } from '@/lib/platforms/types'; +import type { SermonAudioCrossPublishSettings } from '@/types'; const SERMONAUDIO_SERMONS_URL = `${SERMONAUDIO_API_BASE}/v2/node/sermons`; const SERMONAUDIO_MEDIA_URL = `${SERMONAUDIO_API_BASE}/v2/media`; @@ -43,14 +44,6 @@ interface UploadToSermonAudioInput { interface PollSermonAudioProcessingInput { sermonID: string; tokens: PlatformUploadTokens; - /** - * When true, poll until transcoding completes (codec/status) because a custom thumbnail was - * successfully uploaded during distribute; the poster may appear before encoding finishes. - * When false or omitted, poll until SermonAudio generates a poster frame on any `media.video` - * entry. Set from {@link PlatformUploadResult.sermonAudioCustomThumbnailUploaded}, not from - * draft metadata alone. - */ - customThumbnailUploaded?: boolean; /** Delay between poll attempts in milliseconds. Defaults to {@link SERMONAUDIO_PROCESSING_POLL_INTERVAL_MS}. */ intervalMs?: number; /** Maximum poll attempts before rejecting. Defaults to {@link SERMONAUDIO_PROCESSING_MAX_ATTEMPTS}. */ @@ -61,6 +54,14 @@ interface PollSermonAudioProcessingInput { interface PublishSermonAudioInput { sermonID: string; tokens: PlatformUploadTokens; + /** Cross Publish settings from draft metadata; sent as `socialSharingSettings` on publish. */ + crossPublish?: SermonAudioCrossPublishSettings; + /** Draft title used when Cross Publish message fields are empty. */ + defaultTitle?: string; + /** Draft description used when Cross Publish message fields are empty. */ + defaultDescription?: string; + /** Unix timestamp (seconds) for publication. Defaults to the current time when omitted. */ + publishTimestamp?: number; signal?: AbortSignal; } @@ -186,16 +187,6 @@ function buildCreateSermonBody(metadata: PlatformUploadMetadata): Record 0; -} - -function sermonVideoEntryIsReady(item: unknown): boolean { - if (!item || typeof item !== 'object') return false; - const entry = item as { videoCodec?: unknown; videoMediaStatus?: unknown }; - if (hasSermonVideoCodec(entry)) return true; - return isSuccessfulVideoMediaStatus(entry.videoMediaStatus); -} - -function hasSermonVideoThumbnail(item: unknown): boolean { - if (!item || typeof item !== 'object') return false; - const thumbnailImageURL = (item as { thumbnailImageURL?: unknown }).thumbnailImageURL; - return typeof thumbnailImageURL === 'string' && thumbnailImageURL.trim().length > 0; -} - -/** - * True when SermonAudio has finished transcoding any `media.video` rendition. - * Gates on non-null `videoCodec` or successful `videoMediaStatus` (`ready`) — not `thumbnailImageURL`, - * since a custom thumbnail may populate that field before transcoding completes. - */ -function sermonVideoTranscodingIsReady(payload: unknown): boolean { - if (payload === null || typeof payload !== 'object') return false; - const media = (payload as SermonMediaPayload).media; - if (!media || !Array.isArray(media.video) || media.video.length === 0) return false; - return media.video.some((item) => sermonVideoEntryIsReady(item)); -} - /** - * True when SermonAudio has generated a video thumbnail on any `media.video` entry. - * Transcoding can finish before SA extracts the poster frame, so do not gate on codec/status alone. + * True when SermonAudio reports sermon video is ready for publish (`hasVideo` + `videoMediaStatus: ready`). + * @param payload - GET sermon JSON body. + * @returns Whether video processing has reached the publish gate. */ -function sermonVideoPosterIsReady(payload: unknown): boolean { +function sermonVideoPublishIsReady(payload: unknown): boolean { if (payload === null || typeof payload !== 'object') return false; - const media = (payload as SermonMediaPayload).media; - if (!media || !Array.isArray(media.video)) return false; - return media.video.some((item) => hasSermonVideoThumbnail(item)); -} - -function sermonVideoIsReady(payload: unknown, customThumbnailUploaded: boolean): boolean { - // Custom thumbnail success: wait for transcoding, not SA-generated poster URL. - return customThumbnailUploaded - ? sermonVideoTranscodingIsReady(payload) - : sermonVideoPosterIsReady(payload); + const root = payload as { hasVideo?: unknown; videoMediaStatus?: unknown }; + if (root.hasVideo !== true) return false; + return isSuccessfulVideoMediaStatus(root.videoMediaStatus); } /** @@ -651,12 +605,9 @@ export async function uploadToSermonAudio( } /** - * Polls SermonAudio until sermon video processing completes. - * Without a successfully uploaded custom thumbnail, waits for SA-generated `thumbnailImageURL` - * on any `media.video` entry. With one, waits for transcoding (`videoCodec` or - * `videoMediaStatus: ready`) instead, since the uploaded poster may appear before encoding finishes. + * Polls SermonAudio until sermon video is ready for publish (`hasVideo` and `videoMediaStatus: ready`). * @param input - Sermon id, API key tokens, poll tuning, and optional abort signal. - * @returns Resolves when processing is complete. + * @returns Resolves when video is ready. * @throws When transcoding fails, polling is aborted, or `maxAttempts` is exceeded. */ export async function pollSermonAudioProcessing( @@ -677,7 +628,6 @@ export async function pollSermonAudioProcessing( const intervalMs = input.intervalMs ?? SERMONAUDIO_PROCESSING_POLL_INTERVAL_MS; const maxAttempts = input.maxAttempts ?? SERMONAUDIO_PROCESSING_MAX_ATTEMPTS; - const customThumbnailUploaded = input.customThumbnailUploaded === true; const pollUrl = `${SERMONAUDIO_SERMONS_URL}/${encodeURIComponent(sermonID)}?allowUnpublished=true`; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { @@ -713,7 +663,7 @@ export async function pollSermonAudioProcessing( `videoMediaStatus: ${processingFailure}` ); } - if (sermonVideoIsReady(payload, customThumbnailUploaded)) { + if (sermonVideoPublishIsReady(payload)) { return; } @@ -729,10 +679,9 @@ export async function pollSermonAudioProcessing( } /** - * Publishes a SermonAudio sermon by PATCHing `publishDate` to today's local calendar date (`YYYY-MM-DD`). - * Cross Publish options must already be on the sermon from the create POST (`socialSharing`); - * publishing triggers SA to run the configured cross-posts once video processing is complete. - * @param input - Sermon id, API key tokens, and optional abort signal. + * Publishes a SermonAudio sermon by PATCHing `publishTimestamp`. + * When Cross Publish is configured, includes `socialSharingSettings` in the same request. + * @param input - Sermon id, API key tokens, optional publish time, Cross Publish settings, and abort signal. * @throws When the publish request fails. */ export async function publishSermonAudio(input: PublishSermonAudioInput): Promise { @@ -750,12 +699,21 @@ export async function publishSermonAudio(input: PublishSermonAudioInput): Promis } const publishUrl = `${SERMONAUDIO_SERMONS_URL}/${encodeURIComponent(sermonID)}`; + const socialSharingSettings = buildSermonAudioSocialSharingSettings(input.crossPublish, { + defaultTitle: input.defaultTitle, + defaultDescription: input.defaultDescription, + }); + const publishBody: Record = { + publishTimestamp: input.publishTimestamp ?? Math.floor(Date.now() / 1000), + }; + if (socialSharingSettings) { + publishBody.socialSharingSettings = socialSharingSettings; + } + const response = await fetch(publishUrl, { method: 'PATCH', headers: sermonAudioJsonHeaders(apiKey), - body: JSON.stringify({ - publishDate: formatSermonAudioLocalDate(), - }), + body: JSON.stringify(publishBody), ...(input.signal ? { signal: input.signal } : {}), }); diff --git a/lib/platforms/token-refresh.ts b/lib/platforms/token-refresh.ts index 41d6d756..f71d98bc 100644 --- a/lib/platforms/token-refresh.ts +++ b/lib/platforms/token-refresh.ts @@ -7,7 +7,8 @@ import type { ConnectedAccount } from '@/types'; import type { PlatformUploadTokens } from '@/lib/platforms/types'; -import { updateTokens } from '@/lib/repositories/connected-accounts'; +import { isOAuthRefreshTokenRevokedError } from '@/lib/platforms/oauth-refresh-errors'; +import { clearOAuthRefreshToken, updateTokens } from '@/lib/repositories/connected-accounts'; import { refreshFacebookPageConnection, refreshFacebookProfileConnection, @@ -24,6 +25,21 @@ function facebookRefreshFailureMessage(providerError: string): string { return `Facebook token refresh failed: ${detail}. Please reconnect your Facebook account to continue.`; } +async function clearRevokedOAuthRefreshTokenIfNeeded( + account: ConnectedAccount, + details: unknown +): Promise { + if (!isOAuthRefreshTokenRevokedError(details)) return; + try { + await clearOAuthRefreshToken(account.id); + } catch (err) { + console.error( + `[token-refresh] Failed to clear revoked refresh token for ${account.platform} account ${account.id}:`, + err + ); + } +} + /** * Defines the PlatformTokens type. */ @@ -94,6 +110,7 @@ export async function refreshTokenIfNeeded(account: ConnectedAccount): Promise

{ + if (!localFilePath) { + throw new Error('Local file path is required'); + } + if (!key) { + throw new Error('Object key is required'); + } + if (!contentType) { + throw new Error('Content type is required'); + } + + const client = getR2Client(); + const bucket = getBucketName(); + + const upload = new Upload({ + client, + params: { + Bucket: bucket, + Key: key, + Body: createReadStream(localFilePath), + ContentType: contentType, + }, + }); + + await upload.done(); + + return headObject(key); +} + /** * Generate presigned download URL for file retrieval * Used by distribution engine to read video files diff --git a/lib/repositories/connected-accounts.ts b/lib/repositories/connected-accounts.ts index 04e2db1c..c71a50ac 100644 --- a/lib/repositories/connected-accounts.ts +++ b/lib/repositories/connected-accounts.ts @@ -357,6 +357,24 @@ export async function updateTokens( return rowToConnectedAccountPublic(updated); } +/** + * Clears a stored OAuth refresh token after the provider reports the grant is invalid. + * The row is kept so the UI can prompt the user to reconnect. + * @param id - Connected account row id. + * @returns Updated public account row, or null when the row no longer exists. + */ +export async function clearOAuthRefreshToken(id: string): Promise { + await connectToDatabase(); + const updated = await ConnectedAccountModel.findByIdAndUpdate( + id, + { refreshToken: '' }, + { returnDocument: 'after', runValidators: true } + ).lean(); + + if (!updated) return null; + return rowToConnectedAccountPublic(updated); +} + /** * Update tokens and platform metadata (name, userId) for an existing connection. * Use this on reconnection so the stored channel name/id stays current. diff --git a/lib/repositories/drafts.ts b/lib/repositories/drafts.ts index 12694ba0..6cf433eb 100644 --- a/lib/repositories/drafts.ts +++ b/lib/repositories/drafts.ts @@ -30,6 +30,17 @@ import { mergeDraftPlatformsPatch, stringifyDraftDocumentForStorage, } from '@/lib/draft-upload-metadata'; +import { draftLabelListIncludesEquivalent, normalizeDraftLabel } from '@/lib/draft-labels'; + +/** Max draft updates per bulkWrite when stripping labels (keeps memory bounded). */ +const DRAFT_LABEL_REMOVAL_BULK_CHUNK_SIZE = 500; + +type DraftLabelRemovalBulkOp = { + updateOne: { + filter: { _id: string }; + update: { $set: { document: string; updatedAt: Date } }; + }; +}; /** Map a MongoDB document to the shared Draft type. */ function mongoDocToDraft(doc: DraftDocument): Draft { @@ -41,6 +52,7 @@ function mongoDocToDraft(doc: DraftDocument): Draft { title: parsed.title, description: parsed.description, tags: parsed.tags, + labels: parsed.labels, visibility: parsed.visibility, platforms: parsed.platforms, backupNaming: parsed.backupNaming, @@ -69,6 +81,7 @@ export async function markDraftUsedInUpload( description: draft.description, visibility: draft.visibility, tags: draft.tags, + labels: draft.labels ?? [], platforms: draft.platforms, backupNaming: draft.backupNaming, ...(draft.thumbnailR2Key ? { thumbnailR2Key: draft.thumbnailR2Key } : {}), @@ -147,6 +160,8 @@ export interface CreateDraftInput { description: string; /** Shared tags for all targets; default []. */ tags?: string[]; + /** Organizational draft labels; default []. */ + labels?: string[]; visibility?: PlatformUploadVisibility; platforms?: DraftPlatforms; backupNaming?: BackupFileNameSettings; @@ -161,6 +176,7 @@ export async function createDraft(input: CreateDraftInput): Promise { const visibility = input.visibility ?? DEFAULT_DRAFT_VISIBILITY; const platforms = input.platforms ?? {}; const tags = input.tags ?? []; + const labels = input.labels ?? []; const title = resolveDraftTitleForStorage({ title: input.title, targets: input.targets, @@ -172,6 +188,7 @@ export async function createDraft(input: CreateDraftInput): Promise { description: input.description, visibility, tags, + labels, platforms, backupNaming: backupNamingForStorage(input.backupNaming), ...(input.thumbnailR2Key ? { thumbnailR2Key: input.thumbnailR2Key } : {}), @@ -334,6 +351,7 @@ export interface UpdateDraftInput { title?: string; description?: string; tags?: string[]; + labels?: string[]; visibility?: PlatformUploadVisibility; /** Partial platforms object from PATCH; merged without wiping omitted fields. */ platformsPatch?: unknown; @@ -359,6 +377,7 @@ export async function updateDraft(id: string, input: UpdateDraftInput): Promise< input.title !== undefined || input.description !== undefined || input.tags !== undefined || + input.labels !== undefined || input.visibility !== undefined || input.platformsPatch !== undefined || input.backupNaming !== undefined || @@ -423,6 +442,7 @@ export async function updateDraft(id: string, input: UpdateDraftInput): Promise< title, description: input.description ?? current.description, tags: input.tags ?? current.tags, + labels: input.labels ?? current.labels, visibility: input.visibility ?? current.visibility, platforms: mergedPlatforms, backupNaming, @@ -442,6 +462,75 @@ export async function updateDraft(id: string, input: UpdateDraftInput): Promise< return mongoDocToDraft(updated); } +/** + * Removes multiple organizational labels from every draft owned by a user in one scan. + * @param userId - Draft owner id. + * @param labels - Labels to remove (case-insensitive match). + */ +export async function removeLabelsFromAllDraftsForUser( + userId: string, + labels: readonly string[] +): Promise { + const normalizedTargets = labels.map(normalizeDraftLabel).filter(Boolean); + if (normalizedTargets.length === 0) return; + + await connectToDatabase(); + const now = new Date(); + let bulkOps: DraftLabelRemovalBulkOp[] = []; + + const flushBulkOps = async (): Promise => { + if (bulkOps.length === 0) return; + await DraftModel.bulkWrite(bulkOps, { ordered: false }); + bulkOps = []; + }; + + const cursor = DraftModel.find({ userId }) + .select({ _id: 1, document: 1 }) + .lean>() + .cursor(); + + for await (const row of cursor) { + const parsed = draftDocumentFromRow({ document: row.document }); + const nextLabels = parsed.labels.filter( + (existing) => + !normalizedTargets.some((target) => draftLabelListIncludesEquivalent([target], existing)) + ); + if (nextLabels.length === parsed.labels.length) { + continue; + } + + const documentJson = stringifyDraftDocumentForStorage({ + ...parsed, + labels: nextLabels, + }); + assertDraftDocumentJsonWithinLimit(documentJson); + bulkOps.push({ + updateOne: { + filter: { _id: row._id }, + update: { $set: { document: documentJson, updatedAt: now } }, + }, + }); + + if (bulkOps.length >= DRAFT_LABEL_REMOVAL_BULK_CHUNK_SIZE) { + await flushBulkOps(); + } + } + + await flushBulkOps(); +} + +/** + * Removes an organizational label from every draft owned by a user. + * @param userId - Draft owner id. + * @param label - Label to remove (case-insensitive match). + */ +export async function removeLabelFromAllDraftsForUser( + userId: string, + label: string +): Promise { + await removeLabelsFromAllDraftsForUser(userId, [label]); +} + // ----------------------------------------------------------------------------- // Delete // ----------------------------------------------------------------------------- diff --git a/lib/repositories/livestreams.ts b/lib/repositories/livestreams.ts index 2e715d3a..2bbf445f 100644 --- a/lib/repositories/livestreams.ts +++ b/lib/repositories/livestreams.ts @@ -24,6 +24,11 @@ import { import { mergeLivestreamPlatformsPatch } from '@/lib/livestream-upload-metadata'; import { connectToDatabase } from '@/lib/mongodb'; import { LivestreamModel, type LivestreamDocument } from '@/lib/models/Livestream'; +import { + buildStreamedLivestreamsMongoFilter, + buildYoutubeImportLivestreamsMongoFilter, +} from '@/lib/livestreams/livestream-list-filters'; +import { livestreamQueryFieldsFromStoredDocument } from '@/lib/livestreams/livestream-query-fields'; /** Default visibility for new livestreams when omitted at creation. */ export const DEFAULT_LIVESTREAM_VISIBILITY: PlatformUploadVisibility = 'public'; @@ -303,6 +308,58 @@ function isPendingFacebookDeferredArm(livestream: Livestream): boolean { return livestream.autoPromoteToMainKey === true || livestream.autoPromoteToMainKeyMinutes != null; } +/** Maximum legacy livestream rows to backfill per list request. */ +const LIVESTREAM_QUERY_FIELD_BACKFILL_BATCH_SIZE = 50; + +/** + * Backfills indexed query fields for legacy rows missing top-level columns. + * Runs automatically during list requests so operators do not run migrations manually. + * @param userId - Owner user id. + */ +async function backfillLivestreamQueryFieldsForUser(userId: string): Promise { + await connectToDatabase(); + const docs = await LivestreamModel.find({ + userId, + $or: [{ status: { $exists: false } }, { hasYoutubeTarget: { $exists: false } }], + }) + .limit(LIVESTREAM_QUERY_FIELD_BACKFILL_BATCH_SIZE) + .lean(); + + if (docs.length === 0) { + return; + } + + for (const doc of docs) { + const parsed = parseStoredLivestreamDocument(doc.document, String(doc._id)); + await LivestreamModel.updateOne( + { _id: doc._id }, + livestreamQueryFieldsFromStoredDocument(parsed) + ); + } +} + +async function queryLivestreamsPage( + filter: Record, + options: { limit: number; offset: number } +): Promise<{ total: number; livestreams: Livestream[] }> { + await connectToDatabase(); + const total = await LivestreamModel.countDocuments(filter); + if (options.limit <= 0) { + return { total, livestreams: [] }; + } + + const docs = await LivestreamModel.find(filter) + .sort({ updatedAt: -1 }) + .skip(options.offset) + .limit(options.limit) + .lean(); + + return { + total, + livestreams: docs.map(mongoDocToLivestream), + }; +} + function compareScheduledStartAsc(a: Livestream, b: Livestream): number { const aMs = Date.parse(a.scheduledStartTime ?? ''); const bMs = Date.parse(b.scheduledStartTime ?? ''); @@ -355,7 +412,7 @@ export async function createLivestream( userId: string, fields: CreateLivestreamFields ): Promise { - const documentJson = stringifyLivestreamDocumentForStorage({ + const documentPayload: StoredLivestreamDocument = { status: 'draft', title: fields.title, description: fields.description, @@ -369,7 +426,8 @@ export async function createLivestream( : {}), ...(fields.thumbnailR2Key ? { thumbnailR2Key: fields.thumbnailR2Key } : {}), ...(fields.thumbnailContentType ? { thumbnailContentType: fields.thumbnailContentType } : {}), - }); + }; + const documentJson = stringifyLivestreamDocumentForStorage(documentPayload); assertLivestreamDocumentJsonWithinLimit(documentJson); await connectToDatabase(); @@ -377,6 +435,7 @@ export async function createLivestream( _id: randomUUID(), userId, document: documentJson, + ...livestreamQueryFieldsFromStoredDocument(documentPayload), }); return mongoDocToLivestream(created.toObject()); } @@ -410,6 +469,90 @@ export async function listLivestreamsByUser(userId: string): Promise { + const { total } = await getStreamedLivestreamsPage(userId, { limit: 0, offset: 0 }); + return total; +} + +/** + * Returns a paginated slice of streamed livestreams and the filtered total. + * @param userId - Owner user id. + * @param options - Pagination options. + * @param options.limit - Maximum rows to return. + * @param options.offset - Number of rows to skip. + * @returns Page slice and total streamed count from an indexed MongoDB query. + */ +export async function getStreamedLivestreamsPage( + userId: string, + options: { limit: number; offset: number } +): Promise<{ total: number; livestreams: Livestream[] }> { + await backfillLivestreamQueryFieldsForUser(userId); + return queryLivestreamsPage(buildStreamedLivestreamsMongoFilter(userId), options); +} + +/** + * Lists a paginated page of streamed livestreams for a user, most recently updated first. + * @param userId - Owner user id. + * @param options - Pagination options. + * @param options.limit - Maximum rows to return. + * @param options.offset - Number of rows to skip. + * @returns Streamed livestream rows for the requested page. + */ +export async function listStreamedLivestreamsByUserPage( + userId: string, + options: { limit: number; offset: number } +): Promise { + const { livestreams } = await getStreamedLivestreamsPage(userId, options); + return livestreams; +} + +/** + * Counts past YouTube livestreams that can be used as import sources. + * @param userId - Owner user id. + * @returns Total importable YouTube livestream count. + */ +export async function countYoutubeImportLivestreamsByUser(userId: string): Promise { + const { total } = await getYoutubeImportLivestreamsPage(userId, { limit: 0, offset: 0 }); + return total; +} + +/** + * Returns a paginated slice of YouTube-importable livestreams and the filtered total. + * @param userId - Owner user id. + * @param options - Pagination options. + * @param options.limit - Maximum rows to return. + * @param options.offset - Number of rows to skip. + * @returns Page slice and total importable count from an indexed MongoDB query. + */ +export async function getYoutubeImportLivestreamsPage( + userId: string, + options: { limit: number; offset: number } +): Promise<{ total: number; livestreams: Livestream[] }> { + await backfillLivestreamQueryFieldsForUser(userId); + return queryLivestreamsPage(buildYoutubeImportLivestreamsMongoFilter(userId), options); +} + +/** + * Lists a paginated page of YouTube-importable livestreams for a user. + * @param userId - Owner user id. + * @param options - Pagination options. + * @param options.limit - Maximum rows to return. + * @param options.offset - Number of rows to skip. + * @returns Importable YouTube livestream rows for the requested page. + */ +export async function listYoutubeImportLivestreamsByUserPage( + userId: string, + options: { limit: number; offset: number } +): Promise { + const { livestreams } = await getYoutubeImportLivestreamsPage(userId, options); + return livestreams; +} + /** * Returns armed YouTube livestreams for a user (holding a key slot, scheduled or live). * @param userId - Owner user id. @@ -700,13 +843,15 @@ export async function updateLivestream( ), }; - const documentJson = stringifyLivestreamDocumentForStorage(storedDocumentFromLivestream(next)); + const stored = storedDocumentFromLivestream(next); + const documentJson = stringifyLivestreamDocumentForStorage(stored); assertLivestreamDocumentJsonWithinLimit(documentJson); + const queryFields = livestreamQueryFieldsFromStoredDocument(stored); await connectToDatabase(); const updated = await LivestreamModel.findByIdAndUpdate( id, - { document: documentJson }, + { document: documentJson, ...queryFields }, { returnDocument: 'after', runValidators: true } ).lean(); if (!updated) return null; diff --git a/lib/repositories/platform-uploads.ts b/lib/repositories/platform-uploads.ts index f93d6016..c7d78a1f 100644 --- a/lib/repositories/platform-uploads.ts +++ b/lib/repositories/platform-uploads.ts @@ -329,7 +329,7 @@ export async function listStaleSermonAudioUnpublishedPlatformUploads( /** * Update a platform upload's status and optional result fields. * Use platformVideoId and platformUrl when status is terminal success - * (`completed`, `unpublished`, or `published`); use errorMessage when status is `failed`. + * (`completed`, `unpublished`, `published`, or `scheduled`); use errorMessage when status is `failed`. * Returns the updated record or null if not found. */ export async function updatePlatformUploadStatus( @@ -337,7 +337,8 @@ export async function updatePlatformUploadStatus( status: PlatformUploadStatus, platformVideoId?: string, platformUrl?: string, - errorMessage?: string | null + errorMessage?: string | null, + scheduledAt?: string | null ): Promise { await connectToDatabase(); @@ -351,6 +352,9 @@ export async function updatePlatformUploadStatus( if (errorMessage !== undefined) { data.errorMessage = errorMessage ?? ''; } + if (scheduledAt !== undefined) { + data.scheduledAt = scheduledAt ?? ''; + } const updated = await PlatformUploadModel.findByIdAndUpdate(id, data, { returnDocument: 'after', diff --git a/lib/repositories/users.ts b/lib/repositories/users.ts index 6324b243..197d587b 100644 --- a/lib/repositories/users.ts +++ b/lib/repositories/users.ts @@ -17,6 +17,12 @@ import type { import { userSupportsPasswordReset } from '@/lib/auth/password'; import { normalizeStoredPlatformDefaults } from '@/lib/auth/platform-defaults-validation'; import { normalizeStoredUserPreferences } from '@/lib/user-preferences'; +import { + mergeDraftLabelLibraryEntries, + normalizeDraftLabelLibrary, + upsertDraftLabelNamesInLibrary, +} from '@/lib/draft-labels'; +import type { DraftLabelDefinition } from '@/types'; import { revokeGoogleOAuthTokens } from '@/lib/auth/google-oauth'; import { decryptToken, encryptToken } from '@/lib/crypto/token-encryption'; import { connectToDatabase } from '@/lib/mongodb'; @@ -24,6 +30,11 @@ import { UserProfileModel, type UserProfileDocument } from '@/lib/models/UserPro export type { UserAuthProvider } from '@/types'; +/** Fields loaded for draft label library reads (avoids pulling unrelated profile data). */ +const DRAFT_LABEL_LIBRARY_SELECT = { draftLabelLibrary: 1 } as const; + +type DraftLabelLibraryLean = Pick; + /** Fields returned for admin user list rows (excludes secrets such as `googleRefreshToken`). */ const LIST_USER_BASE_SELECT = 'userId email name hasCompletedOnboarding role authProvider createdAt updatedAt'; @@ -69,6 +80,7 @@ export interface SessionUser extends User { function mongoDocToUser(doc: UserProfileDocument): User { const platformDefaults = normalizeStoredPlatformDefaults(doc.platformDefaults); const preferences = normalizeStoredUserPreferences(doc.preferences); + const draftLabelLibrary = normalizeDraftLabelLibrary(doc.draftLabelLibrary); return { userId: String(doc.userId), @@ -81,6 +93,7 @@ function mongoDocToUser(doc: UserProfileDocument): User { $updatedAt: new Date(doc.updatedAt).toISOString(), ...(platformDefaults !== undefined ? { platformDefaults } : {}), ...(preferences !== undefined ? { preferences } : {}), + ...(draftLabelLibrary.length > 0 ? { draftLabelLibrary } : {}), }; } @@ -346,6 +359,8 @@ export interface UpdateUserData { platformDefaultsYoutube?: Partial; /** Partial merge into stored `preferences`. */ preferences?: Partial; + /** Replaces the saved draft label library when provided. */ + draftLabelLibrary?: DraftLabelDefinition[]; } /** @@ -406,6 +421,10 @@ export async function updateUser(userId: string, data: UpdateUserData): Promise< } } + if (data.draftLabelLibrary !== undefined) { + payload.draftLabelLibrary = normalizeDraftLabelLibrary(data.draftLabelLibrary); + } + const updated = await UserProfileModel.findByIdAndUpdate(userId, payload, { returnDocument: 'after', runValidators: true, @@ -678,6 +697,101 @@ export async function revokeStoredGoogleAuthForUser(userId: string): Promise { + await connectToDatabase(); + const doc = await UserProfileModel.findById(userId) + .select(DRAFT_LABEL_LIBRARY_SELECT) + .lean(); + if (!doc) { + const notFound = Object.assign(new Error('User profile not found'), { code: 404 }); + throw notFound; + } + return normalizeDraftLabelLibrary(doc.draftLabelLibrary); +} + +/** + * Merges label names into the user's saved draft label library without duplicates. + * @param userId - Auth user id. + * @param labels - Label names to upsert. + * @returns Updated library after merge. + */ +export async function upsertDraftLabelsInLibrary( + userId: string, + labels: readonly string[] +): Promise { + await connectToDatabase(); + const doc = await UserProfileModel.findById(userId) + .select(DRAFT_LABEL_LIBRARY_SELECT) + .lean(); + if (!doc) { + const notFound = Object.assign(new Error('User profile not found'), { code: 404 }); + throw notFound; + } + + const merged = upsertDraftLabelNamesInLibrary( + normalizeDraftLabelLibrary(doc.draftLabelLibrary), + labels + ); + await UserProfileModel.findByIdAndUpdate( + userId, + { draftLabelLibrary: merged }, + { runValidators: true } + ); + return merged; +} + +/** + * Merges label definitions (including color updates) into the user's saved library. + * @param userId - Auth user id. + * @param entries - Label definitions to merge by name. + * @returns Updated library after merge. + */ +export async function mergeDraftLabelsInLibrary( + userId: string, + entries: readonly DraftLabelDefinition[] +): Promise { + await connectToDatabase(); + const doc = await UserProfileModel.findById(userId) + .select(DRAFT_LABEL_LIBRARY_SELECT) + .lean(); + if (!doc) { + const notFound = Object.assign(new Error('User profile not found'), { code: 404 }); + throw notFound; + } + + const merged = mergeDraftLabelLibraryEntries( + normalizeDraftLabelLibrary(doc.draftLabelLibrary), + entries + ); + await UserProfileModel.findByIdAndUpdate( + userId, + { draftLabelLibrary: merged }, + { runValidators: true } + ); + return merged; +} + +/** + * Replaces the user's saved draft label library. + * @param userId - Auth user id. + * @param labels - Full library to persist. + * @returns Updated library. + */ +export async function setDraftLabelLibrary( + userId: string, + labels: readonly DraftLabelDefinition[] +): Promise { + const normalized = normalizeDraftLabelLibrary(labels); + await updateUser(userId, { draftLabelLibrary: normalized }); + return normalized; +} + /** * Deletes a user profile by id. * @param userId - Auth user id to delete. diff --git a/lib/repositories/youtube-import-jobs.ts b/lib/repositories/youtube-import-jobs.ts new file mode 100644 index 00000000..5df5f1f8 --- /dev/null +++ b/lib/repositories/youtube-import-jobs.ts @@ -0,0 +1,332 @@ +// ============================================================================= +// YOUTUBE IMPORT JOB REPOSITORY +// ============================================================================= +// All YouTube import/trim job data access goes through this module. API routes +// and Server Components should call these functions only. +// +// Uses Mongoose for the youtube_import_jobs collection. +// ============================================================================= + +import { randomUUID } from 'node:crypto'; +import type { YoutubeImportJob, YoutubeImportJobStatus } from '@/types'; +import { connectToDatabase } from '@/lib/mongodb'; +import { + YoutubeImportJobModel, + type YoutubeImportJobDocument, +} from '@/lib/models/YoutubeImportJob'; +import { getUploadJobById } from '@/lib/repositories/upload-jobs'; + +const ACTIVE_YOUTUBE_IMPORT_JOB_STATUSES: readonly YoutubeImportJobStatus[] = [ + 'pending', + 'downloading', + 'trimming', + 'uploading', +]; + +/** + * Thrown when a user already has an active YouTube import job (Mongo duplicate key). + */ +export class YoutubeImportJobAlreadyActiveError extends Error { + /** User id that already has an in-progress import job. */ + readonly userId: string; + + /** + * @param userId - User who already has an active import job. + */ + constructor(userId: string) { + super(`User already has an active YouTube import job`); + this.name = 'YoutubeImportJobAlreadyActiveError'; + this.userId = userId; + } +} + +/** Map a MongoDB document to the shared YoutubeImportJob type. */ +function rowToYoutubeImportJob(doc: YoutubeImportJobDocument): YoutubeImportJob { + return { + id: String(doc._id), + userId: String(doc.userId), + draftId: String(doc.draftId), + sourceUrl: String(doc.sourceUrl), + youtubeVideoId: String(doc.youtubeVideoId), + livestreamId: + doc.livestreamId != null && doc.livestreamId !== '' ? String(doc.livestreamId) : null, + startSeconds: doc.startSeconds, + endSeconds: doc.endSeconds, + status: String(doc.status) as YoutubeImportJobStatus, + progressPercent: doc.progressPercent, + errorMessage: + doc.errorMessage != null && doc.errorMessage !== '' ? String(doc.errorMessage) : null, + r2Key: doc.r2Key != null && doc.r2Key !== '' ? String(doc.r2Key) : null, + uploadJobId: doc.uploadJobId != null && doc.uploadJobId !== '' ? String(doc.uploadJobId) : null, + distributeQueued: Boolean(doc.distributeQueued), + $createdAt: new Date(doc.createdAt).toISOString(), + $updatedAt: new Date(doc.updatedAt).toISOString(), + }; +} + +/** + * Input for creating a new YouTube import job. + */ +export interface CreateYoutubeImportJobInput { + userId: string; + draftId: string; + sourceUrl: string; + youtubeVideoId: string; + /** Past livestream id when picked from history; omit or pass empty for pasted links. */ + livestreamId?: string; + startSeconds: number; + endSeconds: number; +} + +/** + * Partial fields accepted by {@link updateYoutubeImportJobStatus}. + */ +export interface UpdateYoutubeImportJobStatusPatch { + status?: YoutubeImportJobStatus; + progressPercent?: number; + errorMessage?: string | null; + r2Key?: string | null; + uploadJobId?: string | null; + distributeQueued?: boolean; +} + +/** + * Create a new YouTube import job. Status defaults to `pending`. + * @param input - Job creation fields. + * @returns The created import job. + * @throws {YoutubeImportJobAlreadyActiveError} When the user already has an active job. + */ +export async function createYoutubeImportJob( + input: CreateYoutubeImportJobInput +): Promise { + await connectToDatabase(); + + try { + const created = await YoutubeImportJobModel.create({ + _id: randomUUID(), + userId: input.userId, + draftId: input.draftId, + sourceUrl: input.sourceUrl, + youtubeVideoId: input.youtubeVideoId, + livestreamId: input.livestreamId ?? '', + startSeconds: input.startSeconds, + endSeconds: input.endSeconds, + status: 'pending', + progressPercent: 0, + errorMessage: '', + r2Key: '', + uploadJobId: '', + distributeQueued: false, + }); + + return rowToYoutubeImportJob(created.toObject()); + } catch (error) { + const duplicateKeyError = + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: number }).code === 11000; + + if (duplicateKeyError) { + throw new YoutubeImportJobAlreadyActiveError(input.userId); + } + + throw error; + } +} + +/** + * Fetch a YouTube import job by id. + * @param id - Import job id. + * @returns The job, or null when not found. + */ +export async function getYoutubeImportJobById(id: string): Promise { + await connectToDatabase(); + const doc = await YoutubeImportJobModel.findById(id).lean(); + if (!doc) return null; + return rowToYoutubeImportJob(doc); +} + +/** + * Return the user's current in-progress import job, if any. + * @param userId - Owner user id. + * @returns The active job, or null when none is in progress. + */ +export async function getActiveYoutubeImportJobForUser( + userId: string +): Promise { + await connectToDatabase(); + const doc = await YoutubeImportJobModel.findOne({ + userId, + status: { $in: [...ACTIVE_YOUTUBE_IMPORT_JOB_STATUSES] }, + }) + .sort({ createdAt: -1 }) + .lean(); + + if (!doc) return null; + return rowToYoutubeImportJob(doc); +} + +/** + * Returns the import job a draft editor should surface: an in-flight import, or the + * latest completed import whose staged upload has not been distributed yet. + * @param draftId - Draft id. + * @returns Relevant import job, or null when the draft has no active/staged import. + */ +export async function getYoutubeImportJobForDraft( + draftId: string +): Promise { + await connectToDatabase(); + + const activeDoc = await YoutubeImportJobModel.findOne({ + draftId, + status: { $in: [...ACTIVE_YOUTUBE_IMPORT_JOB_STATUSES] }, + }) + .sort({ createdAt: -1 }) + .lean(); + + if (activeDoc) { + return rowToYoutubeImportJob(activeDoc); + } + + const completedDoc = await YoutubeImportJobModel.findOne({ + draftId, + status: 'completed', + uploadJobId: { $ne: '' }, + }) + .sort({ createdAt: -1 }) + .lean(); + + if (!completedDoc) { + return null; + } + + const importJob = rowToYoutubeImportJob(completedDoc); + if (!importJob.uploadJobId) { + return null; + } + + const uploadJob = await getUploadJobById(importJob.uploadJobId); + if (!uploadJob || (uploadJob.status !== 'pending' && uploadJob.status !== 'uploading')) { + return null; + } + + return importJob; +} + +/** + * Returns the most recent failed import for a draft when nothing active/staged remains. + * @param draftId - Draft id. + * @returns Failed import job, or null. + */ +async function getFailedYoutubeImportJobForDraft( + draftId: string +): Promise { + const failedDoc = await YoutubeImportJobModel.findOne({ + draftId, + status: 'failed', + }) + .sort({ createdAt: -1 }) + .lean(); + + if (!failedDoc) { + return null; + } + + return rowToYoutubeImportJob(failedDoc); +} + +/** + * Returns the import job shown in the draft editor, including failed jobs that can be retried. + * @param draftId - Draft id. + * @returns Relevant import job, or null when the draft has no import state to show. + */ +export async function getYoutubeImportJobForDraftEditor( + draftId: string +): Promise { + const blocking = await getYoutubeImportJobForDraft(draftId); + if (blocking) { + return blocking; + } + + return getFailedYoutubeImportJobForDraft(draftId); +} + +/** + * Apply a partial status/progress patch to an import job. + * @param id - Import job id. + * @param patch - Fields to update; omitted keys are left unchanged. + */ +export async function updateYoutubeImportJobStatus( + id: string, + patch: UpdateYoutubeImportJobStatusPatch +): Promise { + await connectToDatabase(); + + const data: Partial = {}; + if (patch.status !== undefined) { + data.status = patch.status; + } + if (patch.progressPercent !== undefined) { + data.progressPercent = patch.progressPercent; + } + if (patch.errorMessage !== undefined) { + data.errorMessage = patch.errorMessage ?? ''; + } + if (patch.r2Key !== undefined) { + data.r2Key = patch.r2Key ?? ''; + } + if (patch.uploadJobId !== undefined) { + data.uploadJobId = patch.uploadJobId ?? ''; + } + if (patch.distributeQueued !== undefined) { + data.distributeQueued = patch.distributeQueued; + } + + if (Object.keys(data).length === 0) { + return; + } + + await YoutubeImportJobModel.findByIdAndUpdate(id, data, { + runValidators: true, + }); +} + +/** + * Atomically moves a pending import job to `downloading` so only one worker can run it. + * @param id - Import job id. + * @param userId - When set, the row must belong to this user. + * @returns Claimed job row, or null when not pending / not found / wrong owner. + */ +export async function claimPendingYoutubeImportJob( + id: string, + userId?: string +): Promise { + await connectToDatabase(); + + const filter: { _id: string; status: 'pending'; userId?: string } = { + _id: id, + status: 'pending', + }; + if (userId) { + filter.userId = userId; + } + + const doc = await YoutubeImportJobModel.findOneAndUpdate( + filter, + { status: 'downloading', progressPercent: 0 }, + { returnDocument: 'after', runValidators: true } + ).lean(); + + if (!doc) return null; + return rowToYoutubeImportJob(doc); +} + +/** + * Delete a YouTube import job row (e.g. after the client consumes a terminal state). + * @param id - Import job id. + */ +export async function deleteYoutubeImportJob(id: string): Promise { + await connectToDatabase(); + await YoutubeImportJobModel.deleteOne({ _id: id }); +} diff --git a/lib/schedule-bounds.ts b/lib/schedule-bounds.ts index 4a37eb84..5a91def6 100644 --- a/lib/schedule-bounds.ts +++ b/lib/schedule-bounds.ts @@ -9,12 +9,16 @@ export const YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS = 12; /** Maximum days in the future Facebook allows scheduling. */ export const FACEBOOK_MAX_SCHEDULE_LEAD_DAYS = 75; +/** Maximum days in the future SermonAudio allows scheduling. */ +export const SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS = 60; + /** * Platform whose schedule window applies. * @property youtube - Up to {@link YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS} months ahead. * @property facebook - Up to {@link FACEBOOK_MAX_SCHEDULE_LEAD_DAYS} days ahead. + * @property sermon_audio - Up to {@link SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS} days ahead. */ -export type SchedulePlatform = 'youtube' | 'facebook'; +export type SchedulePlatform = 'youtube' | 'facebook' | 'sermon_audio'; /** * Earliest selectable calendar day for schedulers (start of today, local time). @@ -32,10 +36,13 @@ export function getScheduleMinDate(now: Date = new Date()): Date { * @returns Start of the last allowed local day. */ export function getScheduleMaxDate(platform: SchedulePlatform, now: Date = new Date()): Date { - if (platform === 'youtube') { - return startOfDay(addMonths(now, YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS)); + if (platform === 'facebook') { + return startOfDay(addDays(now, FACEBOOK_MAX_SCHEDULE_LEAD_DAYS)); + } + if (platform === 'sermon_audio') { + return startOfDay(addDays(now, SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS)); } - return startOfDay(addDays(now, FACEBOOK_MAX_SCHEDULE_LEAD_DAYS)); + return startOfDay(addMonths(now, YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS)); } /** @@ -57,9 +64,13 @@ export function getScheduleMaxDateTimeMs( * @returns Short phrase for UI help text (e.g. `12 months`, `75 days`). */ export function getScheduleMaxLeadLabel(platform: SchedulePlatform): string { - return platform === 'youtube' - ? `${YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS} months` - : `${FACEBOOK_MAX_SCHEDULE_LEAD_DAYS} days`; + if (platform === 'facebook') { + return `${FACEBOOK_MAX_SCHEDULE_LEAD_DAYS} days`; + } + if (platform === 'sermon_audio') { + return `${SERMONAUDIO_MAX_SCHEDULE_LEAD_DAYS} days`; + } + return `${YOUTUBE_MAX_SCHEDULE_LEAD_MONTHS} months`; } function parseUtcIsoScheduleTimestamp(iso: string): number | null { @@ -130,6 +141,32 @@ export function validateFacebookScheduledPublishTime( ); } +/** + * Validates a Unix timestamp (seconds) for SermonAudio scheduled publish. + * Past times are allowed (SermonAudio publishes immediately). Only the upper bound is enforced. + * @param publishTimestamp - Unix timestamp in seconds. + * @param nowMs - Reference time in milliseconds. + * @returns Error message when invalid, or undefined when valid. + */ +export function validateSermonAudioScheduledPublishTime( + publishTimestamp: number, + nowMs: number = Date.now() +): string | undefined { + if (!Number.isFinite(publishTimestamp)) { + return 'Scheduled publish time must be a valid Unix timestamp.'; + } + + const parsedMs = Math.floor(publishTimestamp) * 1000; + const maxMs = getScheduleMaxDateTimeMs('sermon_audio', new Date(nowMs)); + const maxLabel = getScheduleMaxLeadLabel('sermon_audio'); + + if (parsedMs > maxMs) { + return `Scheduled time must be within ${maxLabel} from now.`; + } + + return undefined; +} + /** @deprecated Use {@link SCHEDULE_MIN_LEAD_MINUTES} via seconds in callers. */ export const FACEBOOK_MIN_SCHEDULE_LEAD_SECONDS = SCHEDULE_MIN_LEAD_MINUTES * 60; diff --git a/lib/schedule-date-time.ts b/lib/schedule-date-time.ts index 3ac30faa..b16e3583 100644 --- a/lib/schedule-date-time.ts +++ b/lib/schedule-date-time.ts @@ -182,6 +182,102 @@ export function parseScheduleHourInput(value: string, use12Hour: boolean): numbe return Math.min(max, Math.max(min, num)); } +/** + * Options for {@link parseScheduleTimeInput}. + * @property hour12 - When true, accepts 12-hour text with optional AM/PM. + * @property fallbackPeriod - Meridiem used when 12-hour text omits AM/PM (defaults to `AM`). + */ +export interface ParseScheduleTimeInputOptions { + hour12?: boolean; + fallbackPeriod?: 'AM' | 'PM'; +} + +/** + * Parses free-form schedule time text into 24-hour wall-clock parts. + * Accepts values such as `2:00 pm`, `2pm`, `14:00`, and `9:05`. + * @param value - Raw time text from a schedule field. + * @param options - Parsing and display preferences. + * @returns Parsed parts, or null when the text cannot be interpreted. + */ +export function parseScheduleTimeInput( + value: string, + options?: ParseScheduleTimeInputOptions +): ScheduleTimeParts | null { + const trimmed = value.trim().replace(/\s+/g, ' '); + if (trimmed === '') { + return null; + } + + const meridiemMatch = /\b(a\.?\s*m\.?|p\.?\s*m\.?)\s*$/i.exec(trimmed); + let period: 'AM' | 'PM' | null = null; + let withoutMeridiem = trimmed; + + if (meridiemMatch) { + const normalizedMeridiem = meridiemMatch[1].toLowerCase().replace(/\./g, '').replace(/\s/g, ''); + period = normalizedMeridiem.startsWith('p') ? 'PM' : 'AM'; + withoutMeridiem = trimmed.slice(0, meridiemMatch.index).trim(); + } + + const gluedMeridiemMatch = /^(\d{1,2})(?::(\d{2}))?\s*(a\.?\s*m\.?|p\.?\s*m\.?)$/i.exec( + withoutMeridiem + ); + if (gluedMeridiemMatch) { + const normalizedMeridiem = gluedMeridiemMatch[3] + .toLowerCase() + .replace(/\./g, '') + .replace(/\s/g, ''); + period = normalizedMeridiem.startsWith('p') ? 'PM' : 'AM'; + withoutMeridiem = gluedMeridiemMatch[2] + ? `${gluedMeridiemMatch[1]}:${gluedMeridiemMatch[2]}` + : gluedMeridiemMatch[1]; + } + + const timeMatch = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(withoutMeridiem); + if (timeMatch) { + const hour = Number(timeMatch[1]); + const minute = Number(timeMatch[2]); + if (minute < 0 || minute > 59) { + return null; + } + + if (period) { + if (hour < 1 || hour > 12) { + return null; + } + return { hour: to24HourFrom12(hour, period), minute }; + } + + if (options?.hour12 && hour >= 1 && hour <= 12) { + const fallbackPeriod = options.fallbackPeriod ?? 'AM'; + return { hour: to24HourFrom12(hour, fallbackPeriod), minute }; + } + + if (hour >= 0 && hour <= 23) { + return { hour, minute }; + } + + return null; + } + + const hourOnlyMatch = /^(\d{1,2})$/.exec(withoutMeridiem); + if (hourOnlyMatch) { + const hour = Number(hourOnlyMatch[1]); + + if (period) { + if (hour < 1 || hour > 12) { + return null; + } + return { hour: to24HourFrom12(hour, period), minute: 0 }; + } + + if (!options?.hour12 && hour >= 0 && hour <= 23) { + return { hour, minute: 0 }; + } + } + + return null; +} + /** * Parses and clamps a typed minute for schedule picker columns. * @param value - Raw digits from the minute input. diff --git a/lib/uploads/status.ts b/lib/uploads/status.ts index 977a7723..618b6a3e 100644 --- a/lib/uploads/status.ts +++ b/lib/uploads/status.ts @@ -36,14 +36,33 @@ export type PlatformStatusItem = { sermonAudioAutoPublishOnProcessed?: boolean; }; +/** + * Resolves the terminal SermonAudio platform upload status after a successful publish PATCH. + * Future `publishTimestamp` values become `scheduled`; past or immediate times become `published`. + * @param publishTimestamp - Unix timestamp (seconds) sent to SermonAudio, if any. + * @param nowSec - Reference time in seconds. + * @returns Terminal upload status for the platform row. + */ +export function resolveSermonAudioTerminalUploadStatus( + publishTimestamp: number | undefined, + nowSec: number = Math.floor(Date.now() / 1000) +): 'published' | 'scheduled' { + return publishTimestamp !== undefined && publishTimestamp > nowSec ? 'scheduled' : 'published'; +} + /** * True when bytes were successfully sent to the platform (terminal upload outcome, excluding failure). - * SermonAudio stops at `unpublished` or `published`; other platforms use `completed`. + * SermonAudio stops at `unpublished`, `published`, or `scheduled`; other platforms use `completed`. * @param status - Platform upload row status. * @returns Whether distribution finished without error. */ export function isPlatformUploadDistributionComplete(status: PlatformUploadStatus): boolean { - return status === 'completed' || status === 'unpublished' || status === 'published'; + return ( + status === 'completed' || + status === 'unpublished' || + status === 'published' || + status === 'scheduled' + ); } /** diff --git a/lib/youtube-import/discard-draft-import.ts b/lib/youtube-import/discard-draft-import.ts new file mode 100644 index 00000000..d6c11d45 --- /dev/null +++ b/lib/youtube-import/discard-draft-import.ts @@ -0,0 +1,117 @@ +import { deleteObject, R2ObjectNotFoundError } from '@/lib/r2'; +import { getDraftById } from '@/lib/repositories/drafts'; +import { + getYoutubeImportJobById, + getYoutubeImportJobForDraftEditor, + updateYoutubeImportJobStatus, +} from '@/lib/repositories/youtube-import-jobs'; +import { getUploadJobById, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; +import { isActiveYoutubeImportStatus } from '@/lib/youtube-import/import-job-ui'; +import type { YoutubeImportJob } from '@/types'; + +/** + * Cancels a staged upload job created by a YouTube import and deletes its R2 object. + * @param uploadJobId - Linked upload job id. + * @param userId - Owning user id. + */ +async function discardStagedYoutubeImportUploadJob( + uploadJobId: string, + userId: string +): Promise { + const uploadJob = await getUploadJobById(uploadJobId); + if (!uploadJob || uploadJob.userId !== userId) { + return; + } + + if (uploadJob.status !== 'pending' && uploadJob.status !== 'uploading') { + return; + } + + await updateUploadJobStatus(uploadJobId, 'cancelled', null); + + if (uploadJob.r2Key) { + await deleteObject(uploadJob.r2Key).catch((error) => { + if (error instanceof R2ObjectNotFoundError) { + return; + } + console.error( + `[discardStagedYoutubeImportUploadJob] Failed to delete R2 object for upload job ${uploadJobId}:`, + error + ); + }); + } +} + +/** + * Clears a single YouTube import job so the draft can start over. + * @param job - Import job to discard. + * @param userId - Owning user id. + */ +export async function discardYoutubeImportJob( + job: YoutubeImportJob, + userId: string +): Promise { + if (job.userId !== userId) { + throw new Error('You do not have access to this import job'); + } + + if (job.status === 'cancelled') { + return; + } + + if (isActiveYoutubeImportStatus(job.status)) { + await updateYoutubeImportJobStatus(job.id, { status: 'cancelled', errorMessage: null }); + return; + } + + if (job.status === 'completed' && job.uploadJobId) { + await discardStagedYoutubeImportUploadJob(job.uploadJobId, userId); + } + + await updateYoutubeImportJobStatus(job.id, { + status: 'cancelled', + errorMessage: null, + distributeQueued: false, + }); +} + +/** + * Discards any import state on a draft that would block starting over. + * @param draftId - Draft id. + * @param userId - Owning user id. + */ +export async function discardBlockingDraftYoutubeImport( + draftId: string, + userId: string +): Promise { + const draft = await getDraftById(draftId); + if (!draft) { + throw new Error('Draft not found'); + } + if (draft.userId !== userId) { + throw new Error('You do not have access to this draft'); + } + + const blocking = await getYoutubeImportJobForDraftEditor(draftId); + if (!blocking) { + return; + } + + await discardYoutubeImportJob(blocking, userId); +} + +/** + * Discards a YouTube import job by id after verifying ownership. + * @param jobId - Import job id. + * @param userId - Owning user id. + * @returns The discarded job id. + */ +export async function discardYoutubeImportJobById(jobId: string, userId: string): Promise { + const job = await getYoutubeImportJobById(jobId); + if (!job) { + throw new Error('YouTube import job not found'); + } + + await discardYoutubeImportJob(job, userId); + return jobId; +} diff --git a/lib/youtube-import/execute-import-job.ts b/lib/youtube-import/execute-import-job.ts new file mode 100644 index 00000000..78d3b4d0 --- /dev/null +++ b/lib/youtube-import/execute-import-job.ts @@ -0,0 +1,38 @@ +import { + claimPendingYoutubeImportJob, + getYoutubeImportJobById, +} from '@/lib/repositories/youtube-import-jobs'; +import { runYoutubeImportJob } from '@/lib/youtube-import/run-import-job'; +import type { YoutubeImportJobStatus } from '@/types'; + +/** + * Result of attempting to claim and run a YouTube import worker. + */ +export type YoutubeImportJobExecutionResult = + | { outcome: 'ran' } + | { outcome: 'already_running'; status: YoutubeImportJobStatus } + | { outcome: 'not_found' }; + +/** + * Atomically claims a pending import job and runs the download/trim/stage pipeline. + * @param jobId - Import job id. + * @param userId - When set, the pending row must belong to this user. + * @returns Whether the worker ran, was already active, or the job was missing. + */ +export async function executeYoutubeImportJobWorker( + jobId: string, + userId?: string +): Promise { + const claimed = await claimPendingYoutubeImportJob(jobId, userId); + if (!claimed) { + const current = await getYoutubeImportJobById(jobId); + if (!current) { + return { outcome: 'not_found' }; + } + return { outcome: 'already_running', status: current.status }; + } + + console.info(`[executeYoutubeImportJobWorker] Starting import worker for ${jobId}`); + await runYoutubeImportJob(jobId); + return { outcome: 'ran' }; +} diff --git a/lib/youtube-import/import-job-fs.ts b/lib/youtube-import/import-job-fs.ts new file mode 100644 index 00000000..0ca1b6ed --- /dev/null +++ b/lib/youtube-import/import-job-fs.ts @@ -0,0 +1 @@ +export { mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; diff --git a/lib/youtube-import/import-job-ui.ts b/lib/youtube-import/import-job-ui.ts new file mode 100644 index 00000000..c3531893 --- /dev/null +++ b/lib/youtube-import/import-job-ui.ts @@ -0,0 +1,41 @@ +import type { YoutubeImportJobStatus } from '@/types'; + +/** + * Returns whether a YouTube import job is still in progress. + * @param status - Import job status. + * @returns `true` when the worker may still be running. + */ +export function isActiveYoutubeImportStatus(status: YoutubeImportJobStatus): boolean { + return ( + status === 'pending' || + status === 'downloading' || + status === 'trimming' || + status === 'uploading' + ); +} + +/** + * Human-readable label for import progress UI. + * @param status - Import job status. + * @returns Short status text for the draft/import modals. + */ +export function formatYoutubeImportStatusLabel(status: YoutubeImportJobStatus): string { + switch (status) { + case 'pending': + return 'Queued'; + case 'downloading': + return 'Downloading'; + case 'trimming': + return 'Trimming'; + case 'uploading': + return 'Staging video'; + case 'completed': + return 'Ready to upload'; + case 'failed': + return 'Import failed'; + case 'cancelled': + return 'Cancelled'; + default: + return status; + } +} diff --git a/lib/youtube-import/preview-media-url.ts b/lib/youtube-import/preview-media-url.ts new file mode 100644 index 00000000..bc583c86 --- /dev/null +++ b/lib/youtube-import/preview-media-url.ts @@ -0,0 +1,126 @@ +import { + getDirectMediaUrl, + type YouTubeDirectMediaUrl, +} from '@/lib/youtube-import/probe-keyframes'; + +const PREVIEW_CACHE_REFRESH_BUFFER_MS = 30_000; +/** Upper bound on cached preview URLs; entries are short-lived CDN links. */ +const PREVIEW_CACHE_MAX_ENTRIES = 64; + +const previewMediaCache = new Map(); +let previewMediaCacheMaxEntriesForTests: number | null = null; + +function previewMediaCacheKey(userId: string, youtubeVideoId: string): string { + return `${userId}:${youtubeVideoId}`; +} + +function getPreviewMediaCacheMaxEntries(): number { + return previewMediaCacheMaxEntriesForTests ?? PREVIEW_CACHE_MAX_ENTRIES; +} + +/** + * Overrides the preview media cache size cap in unit tests. + * @param maxEntries - Maximum entries, or `null` to restore the default. + * @internal + */ +export function setPreviewMediaCacheMaxEntriesForTests(maxEntries: number | null): void { + previewMediaCacheMaxEntriesForTests = maxEntries; +} + +/** + * Clears cached preview media URLs (tests only). + */ +export function clearPreviewMediaCacheForTests(): void { + previewMediaCache.clear(); +} + +function evictExpiredPreviewMediaCacheEntries(now = Date.now()): void { + const refreshDeadline = now + PREVIEW_CACHE_REFRESH_BUFFER_MS; + for (const [key, value] of previewMediaCache) { + if (value.expiresAt <= refreshDeadline) { + previewMediaCache.delete(key); + } + } +} + +function storePreviewMediaCacheEntry(cacheKey: string, resolved: YouTubeDirectMediaUrl): void { + evictExpiredPreviewMediaCacheEntries(); + const maxEntries = getPreviewMediaCacheMaxEntries(); + while (previewMediaCache.size >= maxEntries) { + const oldestKey = previewMediaCache.keys().next().value; + if (oldestKey === undefined) { + break; + } + previewMediaCache.delete(oldestKey); + } + previewMediaCache.set(cacheKey, resolved); +} + +/** + * Returns true when a yt-dlp media URL is safe to proxy for preview playback. + * @param url - Direct media URL from yt-dlp metadata. + * @returns Whether the host is an allowed YouTube CDN origin. + */ +export function isAllowedPreviewUpstreamUrl(url: string): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'https:') { + return false; + } + + const hostname = parsed.hostname.toLowerCase(); + return hostname === 'googlevideo.com' || hostname.endsWith('.googlevideo.com'); + } catch { + return false; + } +} + +/** + * Builds the authenticated preview stream path for a YouTube video id. + * @param youtubeVideoId - 11-character YouTube video id. + * @returns Same-origin preview stream URL for HTML5 video elements. + */ +export function buildYoutubeImportPreviewStreamPath(youtubeVideoId: string): string { + return `/api/youtube-import/preview/stream?youtubeVideoId=${encodeURIComponent(youtubeVideoId)}`; +} + +/** + * Drops a cached preview media URL so the next request re-resolves via yt-dlp. + * @param userId - Authenticated user id. + * @param youtubeVideoId - YouTube video id. + */ +export function invalidatePreviewDirectMediaUrl(userId: string, youtubeVideoId: string): void { + previewMediaCache.delete(previewMediaCacheKey(userId, youtubeVideoId)); +} + +/** + * Resolves and caches a short-lived direct media URL for import preview playback. + * @param userId - Authenticated user id. + * @param youtubeVideoId - YouTube video id. + * @param options - Optional cache-bypass flag. + * @returns Direct media URL and approximate expiry timestamp. + */ +export async function resolvePreviewDirectMediaUrl( + userId: string, + youtubeVideoId: string, + options?: { forceRefresh?: boolean } +): Promise { + const cacheKey = previewMediaCacheKey(userId, youtubeVideoId); + + if (options?.forceRefresh) { + previewMediaCache.delete(cacheKey); + } + + const cached = previewMediaCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now() + PREVIEW_CACHE_REFRESH_BUFFER_MS) { + return cached; + } + + const resolved = await getDirectMediaUrl(youtubeVideoId); + if (!isAllowedPreviewUpstreamUrl(resolved.url)) { + throw new Error('Preview media URL is not from an allowed host'); + } + + storePreviewMediaCacheEntry(cacheKey, resolved); + return resolved; +} diff --git a/lib/youtube-import/probe-keyframes.ts b/lib/youtube-import/probe-keyframes.ts new file mode 100644 index 00000000..8c0d3edd --- /dev/null +++ b/lib/youtube-import/probe-keyframes.ts @@ -0,0 +1,280 @@ +import { spawnProcess } from '@/lib/youtube-import/spawn-process'; +import { buildYouTubeWatchUrl } from '@/lib/youtube-import/resolve-source'; +import { buildYtDlpMetadataArgs } from '@/lib/youtube-import/yt-dlp-args'; +import { buildYtDlpProcessError } from '@/lib/youtube-import/yt-dlp-errors'; + +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; +const PROCESS_TIMEOUT_MS = 15_000; + +let processTimeoutMsForTests: number | null = null; + +/** + * Overrides the spawn timeout in unit tests. + * @param timeoutMs - Timeout in milliseconds, or `null` to restore the default. + * @internal + */ +export function setYouTubeImportProcessTimeoutMsForTests(timeoutMs: number | null): void { + processTimeoutMsForTests = timeoutMs; +} + +function getProcessTimeoutMs(): number { + return processTimeoutMsForTests ?? PROCESS_TIMEOUT_MS; +} + +/** Default expiry when yt-dlp does not expose a format expiration timestamp. */ +const DEFAULT_DIRECT_MEDIA_URL_TTL_MS = 60 * 60 * 1000; + +type YtDlpFormat = { + url?: string; + height?: number; + width?: number; + vcodec?: string; + acodec?: string; + expires?: number; +}; + +type YtDlpJsonMetadata = { + formats?: YtDlpFormat[]; +}; + +/** + * Short-lived direct media URL suitable for ffprobe reads. + */ +export interface YouTubeDirectMediaUrl { + /** Progressive or video-only media URL from yt-dlp. */ + url: string; + /** Approximate Unix expiry time in milliseconds. */ + expiresAt: number; +} + +function assertValidYouTubeVideoId(youtubeVideoId: string): void { + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + throw new Error('Invalid YouTube video id'); + } +} + +function processExitError(label: string, code: number | null, stderrChunks: Buffer[]): Error { + return buildYtDlpProcessError(label, code, stderrChunks); +} + +/** + * Runs a child process with stdout/stderr collection and a hard timeout. + * @param command - Executable name on `PATH`. + * @param args - Argument vector. + * @param label - Human-readable label for error messages. + * @returns Captured stdout and stderr on success. + */ +async function runProcess( + command: string, + args: string[], + label: string +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawnProcess(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on('data', (chunk: Buffer) => { + stdoutChunks.push(chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + }); + + let timedOut = false; + const timeoutMs = getProcessTimeoutMs(); + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutMs); + + const finish = (callback: () => void) => { + clearTimeout(timeout); + callback(); + }; + + child.on('close', (code) => { + if (timedOut) { + finish(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`))); + return; + } + if (code !== 0) { + finish(() => reject(processExitError(label, code, stderrChunks))); + return; + } + finish(() => + resolve({ + stdout: Buffer.concat(stdoutChunks).toString('utf8'), + stderr: Buffer.concat(stderrChunks).toString('utf8'), + }) + ); + }); + + child.on('error', (err) => { + finish(() => reject(err)); + }); + }); +} + +/** + * Picks a low-resolution progressive or video-only format for ffprobe probing. + * @param formats - Format rows from yt-dlp JSON metadata. + * @returns Best probe candidate, or null when none is usable. + */ +export function pickYtDlpProbeFormat(formats: YtDlpFormat[]): YtDlpFormat | null { + const usable = formats.filter((format) => { + const url = format.url?.trim(); + if (!url) { + return false; + } + return (format.vcodec ?? 'none') !== 'none'; + }); + + if (usable.length === 0) { + return null; + } + + const progressive = usable.filter((format) => (format.acodec ?? 'none') !== 'none'); + const candidates = progressive.length > 0 ? progressive : usable; + + candidates.sort((a, b) => { + const heightA = a.height ?? Number.MAX_SAFE_INTEGER; + const heightB = b.height ?? Number.MAX_SAFE_INTEGER; + if (heightA !== heightB) { + return heightA - heightB; + } + + const widthA = a.width ?? Number.MAX_SAFE_INTEGER; + const widthB = b.width ?? Number.MAX_SAFE_INTEGER; + return widthA - widthB; + }); + + return candidates[0] ?? null; +} + +function resolveFormatExpiresAt(format: YtDlpFormat, url: string): number { + if (typeof format.expires === 'number' && Number.isFinite(format.expires) && format.expires > 0) { + return format.expires > 1e12 ? format.expires : format.expires * 1000; + } + + try { + const expireParam = new URL(url).searchParams.get('expire'); + if (expireParam) { + const seconds = Number(expireParam); + if (Number.isFinite(seconds) && seconds > 0) { + return seconds * 1000; + } + } + } catch { + // Ignore malformed URLs. + } + + return Date.now() + DEFAULT_DIRECT_MEDIA_URL_TTL_MS; +} + +/** + * Resolves a short-lived direct media URL for a YouTube video via yt-dlp metadata. + * @param youtubeVideoId - Valid 11-character YouTube video id. + * @returns Direct media URL and approximate expiry timestamp. + */ +export async function getDirectMediaUrl(youtubeVideoId: string): Promise { + assertValidYouTubeVideoId(youtubeVideoId); + + const watchUrl = buildYouTubeWatchUrl(youtubeVideoId); + const { stdout } = await runProcess( + 'yt-dlp', + buildYtDlpMetadataArgs(watchUrl), + 'yt-dlp metadata lookup' + ); + + let metadata: YtDlpJsonMetadata; + try { + metadata = JSON.parse(stdout) as YtDlpJsonMetadata; + } catch { + throw new Error('yt-dlp metadata lookup returned invalid JSON'); + } + + const selected = pickYtDlpProbeFormat(metadata.formats ?? []); + const url = selected?.url?.trim(); + if (!url) { + throw new Error('yt-dlp did not return a usable direct media URL'); + } + + return { + url, + expiresAt: resolveFormatExpiresAt(selected, url), + }; +} + +/** + * Parses ffprobe CSV frame output for keyframe timestamps. + * Column order follows `-show_entries frame=key_frame,pts_time`. + * @param stdout - Raw ffprobe stdout. + * @returns Keyframe timestamps in seconds, sorted ascending. + */ +export function parseFfprobeKeyframeCsv(stdout: string): number[] { + const keyframes: number[] = []; + + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + const [keyFrameRaw, ptsTimeRaw] = trimmed.split(','); + if (keyFrameRaw?.trim() !== '1') { + continue; + } + + const seconds = Number(ptsTimeRaw); + if (Number.isFinite(seconds)) { + keyframes.push(seconds); + } + } + + return keyframes.sort((a, b) => a - b); +} + +/** + * Probes nearby video keyframes using a narrow ffprobe read window. + * @param mediaUrl - Direct media URL readable by ffprobe. + * @param nearSeconds - Approximate timestamp to search around. + * @param windowSeconds - Total read window size in seconds. + * @returns Keyframe timestamps found in the window, or an empty array. + */ +export async function probeNearbyKeyframes( + mediaUrl: string, + nearSeconds: number, + windowSeconds = 8 +): Promise { + if (!mediaUrl.trim()) { + throw new Error('Media URL is required'); + } + if (!Number.isFinite(nearSeconds) || nearSeconds < 0) { + throw new Error('nearSeconds must be a non-negative number'); + } + if (!Number.isFinite(windowSeconds) || windowSeconds <= 0) { + throw new Error('windowSeconds must be a positive number'); + } + + const start = Math.max(0, nearSeconds - windowSeconds / 2); + const readInterval = `${start}%+${windowSeconds}`; + + const { stdout } = await runProcess( + 'ffprobe', + [ + '-read_intervals', + readInterval, + '-select_streams', + 'v:0', + '-show_entries', + 'frame=key_frame,pts_time', + '-of', + 'csv=p=0', + mediaUrl, + ], + 'ffprobe keyframe probe' + ); + + return parseFfprobeKeyframeCsv(stdout); +} diff --git a/lib/youtube-import/proxy-preview-media.ts b/lib/youtube-import/proxy-preview-media.ts new file mode 100644 index 00000000..4ab14be3 --- /dev/null +++ b/lib/youtube-import/proxy-preview-media.ts @@ -0,0 +1,45 @@ +const PASSTHROUGH_RESPONSE_HEADERS = [ + 'content-type', + 'content-length', + 'content-range', + 'accept-ranges', +] as const; + +/** + * Proxies a ranged media request to a YouTube CDN URL for browser preview playback. + * @param upstreamUrl - Direct media URL from yt-dlp. + * @param rangeHeader - Optional HTTP Range request header from the browser. + * @returns Upstream response body and status streamed to the client. + */ +export async function fetchProxiedPreviewMedia( + upstreamUrl: string, + rangeHeader: string | null +): Promise { + const headers: Record = { + Accept: '*/*', + }; + + if (rangeHeader) { + headers.Range = rangeHeader; + } + + const upstream = await fetch(upstreamUrl, { + headers, + redirect: 'follow', + }); + + const responseHeaders = new Headers(); + for (const name of PASSTHROUGH_RESPONSE_HEADERS) { + const value = upstream.headers.get(name); + if (value) { + responseHeaders.set(name, value); + } + } + responseHeaders.set('Cache-Control', 'no-store'); + + return new Response(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); +} diff --git a/lib/youtube-import/queue-import-distribute.ts b/lib/youtube-import/queue-import-distribute.ts new file mode 100644 index 00000000..5f31e172 --- /dev/null +++ b/lib/youtube-import/queue-import-distribute.ts @@ -0,0 +1,87 @@ +import { finalizeUploadJobAndDistribute } from '@/lib/api/finalize-upload-job'; +import { markDraftUsedInUpload } from '@/lib/repositories/drafts'; +import { + getYoutubeImportJobById, + updateYoutubeImportJobStatus, +} from '@/lib/repositories/youtube-import-jobs'; +import { getUploadJobById } from '@/lib/repositories/upload-jobs'; +import { isActiveYoutubeImportStatus } from '@/lib/youtube-import/import-job-ui'; +import type { YoutubeImportJob } from '@/types'; + +/** + * Starts platform distribution for a staged YouTube import upload job. + * @param importJob - Completed import job linked to an upload job. + * @param userId - Owning user id. + * @returns Whether distribution was started. + */ +export async function distributeStagedYoutubeImportUpload( + importJob: YoutubeImportJob, + userId: string +): Promise<{ distributing: boolean }> { + if (!importJob.uploadJobId) { + throw new Error('Import job has no linked upload job'); + } + + const uploadJob = await getUploadJobById(importJob.uploadJobId); + if (!uploadJob) { + throw new Error('Linked upload job no longer exists'); + } + + if (uploadJob.status === 'distributing' || uploadJob.status === 'completed') { + return { distributing: uploadJob.status === 'distributing' }; + } + + const result = await finalizeUploadJobAndDistribute(importJob.uploadJobId, userId); + + if (importJob.draftId) { + await markDraftUsedInUpload(importJob.draftId, uploadJob.$createdAt).catch((error) => { + console.error( + `[distributeStagedYoutubeImportUpload] Failed to mark draft ${importJob.draftId} used:`, + error + ); + }); + } + + return result; +} + +/** + * Queues platform distribution for a YouTube import. If staging already finished, + * distribution starts immediately. + * @param jobId - Import job id. + * @param userId - Authenticated user id; must own the job. + * @returns Updated import job after queueing (and optional immediate distribute). + */ +export async function queueYoutubeImportDistribute( + jobId: string, + userId: string +): Promise { + const job = await getYoutubeImportJobById(jobId); + if (!job) { + throw new Error('YouTube import job not found'); + } + if (job.userId !== userId) { + throw new Error('You do not have access to this import job'); + } + if (job.status === 'failed' || job.status === 'cancelled') { + throw new Error('This import job can no longer be uploaded'); + } + + await updateYoutubeImportJobStatus(jobId, { distributeQueued: true }); + + const refreshed = await getYoutubeImportJobById(jobId); + if (!refreshed) { + throw new Error('YouTube import job not found'); + } + + if (refreshed.status === 'completed' && refreshed.uploadJobId) { + await distributeStagedYoutubeImportUpload(refreshed, userId); + return refreshed; + } + + if (isActiveYoutubeImportStatus(refreshed.status)) { + return refreshed; + } + + throw new Error('Import job is not ready to upload'); +} diff --git a/lib/youtube-import/resolve-source.ts b/lib/youtube-import/resolve-source.ts new file mode 100644 index 00000000..d40e9ff4 --- /dev/null +++ b/lib/youtube-import/resolve-source.ts @@ -0,0 +1,297 @@ +const YOUTUBE_VIDEO_ID_PATTERN = /^[a-zA-Z0-9_-]{11}$/; +const YOUTUBE_VIDEOS_URL = 'https://www.googleapis.com/youtube/v3/videos'; + +/** Default max source length when `YT_IMPORT_MAX_DURATION_SECONDS` is unset (4 hours). */ +export const DEFAULT_YT_IMPORT_MAX_DURATION_SECONDS = 14_400; + +/** + * YouTube `videos.list` item shape used for import source resolution. + */ +export type YouTubeImportVideosListItem = { + id?: string; + snippet?: { + title?: string; + liveBroadcastContent?: string; + thumbnails?: { + high?: { url?: string }; + medium?: { url?: string }; + default?: { url?: string }; + }; + }; + contentDetails?: { + duration?: string; + }; + liveStreamingDetails?: { + actualStartTime?: string; + actualEndTime?: string; + }; +}; + +/** + * Resolved metadata returned by the import resolve route. + */ +export interface YouTubeImportResolvedMetadata { + youtubeVideoId: string; + title: string; + durationSeconds: number; + thumbnailUrl: string; +} + +/** + * Full resolved import source including proxied preview stream details. + */ +export interface YouTubeImportResolvedSource extends YouTubeImportResolvedMetadata { + /** Same-origin preview stream URL for the trim editor. */ + previewStreamUrl: string; + /** Approximate Unix expiry for the proxied preview media URL. */ + previewExpiresAt: number; +} + +/** + * Parses a YouTube watch URL, short URL, `/live/` URL, or bare 11-character id. + * @param input - User-provided URL or video id. + * @returns The extracted video id, or `null` when unparseable. + */ +export function extractYouTubeVideoId(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + + if (YOUTUBE_VIDEO_ID_PATTERN.test(trimmed)) { + return trimmed; + } + + try { + const url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); + const host = url.hostname.replace(/^www\./, ''); + + if (host === 'youtu.be') { + const id = url.pathname.split('/').filter(Boolean)[0] ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null; + } + + if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') { + if (url.pathname === '/watch') { + const id = url.searchParams.get('v')?.trim() ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null; + } + + const liveMatch = /^\/live\/([^/]+)/.exec(url.pathname); + if (liveMatch) { + const id = liveMatch[1] ?? ''; + return YOUTUBE_VIDEO_ID_PATTERN.test(id) ? id : null; + } + } + } catch { + return null; + } + + return null; +} + +/** + * Builds a canonical YouTube watch URL for a video id. + * @param videoId - 11-character YouTube video id. + * @returns Canonical `youtube.com/watch` URL. + */ +export function buildYouTubeWatchUrl(videoId: string): string { + return `https://www.youtube.com/watch?v=${videoId}`; +} + +/** + * Parses a YouTube `contentDetails.duration` ISO-8601 value into seconds. + * @param duration - ISO-8601 duration (for example `PT1H2M3S`). + * @returns Duration in whole seconds, or `null` when invalid. + */ +export function parseIso8601DurationToSeconds(duration: string): number | null { + const trimmed = duration.trim(); + if (!trimmed) { + return null; + } + + const match = /^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/.exec(trimmed); + if (!match || (match[1] == null && match[2] == null && match[3] == null)) { + return null; + } + + const hours = Number(match[1] ?? 0); + const minutes = Number(match[2] ?? 0); + const seconds = Number(match[3] ?? 0); + if (![hours, minutes, seconds].every((value) => Number.isFinite(value) && value >= 0)) { + return null; + } + + return Math.floor(hours * 3600 + minutes * 60 + seconds); +} + +/** + * Reads the configured max import duration from the environment. + * @returns Max allowed source duration in seconds. + */ +export function getYouTubeImportMaxDurationSeconds(): number { + const raw = process.env.YT_IMPORT_MAX_DURATION_SECONDS; + if (raw == null || raw.trim() === '') { + return DEFAULT_YT_IMPORT_MAX_DURATION_SECONDS; + } + + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_YT_IMPORT_MAX_DURATION_SECONDS; + } + + return Math.floor(parsed); +} + +/** + * Returns true when a `videos.list` item is a completed live broadcast archive. + * Rejects upcoming and in-progress live streams using the same signals as + * {@link isYouTubeCompletedLiveArchiveVideo} in `lib/platforms/youtube-api.ts`. + * @param video - `videos.list` item from the YouTube Data API. + * @returns Whether the video can be imported as a completed broadcast. + */ +export function isYouTubeImportableCompletedBroadcast(video: YouTubeImportVideosListItem): boolean { + const liveBroadcastContent = video.snippet?.liveBroadcastContent?.trim().toLowerCase(); + + if (liveBroadcastContent === 'upcoming') { + return false; + } + + if (liveBroadcastContent === 'live') { + return false; + } + + const actualStartTime = video.liveStreamingDetails?.actualStartTime?.trim(); + const actualEndTime = video.liveStreamingDetails?.actualEndTime?.trim(); + return Boolean(actualStartTime && actualEndTime); +} + +/** + * Picks the best available thumbnail URL from a `videos.list` snippet. + * @param video - `videos.list` item from the YouTube Data API. + * @returns Thumbnail URL, or an empty string when none is present. + */ +export function pickYouTubeImportThumbnailUrl(video: YouTubeImportVideosListItem): string { + const thumbnails = video.snippet?.thumbnails; + const candidates = [thumbnails?.high?.url, thumbnails?.medium?.url, thumbnails?.default?.url]; + + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim() !== '') { + return candidate.trim(); + } + } + + return ''; +} + +async function readYouTubeApiErrorDetails(response: Response): Promise { + const raw = await response.text().catch(() => ''); + if (!raw.trim()) { + return `YouTube API returned HTTP ${response.status}.`; + } + + try { + const parsed = JSON.parse(raw) as { error?: { message?: string } }; + if (typeof parsed.error?.message === 'string' && parsed.error.message.trim() !== '') { + return parsed.error.message.trim(); + } + } catch { + // Fall through to raw body text. + } + + return raw.trim(); +} + +/** + * Fetches a single YouTube video via `videos.list`. + * @param accessToken - OAuth access token with YouTube read scope. + * @param videoId - YouTube video id to resolve. + * @param signal - Optional abort signal. + * @returns The first matching video item, or upstream error details. + */ +export async function fetchYouTubeVideoForImport( + accessToken: string, + videoId: string, + signal?: AbortSignal +): Promise< + | { ok: true; item: YouTubeImportVideosListItem } + | { ok: false; details: string; notFound?: boolean } +> { + const url = new URL(YOUTUBE_VIDEOS_URL); + url.searchParams.set('part', 'snippet,contentDetails,liveStreamingDetails,status'); + url.searchParams.set('id', videoId); + + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${accessToken}` }, + ...(signal ? { signal } : {}), + }); + + if (!res.ok) { + return { ok: false, details: await readYouTubeApiErrorDetails(res) }; + } + + const body = (await res.json().catch(() => ({}))) as { + items?: YouTubeImportVideosListItem[]; + }; + + const item = body.items?.[0]; + if (!item) { + return { + ok: false, + details: 'Video not found or not accessible with the connected YouTube account.', + notFound: true, + }; + } + + return { ok: true, item }; +} + +/** + * Maps a `videos.list` item into import metadata after validation. + * @param item - YouTube video resource from `videos.list`. + * @returns Resolved metadata, or a human-readable validation error. + */ +export function mapYouTubeImportResolvedSource( + item: YouTubeImportVideosListItem +): { ok: true; data: YouTubeImportResolvedMetadata } | { ok: false; message: string } { + const youtubeVideoId = item.id?.trim() ?? ''; + if (!YOUTUBE_VIDEO_ID_PATTERN.test(youtubeVideoId)) { + return { ok: false, message: 'YouTube returned an invalid video id.' }; + } + + if (!isYouTubeImportableCompletedBroadcast(item)) { + return { + ok: false, + message: 'Only completed YouTube live broadcasts can be imported.', + }; + } + + const durationSeconds = parseIso8601DurationToSeconds(item.contentDetails?.duration ?? ''); + if (durationSeconds == null) { + return { + ok: false, + message: 'YouTube did not return a valid video duration.', + }; + } + + const maxDurationSeconds = getYouTubeImportMaxDurationSeconds(); + if (durationSeconds > maxDurationSeconds) { + return { + ok: false, + message: `Video exceeds the maximum import length of ${maxDurationSeconds} seconds.`, + }; + } + + const title = item.snippet?.title?.trim() ?? ''; + const thumbnailUrl = pickYouTubeImportThumbnailUrl(item); + + return { + ok: true, + data: { + youtubeVideoId, + title, + durationSeconds, + thumbnailUrl, + }, + }; +} diff --git a/lib/youtube-import/run-import-job.ts b/lib/youtube-import/run-import-job.ts new file mode 100644 index 00000000..cbe79f7a --- /dev/null +++ b/lib/youtube-import/run-import-job.ts @@ -0,0 +1,558 @@ +import { randomUUID } from 'node:crypto'; +import { readFile, mkdir, mkdtemp, rm, stat } from '@/lib/youtube-import/import-job-fs'; +import { join } from 'node:path'; +import { uploadLocalFileToR2 } from '@/lib/r2'; +import { createUploadJob, updateUploadJobStatus } from '@/lib/repositories/upload-jobs'; +import { + getYoutubeImportJobById, + updateYoutubeImportJobStatus, +} from '@/lib/repositories/youtube-import-jobs'; +import { buildYouTubeWatchUrl } from '@/lib/youtube-import/resolve-source'; +import { distributeStagedYoutubeImportUpload } from '@/lib/youtube-import/queue-import-distribute'; +import { spawnProcess } from '@/lib/youtube-import/spawn-process'; +import { buildYtDlpBaseArgs } from '@/lib/youtube-import/yt-dlp-args'; +import { buildYtDlpProcessError } from '@/lib/youtube-import/yt-dlp-errors'; +import type { YoutubeImportJob } from '@/types'; + +const DEFAULT_YT_IMPORT_WORKDIR = '/tmp/yt-import'; +const DOWNLOADED_BASENAME = 'download'; +const TRIMMED_BASENAME = 'trimmed.mp4'; +const DOWNLOAD_PROGRESS_MAX = 70; +const TRIM_PROGRESS = 85; +const UPLOAD_PROGRESS = 95; +/** Initial cancel poll interval while subprocesses start (ms). */ +const IMPORT_CANCEL_POLL_INITIAL_MS = 1_000; +/** Maximum cancel poll interval during long downloads/trims (ms). */ +const IMPORT_CANCEL_POLL_MAX_MS = 5_000; +/** Multiplier applied after each cancel poll that finds the job still active. */ +const IMPORT_CANCEL_POLL_BACKOFF_FACTOR = 1.5; + +/** + * Thrown when an import subprocess is stopped because the job was cancelled. + */ +class YoutubeImportJobCancelledError extends Error { + constructor() { + super('YouTube import job was cancelled'); + this.name = 'YoutubeImportJobCancelledError'; + } +} + +/** + * Parses a yt-dlp `[download] …%` progress chunk. + * @param chunk - stdout/stderr fragment from yt-dlp. + * @returns Download percent when present, otherwise `null`. + */ +export function parseYtDlpDownloadPercent(chunk: string): number | null { + const match = /\[download\]\s+(\d+(?:\.\d+)?)%/.exec(chunk); + if (!match) { + return null; + } + + const percent = Number(match[1]); + return Number.isFinite(percent) ? percent : null; +} + +/** + * Parses the latest non-negative `time=HH:MM:SS.ms` value from ffmpeg stderr. + * Section downloads mux through ffmpeg and may not emit `[download] …%` until the end. + * @param chunk - stdout/stderr fragment from yt-dlp/ffmpeg. + * @returns Elapsed output seconds when present, otherwise `null`. + */ +export function parseFfmpegTimeSeconds(chunk: string): number | null { + let latest: number | null = null; + + for (const match of chunk.matchAll(/time=(-)?(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)/g)) { + if (match[1] === '-') { + continue; + } + + const hours = Number(match[2]); + const minutes = Number(match[3]); + const seconds = Number(match[4]); + const total = hours * 3600 + minutes * 60 + seconds; + if (Number.isFinite(total) && total >= 0) { + latest = total; + } + } + + return latest; +} + +/** + * Maps yt-dlp/ffmpeg progress signals to the download phase percent (0–70). + * @param input - Parsed progress signals and clip duration. + * @returns Overall job percent for the download phase, or `null` when no signal is present. + */ +export function computeDownloadPhaseProgressPercent(input: { + downloadPercent: number | null; + ffmpegTimeSeconds: number | null; + sectionDurationSeconds: number; + maxPercent?: number; +}): number | null { + const maxPercent = input.maxPercent ?? DOWNLOAD_PROGRESS_MAX; + let ratio: number | null = null; + + if (input.downloadPercent != null) { + ratio = Math.min(1, Math.max(0, input.downloadPercent / 100)); + } else if ( + input.ffmpegTimeSeconds != null && + input.sectionDurationSeconds > 0 && + input.ffmpegTimeSeconds >= 0 + ) { + ratio = Math.min(1, input.ffmpegTimeSeconds / input.sectionDurationSeconds); + } + + if (ratio == null) { + return null; + } + + return Math.min(maxPercent, Math.max(1, Math.floor(ratio * maxPercent))); +} + +/** + * Computes ffmpeg copy-trim offsets inside a section download. + * @param input - Requested trim points and the section yt-dlp actually fetched. + * @returns Relative `-ss`/`-to` values for the downloaded file. + */ +export function computeTrimOffsets(input: { + jobStartSeconds: number; + jobEndSeconds: number; + sectionStartSeconds: number; + downloadedDurationSeconds: number; +}): { relativeStart: number; relativeEnd: number } { + const relativeStart = Math.max(0, input.jobStartSeconds - input.sectionStartSeconds); + const relativeEnd = Math.min( + input.downloadedDurationSeconds, + input.jobEndSeconds - input.sectionStartSeconds + ); + + if ( + !Number.isFinite(relativeStart) || + !Number.isFinite(relativeEnd) || + relativeEnd <= relativeStart + ) { + throw new Error('Trim range is empty after section download'); + } + + return { relativeStart, relativeEnd }; +} + +/** + * Builds an R2 staging key for a YouTube import upload, mirroring presign naming. + * @param userId - Owning user id. + * @param youtubeVideoId - Source YouTube video id. + * @returns Object key under `temp/uploads/{userId}/...`. + */ +export function buildYoutubeImportUploadKey(userId: string, youtubeVideoId: string): string { + const sanitized = `youtube-import-${youtubeVideoId}.mp4`.replace(/[/\\]/g, '_'); + const timestamp = Date.now(); + const uid = randomUUID(); + return `temp/uploads/${userId}/${timestamp}-${uid}/${sanitized}`; +} + +function getYoutubeImportWorkdirRoot(): string { + const configured = process.env.YT_IMPORT_WORKDIR?.trim(); + return configured && configured.length > 0 ? configured : DEFAULT_YT_IMPORT_WORKDIR; +} + +async function createImportWorkDir(): Promise { + const root = getYoutubeImportWorkdirRoot(); + await mkdir(root, { recursive: true }); + return mkdtemp(join(root.endsWith('/') ? root : `${root}/`, 'yt-import-job-')); +} + +function spawnExitError(label: string, code: number | null, stderrChunks: Buffer[]): Error { + return buildYtDlpProcessError(label, code, stderrChunks); +} + +async function runSpawnCollecting( + command: string, + args: readonly string[], + label: string, + options?: { + onStderrChunk?: (chunk: string) => void; + onStdoutChunk?: (chunk: string) => void; + isCancelled?: () => Promise; + } +): Promise { + await new Promise((resolve, reject) => { + const child = spawnProcess(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const stderrChunks: Buffer[] = []; + let stoppedForCancel = false; + let pollTimer: ReturnType | null = null; + let nextPollDelayMs = IMPORT_CANCEL_POLL_INITIAL_MS; + + const stopPolling = () => { + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + }; + + const rejectIfCancelled = async (): Promise => { + if (stoppedForCancel) { + return true; + } + if (!options?.isCancelled) { + return false; + } + if (await options.isCancelled()) { + stoppedForCancel = true; + child.kill('SIGTERM'); + return true; + } + return false; + }; + + const scheduleCancelPoll = () => { + pollTimer = setTimeout(() => { + void (async () => { + if (await rejectIfCancelled()) { + return; + } + nextPollDelayMs = Math.min( + Math.round(nextPollDelayMs * IMPORT_CANCEL_POLL_BACKOFF_FACTOR), + IMPORT_CANCEL_POLL_MAX_MS + ); + scheduleCancelPoll(); + })(); + }, nextPollDelayMs); + }; + + if (options?.isCancelled) { + void rejectIfCancelled(); + scheduleCancelPoll(); + } + + child.stdout.on('data', (chunk: Buffer) => { + options?.onStdoutChunk?.(chunk.toString('utf8')); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrChunks.push(chunk); + options?.onStderrChunk?.(chunk.toString('utf8')); + }); + + child.on('close', (code) => { + stopPolling(); + void (async () => { + if (stoppedForCancel || (await options?.isCancelled?.())) { + reject(new YoutubeImportJobCancelledError()); + return; + } + if (code !== 0) { + reject(spawnExitError(label, code, stderrChunks)); + return; + } + resolve(); + })(); + }); + child.on('error', (error) => { + stopPolling(); + reject(error); + }); + }); +} + +async function runSpawnStdout( + command: string, + args: readonly string[], + label: string, + options?: { isCancelled?: () => Promise } +): Promise { + let stdout = ''; + await runSpawnCollecting(command, args, label, { + ...options, + onStdoutChunk: (chunk) => { + stdout += chunk; + }, + }); + return stdout.trim(); +} + +function parseSectionStartFromInfoJson(info: Record): number | null { + if (typeof info.section_start === 'number' && Number.isFinite(info.section_start)) { + return info.section_start; + } + + const requested = info.requested_downloads; + if (Array.isArray(requested) && requested.length > 0) { + const first = requested[0]; + if (typeof first === 'object' && first !== null) { + const sectionStart = (first as Record).section_start; + if (typeof sectionStart === 'number' && Number.isFinite(sectionStart)) { + return sectionStart; + } + } + } + + return null; +} + +async function readDownloadMetadata( + workDir: string, + jobId: string +): Promise<{ + downloadedPath: string; + sectionStartSeconds: number; + downloadedDurationSeconds: number; +}> { + const downloadedPath = join(workDir, `${DOWNLOADED_BASENAME}.mp4`); + const infoPath = join(workDir, `${DOWNLOADED_BASENAME}.info.json`); + const isCancelled = () => isImportJobCancelled(jobId); + + const [infoRaw, durationRaw] = await Promise.all([ + readFile(infoPath, 'utf8'), + runSpawnStdout( + 'ffprobe', + [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + downloadedPath, + ], + 'ffprobe duration probe', + { isCancelled } + ), + ]); + + const info = JSON.parse(infoRaw) as Record; + const sectionStartSeconds = parseSectionStartFromInfoJson(info); + if (sectionStartSeconds == null) { + throw new Error('yt-dlp info JSON did not include section_start metadata'); + } + + const downloadedDurationSeconds = Number(durationRaw); + if (!Number.isFinite(downloadedDurationSeconds) || downloadedDurationSeconds <= 0) { + throw new Error('Downloaded section has invalid duration'); + } + + return { downloadedPath, sectionStartSeconds, downloadedDurationSeconds }; +} + +async function isImportJobCancelled(jobId: string): Promise { + const current = await getYoutubeImportJobById(jobId); + return current?.status === 'cancelled'; +} + +async function downloadYoutubeSection( + job: YoutubeImportJob, + workDir: string, + jobId: string +): Promise<{ + downloadedPath: string; + sectionStartSeconds: number; + downloadedDurationSeconds: number; +}> { + const watchUrl = buildYouTubeWatchUrl(job.youtubeVideoId); + const outputTemplate = join(workDir, `${DOWNLOADED_BASENAME}.%(ext)s`); + const section = `*${job.startSeconds}-${job.endSeconds}`; + + let lastPersistedPercent = -1; + const sectionDurationSeconds = Math.max(1, job.endSeconds - job.startSeconds); + + void updateYoutubeImportJobStatus(jobId, { progressPercent: 1 }); + + const handleDownloadProgressChunk = (chunk: string) => { + const overallPercent = computeDownloadPhaseProgressPercent({ + downloadPercent: parseYtDlpDownloadPercent(chunk), + ffmpegTimeSeconds: parseFfmpegTimeSeconds(chunk), + sectionDurationSeconds, + }); + if (overallPercent == null || overallPercent === lastPersistedPercent) { + return; + } + + lastPersistedPercent = overallPercent; + void updateYoutubeImportJobStatus(jobId, { progressPercent: overallPercent }); + }; + + await runSpawnCollecting( + 'yt-dlp', + [ + ...buildYtDlpBaseArgs(), + '--no-playlist', + '--newline', + '--download-sections', + section, + '-f', + 'bv*+ba/b', + '--merge-output-format', + 'mp4', + '--retries', + '3', + '--fragment-retries', + '3', + '--write-info-json', + '-o', + outputTemplate, + watchUrl, + ], + 'yt-dlp section download', + { + onStderrChunk: handleDownloadProgressChunk, + onStdoutChunk: handleDownloadProgressChunk, + isCancelled: () => isImportJobCancelled(jobId), + } + ); + + return readDownloadMetadata(workDir, jobId); +} + +async function trimDownloadedSection( + job: YoutubeImportJob, + download: { + downloadedPath: string; + sectionStartSeconds: number; + downloadedDurationSeconds: number; + }, + workDir: string, + jobId: string +): Promise { + const { relativeStart, relativeEnd } = computeTrimOffsets({ + jobStartSeconds: job.startSeconds, + jobEndSeconds: job.endSeconds, + sectionStartSeconds: download.sectionStartSeconds, + downloadedDurationSeconds: download.downloadedDurationSeconds, + }); + + const trimmedPath = join(workDir, TRIMMED_BASENAME); + + await runSpawnCollecting( + 'ffmpeg', + [ + '-hide_banner', + '-nostdin', + '-loglevel', + 'error', + '-ss', + String(relativeStart), + '-to', + String(relativeEnd), + '-i', + download.downloadedPath, + '-c', + 'copy', + '-y', + trimmedPath, + ], + 'ffmpeg stream-copy trim', + { isCancelled: () => isImportJobCancelled(jobId) } + ); + + const trimmedStat = await stat(trimmedPath); + if (trimmedStat.size <= 0) { + throw new Error('ffmpeg trim produced an empty output file'); + } + + return trimmedPath; +} + +/** + * Executes a single YouTube import/trim job end to end: downloads the + * requested time range, trims it with a stream-copy ffmpeg pass, uploads + * the result to R2, and hands off to the standard upload/distribution + * pipeline. Updates the job's status/progress as it proceeds so callers + * polling `getYoutubeImportJobById` see live progress. + * @param jobId - YoutubeImportJob id to execute. Must already exist with + * status `pending` or `downloading` (after atomic claim). + */ +export async function runYoutubeImportJob(jobId: string): Promise { + let workDir: string | null = null; + + try { + console.info(`[runYoutubeImportJob] Starting job ${jobId}`); + const job = await getYoutubeImportJobById(jobId); + if (!job || (job.status !== 'pending' && job.status !== 'downloading')) { + return; + } + + workDir = await createImportWorkDir(); + + if (await isImportJobCancelled(jobId)) { + return; + } + + if (job.status === 'pending') { + await updateYoutubeImportJobStatus(jobId, { status: 'downloading', progressPercent: 0 }); + } + + const download = await downloadYoutubeSection(job, workDir, jobId); + + if (await isImportJobCancelled(jobId)) { + return; + } + + await updateYoutubeImportJobStatus(jobId, { + status: 'trimming', + progressPercent: DOWNLOAD_PROGRESS_MAX, + }); + const trimmedPath = await trimDownloadedSection(job, download, workDir, jobId); + + if (await isImportJobCancelled(jobId)) { + return; + } + + await updateYoutubeImportJobStatus(jobId, { + status: 'uploading', + progressPercent: TRIM_PROGRESS, + }); + const r2Key = buildYoutubeImportUploadKey(job.userId, job.youtubeVideoId); + await uploadLocalFileToR2(trimmedPath, r2Key, 'video/mp4'); + + if (await isImportJobCancelled(jobId)) { + return; + } + + await updateYoutubeImportJobStatus(jobId, { progressPercent: UPLOAD_PROGRESS }); + const uploadJob = await createUploadJob({ + userId: job.userId, + draftId: job.draftId, + r2Key, + }); + + await updateYoutubeImportJobStatus(jobId, { r2Key, uploadJobId: uploadJob.id }); + + const latestImportJob = await getYoutubeImportJobById(jobId); + if (latestImportJob?.distributeQueued) { + await distributeStagedYoutubeImportUpload( + { ...latestImportJob, uploadJobId: uploadJob.id, r2Key }, + job.userId + ); + } else { + await updateUploadJobStatus(uploadJob.id, 'uploading'); + } + + await updateYoutubeImportJobStatus(jobId, { + status: 'completed', + progressPercent: 100, + errorMessage: null, + }); + } catch (error) { + if (error instanceof YoutubeImportJobCancelledError) { + return; + } + if (await isImportJobCancelled(jobId)) { + return; + } + + const message = error instanceof Error ? error.message : String(error); + await updateYoutubeImportJobStatus(jobId, { + status: 'failed', + errorMessage: message, + }).catch((updateErr) => { + console.error(`[runYoutubeImportJob] Failed to mark job ${jobId} as failed:`, updateErr); + }); + } finally { + if (workDir) { + await rm(workDir, { recursive: true, force: true }).catch((cleanupErr) => { + console.error( + `[runYoutubeImportJob] Failed to remove temp work dir ${workDir}:`, + cleanupErr + ); + }); + } + } +} diff --git a/lib/youtube-import/schedule-import-job.ts b/lib/youtube-import/schedule-import-job.ts new file mode 100644 index 00000000..f3e399ce --- /dev/null +++ b/lib/youtube-import/schedule-import-job.ts @@ -0,0 +1,19 @@ +import { after } from 'next/server'; +import { executeYoutubeImportJobWorker } from '@/lib/youtube-import/execute-import-job'; + +/** + * Schedules a YouTube import worker to run on the server after the current + * HTTP response is sent. Safe to call multiple times; only one worker can + * claim a pending job. + * @param jobId - Import job id. + * @param userId - Owning user id used for claim authorization. + */ +export function scheduleYoutubeImportJob(jobId: string, userId: string): void { + after(async () => { + try { + await executeYoutubeImportJobWorker(jobId, userId); + } catch (error) { + console.error(`[scheduleYoutubeImportJob] Import worker failed for ${jobId}:`, error); + } + }); +} diff --git a/lib/youtube-import/spawn-process.ts b/lib/youtube-import/spawn-process.ts new file mode 100644 index 00000000..5478646c --- /dev/null +++ b/lib/youtube-import/spawn-process.ts @@ -0,0 +1,16 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; + +/** + * Spawns a child process for YouTube import tooling (`yt-dlp`, `ffprobe`). + * @param command - Executable name on `PATH`. + * @param args - Argument vector. + * @param options - Spawn options (stdio is fixed to ignore/pipe/pipe by callers). + * @returns Child process handle. + */ +export function spawnProcess( + command: string, + args: readonly string[], + options: SpawnOptions +): ChildProcess { + return spawn(command, args, options); +} diff --git a/lib/youtube-import/yt-dlp-args.ts b/lib/youtube-import/yt-dlp-args.ts new file mode 100644 index 00000000..2db81682 --- /dev/null +++ b/lib/youtube-import/yt-dlp-args.ts @@ -0,0 +1,29 @@ +import { execPath } from 'node:process'; + +/** Default remote EJS source for distro-installed yt-dlp builds that omit bundled scripts. */ +export const YT_DLP_DEFAULT_REMOTE_COMPONENTS = 'ejs:github'; + +/** + * Builds shared yt-dlp flags required for current YouTube extraction. + * @returns Base argument list to prepend before command-specific yt-dlp options. + */ +export function buildYtDlpBaseArgs(): string[] { + const args = ['--no-update', '--js-runtimes', `node:${execPath}`]; + + const remoteComponents = process.env.YT_DLP_REMOTE_COMPONENTS?.trim(); + if (remoteComponents && remoteComponents.toLowerCase() === 'none') { + return args; + } + + args.push('--remote-components', remoteComponents || YT_DLP_DEFAULT_REMOTE_COMPONENTS); + return args; +} + +/** + * Builds yt-dlp arguments for JSON metadata extraction. + * @param watchUrl - Full YouTube watch URL. + * @returns Argument vector for `yt-dlp -J`. + */ +export function buildYtDlpMetadataArgs(watchUrl: string): string[] { + return [...buildYtDlpBaseArgs(), '-J', '--no-playlist', watchUrl]; +} diff --git a/lib/youtube-import/yt-dlp-errors.ts b/lib/youtube-import/yt-dlp-errors.ts new file mode 100644 index 00000000..c8805bb9 --- /dev/null +++ b/lib/youtube-import/yt-dlp-errors.ts @@ -0,0 +1,39 @@ +/** + * Maps common yt-dlp stderr output to user-facing import errors. + * @param stderr - Raw stderr from a failed yt-dlp process. + * @returns Friendly message when recognized, otherwise `null`. + */ +export function getUserFriendlyYtDlpErrorMessage(stderr: string): string | null { + const normalized = stderr.toLowerCase(); + + if (normalized.includes('private video')) { + return 'This video is private. Make it public or unlisted on YouTube before importing.'; + } + + return null; +} + +/** + * Builds an Error from a failed yt-dlp child process, preferring a friendly message when known. + * @param label - Human-readable process label for fallback errors. + * @param code - Process exit code, if available. + * @param stderrChunks - Captured stderr output chunks. + * @returns Error suitable for API responses and UI display. + */ +export function buildYtDlpProcessError( + label: string, + code: number | null, + stderrChunks: Buffer[] +): Error { + const detail = Buffer.concat(stderrChunks).toString('utf8').trim(); + const friendly = getUserFriendlyYtDlpErrorMessage(detail); + if (friendly) { + return new Error(friendly); + } + + const codeLabel = code == null ? 'unknown' : String(code); + const message = detail + ? `${label} failed (exit ${codeLabel}): ${detail}` + : `${label} failed (exit ${codeLabel})`; + return new Error(message); +} diff --git a/next.config.ts b/next.config.ts index 97c1ed3a..f9b3a4e1 100644 --- a/next.config.ts +++ b/next.config.ts @@ -29,6 +29,19 @@ const nextConfig: NextConfig = { // Mongoose/MongoDB use Node built-ins (net, tls, etc.); must not be webpack-bundled // for instrumentation or other server entry points. serverExternalPackages: ['mongoose', 'mongodb', 'ssh2'], + async headers() { + return [ + { + source: '/:path*', + headers: [ + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + ], + }, + ]; + }, images: { remotePatterns: [ { diff --git a/package.json b/package.json index 7484729a..da3aa9d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "videosphere", - "version": "0.1.1", + "version": "0.2.0", "private": true, "engines": { "node": ">=24.0.0", @@ -38,6 +38,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1006.0", + "@aws-sdk/lib-storage": "^3.1006.0", "@aws-sdk/s3-request-presigner": "^3.1006.0", "@heroicons/react": "^2.2.0", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -46,6 +47,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.4.2", "@radix-ui/react-slot": "^1.2.4", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1cc413a..5e263fd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1006.0 version: 3.1006.0 + '@aws-sdk/lib-storage': + specifier: ^3.1006.0 + version: 3.1006.0(@aws-sdk/client-s3@3.1006.0) '@aws-sdk/s3-request-presigner': specifier: ^3.1006.0 version: 3.1006.0 @@ -38,6 +41,9 @@ importers: '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slider': + specifier: ^1.4.2 + version: 1.4.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.3) @@ -366,6 +372,12 @@ packages: resolution: {integrity: sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g==} engines: {node: '>=20.0.0'} + '@aws-sdk/lib-storage@3.1006.0': + resolution: {integrity: sha512-oc7GkB4hN6DBJgbTr4l8Hvv4MXJYUU1lhnUEQnXNiOpr5VhLBtZLxZVb4HotPKGVNvCPDKoJ8gxpy15EjUAS8A==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@aws-sdk/client-s3': ^3.1006.0 + '@aws-sdk/middleware-bucket-endpoint@3.972.7': resolution: {integrity: sha512-goX+axlJ6PQlRnzE2bQisZ8wVrlm6dXJfBzMJhd8LhAIBan/w1Kl73fJnalM/S+18VnpzIHumyV6DtgmvqG5IA==} engines: {node: '>=20.0.0'} @@ -1927,9 +1939,15 @@ packages: '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/number@1.1.2': + resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + '@radix-ui/react-alert-dialog@1.1.15': resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} peerDependencies: @@ -1956,6 +1974,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collection@1.1.11': + resolution: {integrity: sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -1978,6 +2009,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-context@1.1.2': resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} peerDependencies: @@ -1996,6 +2036,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dialog@1.1.15': resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} peerDependencies: @@ -2018,6 +2067,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.11': resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} peerDependencies: @@ -2153,6 +2211,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-progress@1.1.8': resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} peerDependencies: @@ -2179,6 +2250,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.4.2': + resolution: {integrity: sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2197,6 +2281,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -2215,6 +2308,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-effect-event@0.0.2': resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} peerDependencies: @@ -2224,6 +2326,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-escape-keydown@1.1.1': resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} peerDependencies: @@ -2242,6 +2353,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-previous@1.1.1': resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} peerDependencies: @@ -2251,6 +2371,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.2': + resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-use-rect@1.1.1': resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} peerDependencies: @@ -2269,6 +2398,15 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-visually-hidden@1.2.3': resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} peerDependencies: @@ -2617,6 +2755,10 @@ packages: resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.1': + resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.11': resolution: {integrity: sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing==} engines: {node: '>=18.0.0'} @@ -3429,6 +3571,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -3473,6 +3618,9 @@ packages: resolution: {integrity: sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==} engines: {node: '>=20.19.0'} + buffer@5.6.0: + resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buildcheck@0.0.7: resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} engines: {node: '>=10.0.0'} @@ -4000,6 +4148,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -4219,6 +4371,9 @@ packages: engines: {node: '>=18'} hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -5092,6 +5247,10 @@ packages: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5183,6 +5342,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -5328,6 +5490,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -5367,6 +5532,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -5632,6 +5800,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -6204,7 +6375,7 @@ snapshots: '@smithy/property-provider': 4.2.11 '@smithy/protocol-http': 5.3.11 '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -6260,6 +6431,17 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/lib-storage@3.1006.0(@aws-sdk/client-s3@3.1006.0)': + dependencies: + '@aws-sdk/client-s3': 3.1006.0 + '@smithy/abort-controller': 4.2.11 + '@smithy/middleware-endpoint': 4.4.23 + '@smithy/smithy-client': 4.12.3 + buffer: 5.6.0 + events: 3.3.0 + stream-browserify: 3.0.0 + tslib: 2.8.1 + '@aws-sdk/middleware-bucket-endpoint@3.972.7': dependencies: '@aws-sdk/types': 3.973.5 @@ -6383,7 +6565,7 @@ snapshots: '@smithy/node-http-handler': 4.4.14 '@smithy/protocol-http': 5.3.11 '@smithy/smithy-client': 4.12.3 - '@smithy/types': 4.13.0 + '@smithy/types': 4.15.1 '@smithy/url-parser': 4.2.11 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 @@ -6433,7 +6615,7 @@ snapshots: '@aws-sdk/types': 3.973.5 '@smithy/property-provider': 4.2.11 '@smithy/shared-ini-file-loader': 4.4.6 - '@smithy/types': 4.13.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 transitivePeerDependencies: - aws-crt @@ -7963,8 +8145,12 @@ snapshots: '@radix-ui/number@1.1.1': {} + '@radix-ui/number@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/primitive@1.1.4': {} + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7988,6 +8174,18 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) @@ -8006,6 +8204,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -8018,6 +8222,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-context@1.1.4(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8046,6 +8256,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-direction@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -8171,6 +8387,15 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-context': 1.1.3(@types/react@19.2.14)(react@19.2.3) @@ -8210,6 +8435,25 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slider@1.4.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.2 + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-previous': 1.1.2(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3) @@ -8224,6 +8468,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-slot@1.3.0(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 @@ -8238,6 +8489,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.14)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3) @@ -8245,6 +8504,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3) @@ -8258,12 +8524,24 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.3)': dependencies: '@radix-ui/rect': 1.1.1 @@ -8278,6 +8556,13 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -8438,7 +8723,7 @@ snapshots: '@smithy/abort-controller@4.2.11': dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.15.1 tslib: 2.8.1 '@smithy/chunked-blob-reader-native@4.2.3': @@ -8667,6 +8952,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.15.1': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.11': dependencies: '@smithy/querystring-parser': 4.2.11 @@ -9580,6 +9869,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.0: {} bcrypt-pbkdf@1.0.2: @@ -9621,6 +9912,11 @@ snapshots: bson@7.2.0: {} + buffer@5.6.0: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buildcheck@0.0.7: optional: true @@ -10324,6 +10620,8 @@ snapshots: eventemitter3@5.0.4: {} + events@3.3.0: {} + expect-type@1.3.0: {} fast-deep-equal@3.1.3: {} @@ -10548,6 +10846,8 @@ snapshots: husky@9.1.7: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -11384,6 +11684,12 @@ snapshots: react@19.2.3: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -11517,6 +11823,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -11705,6 +12013,11 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + string-argv@0.3.2: {} string-width@4.2.3: @@ -11774,6 +12087,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -12056,6 +12373,8 @@ snapshots: dependencies: react: 19.2.3 + util-deprecate@1.0.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/scripts/docker-build-platform.sh b/scripts/docker-build-platform.sh index 96e972fd..a53407c3 100755 --- a/scripts/docker-build-platform.sh +++ b/scripts/docker-build-platform.sh @@ -5,6 +5,8 @@ # ./scripts/docker-build-platform.sh linux/arm64 # ./scripts/docker-build-platform.sh linux/amd64 videosphere:amd64-test # +# Run the built image locally: docs/local-docker-testing.md +# # Cross-arch on amd64 (arm64) needs QEMU. One-time host setup (Ubuntu/Debian): # sudo apt install qemu-user-static binfmt-support # sudo podman run --rm --privileged docker.io/tonistiigi/binfmt:latest --install all diff --git a/types/index.ts b/types/index.ts index 39640fdf..772b3cff 100644 --- a/types/index.ts +++ b/types/index.ts @@ -42,6 +42,21 @@ export interface User { platformDefaults?: PlatformDefaults; /** Cross-device UI preferences (profile GET/PATCH). */ preferences?: UserPreferences; + /** + * Saved draft organizational labels for autocomplete across drafts. + * Stored on the user profile (`draftLabelLibrary`). + */ + draftLabelLibrary?: DraftLabelDefinition[]; +} + +/** + * Saved draft label with display color for chips and autocomplete. + * @property name - Label text shown on drafts. + * @property color - Hex color (for example `#6366f1`). + */ +export interface DraftLabelDefinition { + name: string; + color: string; } /** User-selectable clock format for schedule time pickers and labels. */ @@ -174,13 +189,14 @@ export interface PerPlatformOverrides extends PerPlatformCopyOverrides { thumbnailPreviewUrlOverride?: string; } -/** Platform upload status. SermonAudio uses `unpublished` / `published` after upload instead of `completed`. */ +/** Platform upload status. SermonAudio uses `unpublished`, `published`, or `scheduled` after upload instead of `completed`. */ export type PlatformUploadStatus = | 'pending' | 'uploading' | 'completed' | 'unpublished' | 'published' + | 'scheduled' | 'failed'; /** Per-platform visibility (PRD: public, unlisted, private). */ @@ -369,27 +385,27 @@ export interface SermonAudioDraftFields /** When not explicitly false, publish automatically after SA video processing completes (defaults to on). */ autoPublishOnProcessed?: boolean; /** - * Scheduled publication datetime for SermonAudio (`publishDate` on sermon create). - * ISO 8601 wall-clock datetime, typically with offset (for example `2026-07-01T09:00:00-04:00`). - * When set, `autoPublishOnProcessed` is disabled and the sermon goes live at this time. + * Scheduled publication time for SermonAudio (`publishTimestamp` on publish PATCH). + * Unix timestamp in seconds. When set, `autoPublishOnProcessed` is disabled and the sermon + * goes live at this time after processing completes. */ - publishDate?: string; + publishTimestamp?: number; /** - * SermonAudio Cross Publish destinations (YouTube, Facebook, X) configured for this draft. - * Mapped to `socialSharing` on sermon create; `publishSermonAudio` only PATCHes `publishDate`. + * SermonAudio Cross Publish destinations (YouTube, Facebook, X, Instagram) configured for this draft. + * Sent as `socialSharingSettings` on publish PATCH together with `publishTimestamp`. */ crossPublish?: SermonAudioCrossPublishSettings; } /** Cross Publish destination id stored under `SermonAudioDraftFields.crossPublish`. */ -export type SermonAudioCrossPublishTarget = 'youtube' | 'facebook' | 'x'; +export type SermonAudioCrossPublishTarget = 'youtube' | 'facebook' | 'x' | 'instagram'; /** * Cross Publish options for one social destination (SermonAudio dashboard feature). - * @property postLink - Post a link to the sermon (Facebook and X only). - * @property uploadFullVideo - Upload the full sermon video (YouTube and Facebook). - * @property uploadVideoPreview - Upload a video preview clip (X/Twitter; maps to SA `useVideoClip`). - * @property linkMessage - Custom message when `postLink` is enabled (Facebook and X). + * @property postLink - Post a link to the sermon (Facebook, X, and Instagram). + * @property uploadFullVideo - Upload the full sermon video (YouTube; Facebook with `postLink` maps to SA `useVideoClip`). + * @property uploadVideoPreview - Upload a video preview clip (X/Twitter and Instagram with `postLink`; maps to SA `useVideoClip`). + * @property linkMessage - Custom message when `postLink` is enabled (Facebook, X, and Instagram). * @property title - YouTube video title when `uploadFullVideo` is enabled (maps to SA `title` on `google`). * @property description - YouTube video description when `uploadFullVideo` is enabled (maps to SA `message` on `google`). * @property privacy - YouTube visibility when `uploadFullVideo` is enabled (maps to SA `privacy` on `google`). @@ -416,12 +432,14 @@ export type SermonAudioCrossPublishOptionId = 'postLink' | 'uploadFullVideo' | ' * @property youtube - YouTube Cross Publish options. * @property facebook - Facebook Cross Publish options. * @property x - X (Twitter) Cross Publish options. + * @property instagram - Instagram Cross Publish options. */ export interface SermonAudioCrossPublishSettings { enabled?: boolean; youtube?: SermonAudioCrossPublishPlatformSettings; facebook?: SermonAudioCrossPublishPlatformSettings; x?: SermonAudioCrossPublishPlatformSettings; + instagram?: SermonAudioCrossPublishPlatformSettings; } /** @@ -453,6 +471,11 @@ export interface Draft { description: string; /** Shared tag list for every target platform; stored in `document`. */ tags: string[]; + /** + * Organizational labels for finding and grouping drafts in VideoSphere. + * Not sent to upload platforms; stored in `document`. + */ + labels?: string[]; /** Applied when distributing (mapped to each API's privacy model). */ visibility: PlatformUploadVisibility; /** Per-platform-only options (e.g. YouTube categoryId, Vimeo category URI). */ @@ -661,6 +684,49 @@ export interface UploadJob { $updatedAt: string; } +/** + * Lifecycle status for a YouTube import/trim job (download → trim → R2 handoff). + */ +export type YoutubeImportJobStatus = + | 'pending' + | 'downloading' + | 'trimming' + | 'uploading' + | 'completed' + | 'failed' + | 'cancelled'; + +/** + * YouTube import job tracking download, trim, and R2 staging before upload distribution. + */ +export interface YoutubeImportJob { + id: string; + userId: string; + /** Draft this import is destined for; set at creation. */ + draftId: string; + /** Original pasted URL or resolved watch URL. */ + sourceUrl: string; + youtubeVideoId: string; + /** Past livestream id when picked from history; null when the source was a pasted link. */ + livestreamId: string | null; + startSeconds: number; + endSeconds: number; + status: YoutubeImportJobStatus; + /** Coarse progress for the UI (0–100). */ + progressPercent: number; + errorMessage: string | null; + /** R2 object key once the trimmed file lands in staging storage. */ + r2Key: string | null; + /** Upload job id once handed off to the distribution pipeline. */ + uploadJobId: string | null; + /** When true, distribution starts automatically once staging completes. */ + distributeQueued: boolean; + /** Persistence system attribute (ISO string). */ + $createdAt: string; + /** Persistence system attribute (ISO string). */ + $updatedAt: string; +} + /** Platform upload (one per target platform per upload job). See PRD Platform Upload. */ export interface PlatformUpload { id: string; @@ -736,6 +802,11 @@ export interface ConnectedAccountPublic { hasYoutubeMainStreamKey: boolean; /** True when a non-empty YouTube temp stream key is stored (encrypted at rest). */ hasYoutubeTempStreamKey: boolean; + /** + * Present on GET /api/platforms/connections after optional OAuth health verification. + * When omitted, callers should derive status from token fields via `getConnectionStatus`. + */ + connectionStatus?: 'connected' | 'expired'; /** Persistence system attribute (ISO string). */ $createdAt: string; /** Persistence system attribute (ISO string). */