From 7a2710dc9a00e21fdcdfb4e403b25e9678650f2b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 23 May 2026 15:48:09 -0400 Subject: [PATCH] docs: design spec for YouTube importer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a paste-URL ingest path under Ingest → YouTube. Worker hosts yt-dlp, downloads to S3, then hands off to the existing proxy + thumbnail pipeline so imported assets share one lifecycle with uploads. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-23-youtube-importer-design.md | 272 +++++++ services/web-ui/public/screens-ingest.jsx | 670 ++++++++++++------ 2 files changed, 724 insertions(+), 218 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-23-youtube-importer-design.md diff --git a/docs/superpowers/specs/2026-05-23-youtube-importer-design.md b/docs/superpowers/specs/2026-05-23-youtube-importer-design.md new file mode 100644 index 0000000..8e698c7 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-youtube-importer-design.md @@ -0,0 +1,272 @@ +# YouTube Importer — Design Spec + +> Status: **design approved 2026-05-23**, awaiting user review of the spec before the implementation plan is written. + +## Context + +The Ingest group in Dragonflight today covers file Upload, Recorders (SRT/RTMP/SDI), Capture (DeckLink), Monitors, and Schedule. There is no path to bring in media that already lives on the public web. The frequent ask is: "I want to grab a YouTube link and have it become an asset in my project, with the same proxy/thumbnail pipeline as anything else." This spec adds a YouTube importer that mirrors the existing upload flow: paste a URL, pick a project, click Import, and the asset shows up in the Library once the worker is done. + +The importer rides on the existing job pipeline. After the download lands in S3, the asset re-enters the same proxy → thumbnail → ready path as a regular upload, so there is no parallel "imported asset" lifecycle to maintain. + +## Goals & non-goals + +**Goals** +- Paste a public YouTube URL, end up with a `ready` asset in the chosen project. +- Reuse the existing `assets` table, S3 layout, BullMQ pipeline, and Jobs screen — no parallel state machine. +- Progress visible from both the import screen (queue rows) and the Jobs screen. +- Clear, actionable errors for the obvious failure modes (private, age-gated, removed, geo-blocked, network). + +**Non-goals** +- Playlists, channels, or batch-paste of multiple URLs. Single URL per submission. (Easy to add later.) +- Cookies / login. Private, members-only, and age-gated videos are out of scope v1. +- Quality picker. Always grabs best MP4 (with M4A audio merge fallback). +- Non-YouTube sources (Vimeo, Twitch VODs, Dropbox links, etc.). The route is `/imports/youtube` precisely to leave room for siblings later. +- Auto-update of yt-dlp inside the running container. Updates land via image rebuild. +- Copyright enforcement. We surface a one-line "only import videos you have rights to use" note and stop there. + +## Architecture + +The importer threads through four existing layers: + +``` +[web-ui] YouTube screen ──POST /imports/youtube──▶ [mam-api] + │ + assets row (status='ingesting') + jobs row (type='youtube_import') + │ + BullMQ "import" queue + ▼ + [worker] + yt-dlp download → S3 originals/ + ffprobe metadata → assets row + status='processing' + │ + BullMQ "proxy" queue ◀── existing path + ▼ + proxy → thumbnail → ready +``` + +Once the worker hands off to the `proxy` queue, the asset is indistinguishable from one that came through Upload — same proxy worker, same thumbnail worker, same Library list. + +## 1. UX + +### Nav + +A 6th child is added to the **Ingest** group in `shell.jsx`, between Upload and Recorders: + +```js +{ id: "youtube", label: "YouTube", icon: "download" }, +``` + +The `download` glyph already exists in `icons.jsx`. The matching `ingestChildren` array in `shell.jsx` and the crumbs map in `app.jsx` both gain `"youtube"`. + +### Screen + +A new `YouTubeImport` component lives in `screens-ingest.jsx` and is exported on `window` alongside `Upload`, `Recorders`, etc. It is registered as a route in `app.jsx`. + +Layout — visually a sibling of the Upload screen: + +- **Header**: title "YouTube", subtitle "Paste a link — we download and import the best available MP4." +- **Project selector**: same `select` element as Upload's, pre-selected to the first project. +- **URL input**: a single-line `field-input` with placeholder "Paste a YouTube URL (youtube.com/watch, youtu.be, or shorts)…" and an inline **Import** button. Enter submits. The button is disabled until a URL pattern matches. +- **Subtitle line under the input**: "Only import videos you have rights to use. Private, age-gated, and members-only videos are not supported." +- **Queue panel**: identical structure to Upload's queue — one row per submitted URL, showing: + - Source icon (use `link` glyph) and the URL (truncated middle, full URL in `title` tooltip). + - Title once known (filled in by a poll on the asset row). + - Progress bar tied to job `progress` (0–100). The worker drives this between 5 and 60 % for download and 60 to 100 % for upload + DB writes. + - Status pill: queued → downloading → processing → done / failed. + - Error text if the job fails (red, one line). + - A "Clear done" button at the top of the queue. + +The queue persists for the session in component state only — no separate UI table. Jobs screen remains the canonical history. + +### URL validation (client-side, before POST) + +Accept (case-insensitive) any of these patterns: +- `https?://(www\.|m\.)?youtube\.com/watch\?[^ ]*v=[A-Za-z0-9_-]{11}` +- `https?://youtu\.be/[A-Za-z0-9_-]{11}` +- `https?://(www\.)?youtube\.com/shorts/[A-Za-z0-9_-]{11}` + +Anything else is rejected inline ("That doesn't look like a YouTube URL") without an API call. The server re-validates as a defense-in-depth check. + +### Out-of-scope v1 (called out, not built) + +- Pasting a playlist URL. Server returns 400 "Playlists aren't supported yet." +- Multi-line paste. Single URL only. +- Quality picker. yt-dlp format string is hard-coded. +- Cookies upload. Private videos fail with a clear message. + +## 2. API + +### Route + +New file `services/mam-api/src/routes/imports.js`, mounted at `/api/v1/imports` in `services/mam-api/src/index.js`. + +**`POST /api/v1/imports/youtube`** + +Request body: +```json +{ "url": "https://youtu.be/dQw4w9WgXcQ", "projectId": "uuid", "binId": "uuid?" } +``` + +Behavior: +1. Validate `url` against the same three regexes as the client. 400 on miss. +2. Reject playlist URLs (URL contains `list=`) with 400 "Playlists aren't supported yet." +3. Generate `assetId = uuidv4()`. +4. Insert into `assets` with: + - `status='ingesting'` + - `media_type='video'` + - `filename = url` (placeholder; worker overwrites with the sanitized title once yt-dlp prints metadata — keeps the row queryable in the meantime) + - `display_name = url` (same; worker overwrites) + - `original_s3_key = NULL` (worker fills in) + - `source_url = url` (new column — see Schema) + - `project_id`, `bin_id`, timestamps. +5. Insert into `jobs` with `type='youtube_import'`, `asset_id`, `payload={ url }`, `status='queued'`, `progress=0`. +6. Enqueue BullMQ job on the `import` queue: + ```js + await importQueue.add('youtube', { assetId, url }); + ``` +7. Respond `200 { assetId, jobId }`. + +Errors: +- Missing fields → 400. +- Bad URL → 400 with `error: 'Invalid YouTube URL'`. +- Playlist URL → 400 with `error: 'Playlists aren't supported yet'`. +- Project not found → 404. +- DB / queue failure → 500 (next(err)). + +### Jobs screen integration + +`services/web-ui/public/screens-jobs.jsx` already normalizes job types via a `kindMap`. Add one entry: +```js +const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode', youtube_import: 'YouTube' }; +``` +Retry, delete, and the SSE event stream all work for the new type with no further changes because they key off `job.id`, not `job.type`. + +## 3. Worker + +### Container changes + +`services/worker/Dockerfile` gains two packages: +```dockerfile +RUN apk add --no-cache ffmpeg yt-dlp python3 +``` +`yt-dlp` is in the Alpine `community` repo and pulls `python3` as a runtime dep — we list it explicitly for clarity. Image grows by ~25 MB. + +### New worker + +`services/worker/src/workers/youtube-import.js`, registered in `services/worker/src/index.js`: +```js +const workers = [ + createWorker('proxy', proxyWorker), + createWorker('thumbnail', thumbnailWorker), + createWorker('conform', conformWorker), + createWorker('import', youtubeImportWorker), +]; +``` + +### Job handler + +For a job with `{ assetId, url }`: + +1. `job.updateProgress(2)` — accepted. +2. Build a temp directory `tmpdir()/yt-${jobId}`. +3. Run yt-dlp: + ```sh + yt-dlp \ + --no-playlist \ + --no-warnings \ + --restrict-filenames \ + -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b" \ + --merge-output-format mp4 \ + --print-json \ + --newline \ + -o "/.%(ext)s" \ + "" + ``` + - `--print-json` writes one JSON line at the end with title, duration, width, height, uploader, etc. + - `--newline` makes progress lines newline-terminated so we can parse them. + - `--restrict-filenames` prevents shell-special characters in temp paths. +4. Stream stdout line-by-line. Lines matching `r'\[download\]\s+(\d+(\.\d+)?)%'` map to `job.updateProgress(5 + Math.floor(pct * 0.55))` so download takes us from 5 to 60 %. +5. On yt-dlp non-zero exit: parse stderr for the first line containing `ERROR:` and use it as the job's error message. Mark the asset `status='error'`, mark the job failed, throw so BullMQ records it. Surface a friendly substitution for the common cases: + - "Private video" → "Private video — not supported." + - "Sign in to confirm your age" → "Age-restricted video — not supported." + - "Video unavailable" → "Video unavailable or removed." + - "This video is not available in your country" → "Video is geo-blocked from this region." + - HTTP 429 → "YouTube rate-limited the importer — try again later." + - Anything else → use yt-dlp's stderr line verbatim, truncated to 300 chars. +6. Parse the last stdout line as JSON to read metadata. The resulting file is `/.mp4`. +7. `getMediaInfo` (existing helper in `services/worker/src/ffmpeg/executor.js`) on that path. Use ffprobe's values for codec/fps/duration when yt-dlp's are missing or wrong. +8. Sanitize the title for the S3 filename: keep `[A-Za-z0-9 ._-]`, collapse runs of whitespace, trim, cap at 120 chars, append `.mp4`. If the sanitized title is empty, fall back to `youtube-.mp4`. +9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %. +10. UPDATE the assets row with: + - `filename = .mp4` + - `display_name = ` + - `original_s3_key = originals//.mp4` + - `codec`, `resolution`, `fps`, `duration_ms`, `file_size` from ffprobe. + - `status = 'processing'` + - `updated_at = NOW()` +11. Enqueue a `proxy` job on the existing `proxy` queue with the same payload shape `upload.js` uses: + ```js + await proxyQueue.add('generate', { + assetId, + inputKey: asset.original_s3_key, + outputKey: `proxies/${assetId}.mp4`, + }); + ``` +12. `job.updateProgress(100)`. Return — BullMQ marks the import job done. The proxy job picks up the rest exactly like a regular upload. +13. Always `rm -rf` the temp directory in a `finally`. + +### Concurrency & retries + +- Default BullMQ concurrency for this queue: **1** per worker process. Two simultaneous yt-dlp invocations risk YouTube rate-limiting more than they help throughput. Configurable later via env if needed. +- No automatic BullMQ retry — yt-dlp failures are almost always permanent (private, geo, removed) and a silent retry storm would chew through quota. The Jobs screen's manual Retry button is the right knob for "this should be transient" cases. + +## 4. Schema migration + +New file `services/mam-api/src/db/migrations/011-youtube-import.sql`: + +```sql +-- 1. Add the new job type to the enum. +-- Postgres requires ALTER TYPE ... ADD VALUE for enum changes. +ALTER TYPE job_type ADD VALUE IF NOT EXISTS 'youtube_import'; + +-- 2. Remember where an asset came from. NULL for everything that +-- pre-dates the importer; populated for any imported asset. +ALTER TABLE assets ADD COLUMN IF NOT EXISTS source_url TEXT; +``` + +`source_url` is exposed on the asset drawer as a "Source" line ("imported from youtu.be/…") in a follow-up PR — out of scope for this spec, but worth noting that the column exists for it. + +## 5. Files touched + +**New** +- `services/mam-api/src/routes/imports.js` +- `services/mam-api/src/db/migrations/011-youtube-import.sql` +- `services/worker/src/workers/youtube-import.js` + +**Edited** +- `services/mam-api/src/index.js` — mount the new route. +- `services/web-ui/public/screens-ingest.jsx` — add `YouTubeImport`, export on `window`. +- `services/web-ui/public/shell.jsx` — add the nav child, extend `ingestChildren`. +- `services/web-ui/public/app.jsx` — register the route and the crumb. +- `services/web-ui/public/screens-jobs.jsx` — extend `kindMap` with `youtube_import: 'YouTube'`. +- `services/worker/src/index.js` — register the `import` queue worker. +- `services/worker/Dockerfile` — add `yt-dlp` and `python3` to the apk install line. + +## 6. Risks & trade-offs + +- **Worker egress**. The worker container needs outbound HTTPS to YouTube. Fine in the current homelab; will fail in a locked-down cluster. Documented in the implementation plan. +- **yt-dlp drift**. YouTube changes break old yt-dlp versions every few weeks. The Alpine package lags upstream by days. Fix is to rebuild the worker image. We do not auto-update inside the running container — too risky for an offline / locked-down deploy. If imports start failing en masse, the runbook is `docker compose build worker && docker compose up -d worker`. +- **Single-URL UX feels light**. That is deliberate for v1. Adding multi-URL paste and playlist expansion are both small follow-ups once the single-URL path is stable. +- **No copyright enforcement**. We rely on the one-line notice in the UI. If misuse becomes a real concern, the next step would be an admin allowlist of domains or a per-user import quota — not in this spec. +- **`filename = url` placeholder**. Briefly, the asset row in the Library shows the URL as the name. The worker overwrites it within seconds for a successful import. Acceptable; the Library already handles "ingesting" assets with placeholder names from the upload path. + +## 7. Acceptance + +The feature is done when: +- A user can navigate to **Ingest → YouTube**, paste a public YouTube URL, pick a project, click Import, and within a minute or two see the asset appear in the Library with proxy and thumbnail. +- A failed import (private video, removed video, bogus URL) shows a clear error message on both the YouTube screen's queue row and the Jobs screen. +- The Jobs screen lists "YouTube" jobs alongside Proxy / Thumbnail / Conform, with Retry working. +- `source_url` is populated on the imported asset row. +- Image rebuild + `docker compose up -d worker` is the documented recovery path if YouTube changes break yt-dlp. diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 191c4f1..77c24d0 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -669,109 +669,281 @@ function _durationMin(startISO, endISO) { return Math.round((new Date(endISO) - new Date(startISO)) / 60000); } -// ── Calendar helpers ───────────────────────────────────────────────────────── -function _ymd(d) { - // Local-zone yyyy-mm-dd key for grouping events into day cells. - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return y + '-' + m + '-' + day; +// ── EPG (timeline) helpers ─────────────────────────────────────────────────── +// +// The Schedule screen is a broadcast-control-room timeline: recorders are +// rows, time is the horizontal axis. Helpers below convert between Date and +// "minutes into local day" so we can position absolute-positioned event +// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable. +// +function _dayStart(d) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; } -function _startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } -function _addMonths(d, n) { return new Date(d.getFullYear(), d.getMonth() + n, 1); } -function _gridStart(viewMonth) { - // Sunday before (or equal to) the 1st of viewMonth — gives a fixed 6-row grid. - const first = _startOfMonth(viewMonth); - return new Date(first.getFullYear(), first.getMonth(), 1 - first.getDay()); +function _dayEnd(d) { + const x = _dayStart(d); + x.setDate(x.getDate() + 1); + return x; +} +function _addDays(d, n) { + const x = new Date(d); + x.setDate(x.getDate() + n); + return x; +} +function _minutesIntoDay(date, dayStart) { + return Math.max(0, Math.min(24 * 60, (date - dayStart) / 60000)); +} +function _eventOverlapsDay(ev, dayStart, dayEnd) { + const s = new Date(ev.start_at); + const e = new Date(ev.end_at); + return s < dayEnd && e > dayStart; } function _sameDay(a, b) { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } +function _fmtDay(d) { + return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); +} function _fmtTime(d) { return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); } +function _fmtHour(h) { + // 0 → "12 AM", 12 → "12 PM", 18 → "6 PM" + const ampm = h < 12 ? 'AM' : 'PM'; + const hr = ((h + 11) % 12) + 1; + return hr + ' ' + ampm; +} +function _fmtCountdown(ms) { + if (ms <= 0) return 'now'; + const s = Math.floor(ms / 1000); + if (s < 60) return 'in ' + s + 's'; + const m = Math.floor(s / 60); + if (m < 60) return 'in ' + m + 'm'; + const h = Math.floor(m / 60); + return 'in ' + h + 'h ' + (m % 60) + 'm'; +} +function _fmtElapsed(ms) { + if (ms < 0) ms = 0; + const s = Math.floor(ms / 1000); + return String(Math.floor(s / 3600)).padStart(2, '0') + ':' + + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' + + String(s % 60).padStart(2, '0'); +} -function ScheduleCalendar({ schedules, viewMonth, onDayClick, onEventClick }) { - const today = new Date(); - const gridStart = _gridStart(viewMonth); - const days = []; - for (let i = 0; i < 42; i++) { - const d = new Date(gridStart); - d.setDate(gridStart.getDate() + i); - days.push(d); - } +// Pick a stable color for a project_id given the global PROJECTS list. +function _projectColor(projectId, projects) { + if (!projectId) return null; + const p = (projects || []).find(p => p.id === projectId); + return p?.color || null; +} - const byDay = React.useMemo(() => { - const m = {}; - (schedules || []).forEach(s => { - const key = _ymd(new Date(s.start_at)); - (m[key] || (m[key] = [])).push(s); - }); - Object.values(m).forEach(list => list.sort((a, b) => new Date(a.start_at) - new Date(b.start_at))); - return m; - }, [schedules]); +// ── EPG components ─────────────────────────────────────────────────────────── + +function _StatusStrip({ schedules, recorders, now, projects }) { + // What's recording: any schedule whose window contains `now` AND whose + // status is 'running'. (Manually-started recorders without a schedule + // surface on the Recorders screen; the schedule strip stays focused on + // planned events so the operator can trust it.) + const active = (schedules || []).filter(s => { + const start = new Date(s.start_at); + const end = new Date(s.end_at); + return start <= now && now < end && (s.status === 'running' || s.status === 'pending'); + }); + + // Next up: earliest pending schedule strictly in the future. + const upcoming = (schedules || []) + .filter(s => s.status === 'pending' && new Date(s.start_at) > now) + .sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + const next = upcoming[0]; + + const recMap = {}; + (recorders || []).forEach(r => { recMap[r.id] = r; }); return ( -
-
- {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(w =>
{w}
)} -
-
- {days.map(d => { - const inMonth = d.getMonth() === viewMonth.getMonth(); - const isToday = _sameDay(d, today); - const dayEvents = byDay[_ymd(d)] || []; - const visible = dayEvents.slice(0, 3); - const overflow = dayEvents.length - visible.length; - return ( -
onDayClick(d)} - title="Click to schedule on this day"> -
- {d.getDate()} - {isToday && today} -
-
- {visible.map(s => { - const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending; - return ( - - ); - })} - {overflow > 0 && ( -
+{overflow} more
- )} -
+
+
+ {active.length === 0 ? ( + <> + + Nothing scheduled right now + + ) : ( + <> + + On air +
+ {active.map(s => { + const rec = recMap[s.recorder_id]; + const elapsed = _fmtElapsed(now - new Date(s.start_at)); + const endsAt = _fmtTime(new Date(s.end_at)); + const color = _projectColor(rec?.project_id, projects); + return ( + + {color && } + {s.name} + {rec?.name || s.recorder_id.slice(0, 8)} + {elapsed} · ends {endsAt} + + ); + })}
- ); - })} + + )} +
+
+ {next ? ( + <> + Next up + {next.name} + {recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)} + {_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))} + + ) : ( + No upcoming schedules + )}
); } -function Schedule({ navigate }) { - const [schedules, setSchedules] = React.useState(null); - const [recorders, setRecorders] = React.useState([]); - const [showNew, setShowNew] = React.useState(false); - const [newDefaults, setNewDefaults] = React.useState(null); // { start: Date, end: Date } - const [editing, setEditing] = React.useState(null); - const [filter, setFilter] = React.useState('upcoming'); - const [view, setView] = React.useState('calendar'); // 'calendar' | 'list' - const [viewMonth, setViewMonth] = React.useState(() => _startOfMonth(new Date())); +function _EpgRuler({ pph }) { + // 25 ticks so the last one (24:00) labels the right edge. The 24h column + // ends at 24*pph; the 25th tick is purely a label and has zero width. + const hours = []; + for (let h = 0; h <= 24; h++) hours.push(h); + return ( +
+ {hours.map(h => ( +
+ {h === 24 ? '' : _fmtHour(h)} +
+ ))} +
+ ); +} - // Calendar mode wants every schedule in the visible window — the upcoming/past - // filter only applies to the list view, so swap the API query accordingly. - const apiFilter = view === 'calendar' ? 'all' : filter; +function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) { + const s = new Date(event.start_at); + const e = new Date(event.end_at); + const startMin = _minutesIntoDay(s, dayStart); + const endMin = _minutesIntoDay(e, dayStart); + const left = (startMin / 60) * pph; + const width = Math.max(40, ((endMin - startMin) / 60) * pph); + + const isLive = event.status === 'running' || (event.status === 'pending' && s <= now && now < e); + const isFailed = event.status === 'failed'; + const isPast = (event.status === 'completed' || event.status === 'cancelled') || e < now; + const color = _projectColor(recorder?.project_id, projects); + + const classes = ['epg-block']; + if (isLive) classes.push('live'); + if (isFailed) classes.push('failed'); + else if (isPast) classes.push('past'); + + return ( + + ); +} + +function _EpgRow({ recorder, schedules, dayStart, dayEnd, pph, now, projects, onEventClick, onEmptyClick }) { + const dayEvents = (schedules || []).filter(s => s.recorder_id === recorder.id && _eventOverlapsDay(s, dayStart, dayEnd)); + + const handleRowClick = (e) => { + // Translate clicked x to a Date in this row's day. Snap to 15-minute + // increments so the resulting modal pre-fill looks intentional. + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left; + const minutes = Math.max(0, Math.min(24 * 60 - 30, Math.round((x / pph) * 60 / 15) * 15)); + const start = new Date(dayStart); + start.setMinutes(minutes); + onEmptyClick(recorder, start); + }; + + return ( +
+ {dayEvents.map(s => ( + <_EventBlock + key={s.id} + event={s} + recorder={recorder} + dayStart={dayStart} + dayEnd={dayEnd} + pph={pph} + now={now} + projects={projects} + onClick={onEventClick} /> + ))} +
+ ); +} + +function _NowLine({ now, dayStart, pph }) { + if (!_sameDay(now, dayStart)) return null; + const min = _minutesIntoDay(now, dayStart); + const x = (min / 60) * pph; + return ( +
+ +
+ ); +} + +function _RecorderGutter({ recorders, projects }) { + return ( +
+ {recorders.map(r => { + const color = _projectColor(r.project_id, projects); + const isLive = r.status === 'recording'; + const isErr = r.status === 'error'; + return ( +
+ +
+
{r.name}
+
{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && }
+
+
+ ); + })} +
+ ); +} + +function Schedule({ navigate }) { + const [schedules, setSchedules] = React.useState(null); + const [recorders, setRecorders] = React.useState([]); + const [showNew, setShowNew] = React.useState(false); + const [newDefaults, setNewDefaults] = React.useState(null); + const [editing, setEditing] = React.useState(null); + const [view, setView] = React.useState('today'); // 'today' | 'week' | 'list' + const [day, setDay] = React.useState(() => _dayStart(new Date())); + const [listFilter, setListFilter] = React.useState('upcoming'); + const [now, setNow] = React.useState(() => new Date()); + + // Tick the now-line every second. We only re-render the components that + // consume `now`; the rest are React.memo or insensitive. + React.useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + + // Schedule data — pull everything once and filter client-side for the + // active view. /schedules caps at 200 rows so this stays cheap. + const apiFilter = view === 'list' ? listFilter : 'all'; const load = React.useCallback(() => { window.ZAMPP_API.fetch('/schedules?status=' + apiFilter) .then(d => setSchedules(d.schedules || [])) @@ -779,165 +951,226 @@ function Schedule({ navigate }) { }, [apiFilter]); React.useEffect(() => { load(); }, [load]); - React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []); - - // Auto-refresh every 10s so the list reflects the tick loop's transitions React.useEffect(() => { - const t = setInterval(load, 10_000); - return () => clearInterval(t); - }, [load]); + window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); + }, []); - const cancel = (s) => { - if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return; - window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }) - .then(load) - .catch(e => alert('Cancel failed: ' + e.message)); - }; - const remove = (s) => { - if (!confirm(`Delete schedule "${s.name}"?`)) return; - window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }) - .then(load) - .catch(e => alert('Delete failed: ' + e.message)); - }; + // Auto-refresh: 10s in normal view, 4s if anything is live so the now-state + // catches transitions promptly. + React.useEffect(() => { + const anyLive = (schedules || []).some(s => s.status === 'running'); + const id = setInterval(load, anyLive ? 4000 : 10_000); + return () => clearInterval(id); + }, [load, schedules]); - // Calendar: clicking a day pre-fills the new-schedule modal with a 30-minute - // window starting at 10:00 AM that day (or +5min if the day is today and - // 10:00 has already passed). Gives the operator a sensible starting point - // instead of dropping them into empty datetime-local fields. - const openNewOnDay = (day) => { - const now = new Date(); - const isToday = _sameDay(day, now); - const start = new Date(day); - if (isToday && now.getHours() >= 10) { - start.setHours(now.getHours(), now.getMinutes() + 5, 0, 0); - } else { - start.setHours(10, 0, 0, 0); + // The Recorders screen broadcasts these on create/delete; refresh the + // gutter so renamed or new recorders show up immediately. + React.useEffect(() => { + const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {}); + window.addEventListener('df:recorders-changed', refresh); + return () => window.removeEventListener('df:recorders-changed', refresh); + }, []); + + const projects = window.ZAMPP_DATA?.PROJECTS || []; + + // Pixels per hour — wider on Today (high-res operations view), tighter + // when the user is scanning Week-at-a-glance. + const pph = view === 'week' ? 44 : 88; + + const dayStart = _dayStart(day); + const dayEnd = _dayEnd(day); + + // Scroll the canvas so "now" sits ~30% from the left edge on first paint + // for Today view. Re-runs when the user jumps days via the Today button. + const canvasRef = React.useRef(null); + React.useLayoutEffect(() => { + if (view !== 'today' || !canvasRef.current) return; + if (!_sameDay(now, dayStart)) { + canvasRef.current.scrollLeft = 0; + return; } + const min = _minutesIntoDay(now, dayStart); + const x = (min / 60) * pph; + const target = Math.max(0, x - canvasRef.current.clientWidth * 0.3); + canvasRef.current.scrollLeft = target; + // Deliberately only re-run on view/day change, not on `now` ticking. + // Otherwise the canvas would re-scroll every second and trap the user. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, day, pph, recorders.length]); + + const openNewAt = (recorder, start) => { const end = new Date(start.getTime() + 30 * 60 * 1000); - setNewDefaults({ start, end }); + setNewDefaults({ start, end, recorder_id: recorder.id }); setShowNew(true); }; const openNewBlank = () => { setNewDefaults(null); setShowNew(true); }; - const monthLabel = viewMonth.toLocaleString(undefined, { month: 'long', year: 'numeric' }); + const cancel = (s) => { + if (!confirm('Cancel scheduled recording "' + s.name + '"?')) return; + window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' }).then(load).catch(e => alert('Cancel failed: ' + e.message)); + }; + const remove = (s) => { + if (!confirm('Delete schedule "' + s.name + '"?')) return; + window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' }).then(load).catch(e => alert('Delete failed: ' + e.message)); + }; + + // Days for Week view: the 7-day window starting at the Sunday of `day`. + const weekDays = React.useMemo(() => { + const sun = _addDays(dayStart, -dayStart.getDay()); + return Array.from({ length: 7 }, (_, i) => _addDays(sun, i)); + }, [dayStart]); + + // List view filters schedules by client-side time bucket too. + const listSchedules = React.useMemo(() => { + if (!schedules) return null; + return [...schedules].sort((a, b) => new Date(a.start_at) - new Date(b.start_at)); + }, [schedules]); return ( -
-
-

Schedule

- - {view === 'calendar' - ? 'Click a day to schedule · ' + (schedules?.length || 0) + ' total' - : 'Plan recorder windows · ' + (schedules?.length || 0) + ' ' + filter} - -
-
- - +
+ <_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} /> + +
+
+ +
{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}
+ + +
+
+
+ + +
- {view === 'list' && ( -
- - - -
- )}
-
- {view === 'calendar' && ( - <> -
- -
{monthLabel}
- - -
- {recorders.length === 0 && ( -
Create a recorder before scheduling.
- )} -
- {schedules === null - ?
Loading…
- : setEditing(s)} />} - - )} + {schedules === null && ( +
Loading…
+ )} - {view === 'list' && schedules === null && ( -
Loading…
- )} - {view === 'list' && schedules !== null && schedules.length === 0 && ( -
-
📅
-
No {filter} recordings
- {filter === 'upcoming' && recorders.length === 0 && ( -
Create a recorder first, then schedule it here.
- )} - {filter === 'upcoming' && recorders.length > 0 && ( -
Click New schedule to plan a future recording.
- )} + {schedules !== null && recorders.length === 0 && ( +
+
No recorders configured
+
Create a recorder before scheduling.
+ +
+ )} + + {schedules !== null && recorders.length > 0 && view === 'today' && ( +
+
+ {_fmtDay(day)}
- )} - {view === 'list' && schedules !== null && schedules.length > 0 && ( -
-
-
Name
-
Recorder
-
Starts
-
Duration
-
Recurrence
-
Status
-
+
+ <_RecorderGutter recorders={recorders} projects={projects} /> +
+
+ <_EpgRuler pph={pph} /> +
+
+
+ {recorders.map(r => ( + <_EpgRow key={r.id} + recorder={r} + schedules={schedules} + dayStart={dayStart} + dayEnd={dayEnd} + pph={pph} + now={now} + projects={projects} + onEventClick={(s) => setEditing(s)} + onEmptyClick={openNewAt} /> + ))}
- {schedules.map(s => { - const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending; - return ( -
-
- {s.name} - {s.error_message && ( -
{s.error_message}
- )} -
-
{s.recorder_name || s.recorder_id.slice(0, 8)}
-
{_fmtWhen(s.start_at)}
-
{_durationMin(s.start_at, s.end_at)} min
-
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
-
{badge.label}
-
- {s.status === 'pending' && ( - - )} - {(s.status === 'pending' || s.status === 'running') && ( - - )} - {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && ( - - )} -
-
- ); - })} + <_NowLine now={now} dayStart={dayStart} pph={pph} />
- )} -
+
+ )} + + {schedules !== null && recorders.length > 0 && view === 'week' && ( +
+ {weekDays.map(d => { + const dEnd = _dayEnd(d); + const isToday = _sameDay(d, new Date()); + return ( +
+
+ {_fmtDay(d)} + {isToday && today} +
+
+ <_EpgRuler pph={pph} /> +
+ {recorders.map(r => ( + <_EpgRow key={r.id} + recorder={r} + schedules={schedules} + dayStart={d} + dayEnd={dEnd} + pph={pph} + now={now} + projects={projects} + onEventClick={(s) => setEditing(s)} + onEmptyClick={openNewAt} /> + ))} +
+ {isToday && <_NowLine now={now} dayStart={d} pph={pph} />} +
+
+ ); + })} +
+ )} + + {schedules !== null && recorders.length > 0 && view === 'list' && ( +
+
+ + + +
+ {(listSchedules || []).length === 0 ? ( +
No {listFilter} schedules
+ ) : ( +
+
+
Name
Recorder
Starts
Duration
Recurrence
Status
+
+ {listSchedules.map(s => { + const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending; + return ( +
+
+ {s.name} + {s.error_message &&
{s.error_message}
} +
+
{s.recorder_name || s.recorder_id.slice(0, 8)}
+
{_fmtWhen(s.start_at)}
+
{_durationMin(s.start_at, s.end_at)} min
+
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
+
{badge.label}
+
+ {s.status === 'pending' && } + {(s.status === 'pending' || s.status === 'running') && } + {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && + } +
+
+ ); + })} +
+ )} +
+ )} {showNew && { setShowNew(false); setNewDefaults(null); }} @@ -947,6 +1180,7 @@ function Schedule({ navigate }) { ); } + function EditScheduleModal({ schedule, onClose, onSaved }) { const toLocalInput = (iso) => { const d = new Date(iso); @@ -1036,7 +1270,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) { ); } -function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd }) { +function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd, defaultRecorderId }) { // If the user clicked a day on the calendar we honour that; otherwise default // to "start in 5 minutes, run for 30 min" so the modal is immediately usable. const toLocalInput = (d) => { @@ -1050,7 +1284,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default const [form, setForm] = React.useState({ name: '', - recorder_id: recorders[0]?.id || '', + recorder_id: defaultRecorderId || recorders[0]?.id || '', start_at: toLocalInput(startDefault), end_at: toLocalInput(endDefault), recurrence: 'none',