docs: design spec for YouTube importer
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 <noreply@anthropic.com>
This commit is contained in:
parent
674dccca4e
commit
7a2710dc9a
2 changed files with 724 additions and 218 deletions
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
272
docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Normal file
|
|
@ -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 "<tmpdir>/<assetId>.%(ext)s" \
|
||||||
|
"<url>"
|
||||||
|
```
|
||||||
|
- `--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 `<tmpdir>/<assetId>.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-<videoId>.mp4`.
|
||||||
|
9. Upload to `originals/{assetId}/{sanitized-title}.mp4` via the existing `uploadToS3` helper. Progress 60 → 90 %.
|
||||||
|
10. UPDATE the assets row with:
|
||||||
|
- `filename = <sanitized title>.mp4`
|
||||||
|
- `display_name = <yt-dlp title untouched>`
|
||||||
|
- `original_s3_key = originals/<assetId>/<sanitized-title>.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.
|
||||||
|
|
@ -669,109 +669,281 @@ function _durationMin(startISO, endISO) {
|
||||||
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
|
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Calendar helpers ─────────────────────────────────────────────────────────
|
// ── EPG (timeline) helpers ───────────────────────────────────────────────────
|
||||||
function _ymd(d) {
|
//
|
||||||
// Local-zone yyyy-mm-dd key for grouping events into day cells.
|
// The Schedule screen is a broadcast-control-room timeline: recorders are
|
||||||
const y = d.getFullYear();
|
// rows, time is the horizontal axis. Helpers below convert between Date and
|
||||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
// "minutes into local day" so we can position absolute-positioned event
|
||||||
const day = String(d.getDate()).padStart(2, '0');
|
// blocks against a fixed --epg-pph (pixels-per-hour) CSS variable.
|
||||||
return y + '-' + m + '-' + day;
|
//
|
||||||
|
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 _dayEnd(d) {
|
||||||
function _addMonths(d, n) { return new Date(d.getFullYear(), d.getMonth() + n, 1); }
|
const x = _dayStart(d);
|
||||||
function _gridStart(viewMonth) {
|
x.setDate(x.getDate() + 1);
|
||||||
// Sunday before (or equal to) the 1st of viewMonth — gives a fixed 6-row grid.
|
return x;
|
||||||
const first = _startOfMonth(viewMonth);
|
}
|
||||||
return new Date(first.getFullYear(), first.getMonth(), 1 - first.getDay());
|
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) {
|
function _sameDay(a, b) {
|
||||||
return a.getFullYear() === b.getFullYear()
|
return a.getFullYear() === b.getFullYear()
|
||||||
&& a.getMonth() === b.getMonth()
|
&& a.getMonth() === b.getMonth()
|
||||||
&& a.getDate() === b.getDate();
|
&& a.getDate() === b.getDate();
|
||||||
}
|
}
|
||||||
|
function _fmtDay(d) {
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
function _fmtTime(d) {
|
function _fmtTime(d) {
|
||||||
return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
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 }) {
|
// Pick a stable color for a project_id given the global PROJECTS list.
|
||||||
const today = new Date();
|
function _projectColor(projectId, projects) {
|
||||||
const gridStart = _gridStart(viewMonth);
|
if (!projectId) return null;
|
||||||
const days = [];
|
const p = (projects || []).find(p => p.id === projectId);
|
||||||
for (let i = 0; i < 42; i++) {
|
return p?.color || null;
|
||||||
const d = new Date(gridStart);
|
}
|
||||||
d.setDate(gridStart.getDate() + i);
|
|
||||||
days.push(d);
|
|
||||||
}
|
|
||||||
|
|
||||||
const byDay = React.useMemo(() => {
|
// ── EPG components ───────────────────────────────────────────────────────────
|
||||||
const m = {};
|
|
||||||
(schedules || []).forEach(s => {
|
function _StatusStrip({ schedules, recorders, now, projects }) {
|
||||||
const key = _ymd(new Date(s.start_at));
|
// What's recording: any schedule whose window contains `now` AND whose
|
||||||
(m[key] || (m[key] = [])).push(s);
|
// status is 'running'. (Manually-started recorders without a schedule
|
||||||
});
|
// surface on the Recorders screen; the schedule strip stays focused on
|
||||||
Object.values(m).forEach(list => list.sort((a, b) => new Date(a.start_at) - new Date(b.start_at)));
|
// planned events so the operator can trust it.)
|
||||||
return m;
|
const active = (schedules || []).filter(s => {
|
||||||
}, [schedules]);
|
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 (
|
return (
|
||||||
<div className="cal">
|
<div className="epg-status">
|
||||||
<div className="cal-weekheads">
|
<div className="epg-status-row">
|
||||||
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(w => <div key={w}>{w}</div>)}
|
{active.length === 0 ? (
|
||||||
</div>
|
<>
|
||||||
<div className="cal-grid">
|
<span className="epg-status-dot idle" />
|
||||||
{days.map(d => {
|
<span className="epg-status-label">Nothing scheduled right now</span>
|
||||||
const inMonth = d.getMonth() === viewMonth.getMonth();
|
</>
|
||||||
const isToday = _sameDay(d, today);
|
) : (
|
||||||
const dayEvents = byDay[_ymd(d)] || [];
|
<>
|
||||||
const visible = dayEvents.slice(0, 3);
|
<span className="epg-status-dot live" />
|
||||||
const overflow = dayEvents.length - visible.length;
|
<span className="epg-status-label">On air</span>
|
||||||
return (
|
<div className="epg-status-active">
|
||||||
<div key={d.toISOString()}
|
{active.map(s => {
|
||||||
className={'cal-cell' + (inMonth ? '' : ' off-month') + (isToday ? ' today' : '')}
|
const rec = recMap[s.recorder_id];
|
||||||
onClick={() => onDayClick(d)}
|
const elapsed = _fmtElapsed(now - new Date(s.start_at));
|
||||||
title="Click to schedule on this day">
|
const endsAt = _fmtTime(new Date(s.end_at));
|
||||||
<div className="cal-cell-head">
|
const color = _projectColor(rec?.project_id, projects);
|
||||||
<span className="cal-daynum">{d.getDate()}</span>
|
return (
|
||||||
{isToday && <span className="cal-today-pip">today</span>}
|
<span key={s.id} className="epg-status-pill">
|
||||||
</div>
|
{color && <span className="epg-status-pill-bar" style={{ background: color }} />}
|
||||||
<div className="cal-events">
|
<span className="epg-status-pill-name">{s.name}</span>
|
||||||
{visible.map(s => {
|
<span className="epg-status-pill-rec mono">{rec?.name || s.recorder_id.slice(0, 8)}</span>
|
||||||
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
|
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
|
||||||
return (
|
</span>
|
||||||
<button key={s.id}
|
);
|
||||||
className={'cal-event ' + badge.cls}
|
})}
|
||||||
onClick={(e) => { e.stopPropagation(); onEventClick(s); }}
|
|
||||||
title={s.name + ' · ' + _fmtWhen(s.start_at) + ' → ' + _fmtWhen(s.end_at) + ' · ' + badge.label}>
|
|
||||||
<span className="cal-event-time mono">{_fmtTime(new Date(s.start_at))}</span>
|
|
||||||
<span className="cal-event-name">{s.name}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{overflow > 0 && (
|
|
||||||
<div className="cal-event-overflow">+{overflow} more</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</>
|
||||||
})}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="epg-status-row sub">
|
||||||
|
{next ? (
|
||||||
|
<>
|
||||||
|
<span className="epg-status-label muted">Next up</span>
|
||||||
|
<span className="epg-status-next">{next.name}</span>
|
||||||
|
<span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)}</span>
|
||||||
|
<span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="epg-status-label muted">No upcoming schedules</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Schedule({ navigate }) {
|
function _EpgRuler({ pph }) {
|
||||||
const [schedules, setSchedules] = React.useState(null);
|
// 25 ticks so the last one (24:00) labels the right edge. The 24h column
|
||||||
const [recorders, setRecorders] = React.useState([]);
|
// ends at 24*pph; the 25th tick is purely a label and has zero width.
|
||||||
const [showNew, setShowNew] = React.useState(false);
|
const hours = [];
|
||||||
const [newDefaults, setNewDefaults] = React.useState(null); // { start: Date, end: Date }
|
for (let h = 0; h <= 24; h++) hours.push(h);
|
||||||
const [editing, setEditing] = React.useState(null);
|
return (
|
||||||
const [filter, setFilter] = React.useState('upcoming');
|
<div className="epg-ruler" style={{ width: 24 * pph }}>
|
||||||
const [view, setView] = React.useState('calendar'); // 'calendar' | 'list'
|
{hours.map(h => (
|
||||||
const [viewMonth, setViewMonth] = React.useState(() => _startOfMonth(new Date()));
|
<div key={h} className={'epg-ruler-tick ' + (h === 24 ? 'end' : '')}
|
||||||
|
style={{ left: h * pph }}>
|
||||||
|
<span>{h === 24 ? '' : _fmtHour(h)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calendar mode wants every schedule in the visible window — the upcoming/past
|
function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, onClick }) {
|
||||||
// filter only applies to the list view, so swap the API query accordingly.
|
const s = new Date(event.start_at);
|
||||||
const apiFilter = view === 'calendar' ? 'all' : filter;
|
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 (
|
||||||
|
<button
|
||||||
|
className={classes.join(' ')}
|
||||||
|
style={{ left, width, '--epg-block-color': color || 'var(--text-3)' }}
|
||||||
|
onClick={(ev) => { ev.stopPropagation(); onClick(event); }}
|
||||||
|
title={event.name + ' · ' + _fmtTime(s) + ' → ' + _fmtTime(e) + (event.error_message ? ' · ' + event.error_message : '')}>
|
||||||
|
<span className="epg-block-bar" />
|
||||||
|
<span className="epg-block-name">{event.name}</span>
|
||||||
|
<span className="epg-block-time mono">{_fmtTime(s)}</span>
|
||||||
|
{isLive && <span className="epg-block-glyph live" title="on air">●</span>}
|
||||||
|
{isFailed && <span className="epg-block-glyph failed" title={event.error_message || 'failed'}>!</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="epg-row" style={{ width: 24 * pph }} onClick={handleRowClick}>
|
||||||
|
{dayEvents.map(s => (
|
||||||
|
<_EventBlock
|
||||||
|
key={s.id}
|
||||||
|
event={s}
|
||||||
|
recorder={recorder}
|
||||||
|
dayStart={dayStart}
|
||||||
|
dayEnd={dayEnd}
|
||||||
|
pph={pph}
|
||||||
|
now={now}
|
||||||
|
projects={projects}
|
||||||
|
onClick={onEventClick} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _NowLine({ now, dayStart, pph }) {
|
||||||
|
if (!_sameDay(now, dayStart)) return null;
|
||||||
|
const min = _minutesIntoDay(now, dayStart);
|
||||||
|
const x = (min / 60) * pph;
|
||||||
|
return (
|
||||||
|
<div className="epg-now" style={{ left: x }}>
|
||||||
|
<span className="epg-now-pip" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _RecorderGutter({ recorders, projects }) {
|
||||||
|
return (
|
||||||
|
<div className="epg-gutter-rows">
|
||||||
|
{recorders.map(r => {
|
||||||
|
const color = _projectColor(r.project_id, projects);
|
||||||
|
const isLive = r.status === 'recording';
|
||||||
|
const isErr = r.status === 'error';
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="epg-gutter-row">
|
||||||
|
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
|
||||||
|
<div className="epg-gutter-meta">
|
||||||
|
<div className="epg-gutter-name">{r.name}</div>
|
||||||
|
<div className="epg-gutter-sub mono">{(r.source_type || '—').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
const load = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
|
window.ZAMPP_API.fetch('/schedules?status=' + apiFilter)
|
||||||
.then(d => setSchedules(d.schedules || []))
|
.then(d => setSchedules(d.schedules || []))
|
||||||
|
|
@ -779,165 +951,226 @@ function Schedule({ navigate }) {
|
||||||
}, [apiFilter]);
|
}, [apiFilter]);
|
||||||
|
|
||||||
React.useEffect(() => { load(); }, [load]);
|
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(() => {
|
React.useEffect(() => {
|
||||||
const t = setInterval(load, 10_000);
|
window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([]));
|
||||||
return () => clearInterval(t);
|
}, []);
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const cancel = (s) => {
|
// Auto-refresh: 10s in normal view, 4s if anything is live so the now-state
|
||||||
if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
|
// catches transitions promptly.
|
||||||
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
|
React.useEffect(() => {
|
||||||
.then(load)
|
const anyLive = (schedules || []).some(s => s.status === 'running');
|
||||||
.catch(e => alert('Cancel failed: ' + e.message));
|
const id = setInterval(load, anyLive ? 4000 : 10_000);
|
||||||
};
|
return () => clearInterval(id);
|
||||||
const remove = (s) => {
|
}, [load, schedules]);
|
||||||
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));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calendar: clicking a day pre-fills the new-schedule modal with a 30-minute
|
// The Recorders screen broadcasts these on create/delete; refresh the
|
||||||
// window starting at 10:00 AM that day (or +5min if the day is today and
|
// gutter so renamed or new recorders show up immediately.
|
||||||
// 10:00 has already passed). Gives the operator a sensible starting point
|
React.useEffect(() => {
|
||||||
// instead of dropping them into empty datetime-local fields.
|
const refresh = () => window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => {});
|
||||||
const openNewOnDay = (day) => {
|
window.addEventListener('df:recorders-changed', refresh);
|
||||||
const now = new Date();
|
return () => window.removeEventListener('df:recorders-changed', refresh);
|
||||||
const isToday = _sameDay(day, now);
|
}, []);
|
||||||
const start = new Date(day);
|
|
||||||
if (isToday && now.getHours() >= 10) {
|
const projects = window.ZAMPP_DATA?.PROJECTS || [];
|
||||||
start.setHours(now.getHours(), now.getMinutes() + 5, 0, 0);
|
|
||||||
} else {
|
// Pixels per hour — wider on Today (high-res operations view), tighter
|
||||||
start.setHours(10, 0, 0, 0);
|
// 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);
|
const end = new Date(start.getTime() + 30 * 60 * 1000);
|
||||||
setNewDefaults({ start, end });
|
setNewDefaults({ start, end, recorder_id: recorder.id });
|
||||||
setShowNew(true);
|
setShowNew(true);
|
||||||
};
|
};
|
||||||
const openNewBlank = () => { setNewDefaults(null); 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 (
|
return (
|
||||||
<div className="page">
|
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
|
||||||
<div className="page-header">
|
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
|
||||||
<h1>Schedule</h1>
|
|
||||||
<span className="subtitle">
|
<div className="epg-toolbar">
|
||||||
{view === 'calendar'
|
<div className="epg-date">
|
||||||
? 'Click a day to schedule · ' + (schedules?.length || 0) + ' total'
|
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
|
||||||
: 'Plan recorder windows · ' + (schedules?.length || 0) + ' ' + filter}
|
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
|
||||||
</span>
|
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
|
||||||
<div className="spacer" />
|
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
|
||||||
<div className="tab-group" style={{ marginRight: 8 }}>
|
</div>
|
||||||
<button className={view === 'calendar' ? 'active' : ''} onClick={() => setView('calendar')} title="Month calendar"><Icon name="jobs" />Calendar</button>
|
<div className="spacer" />
|
||||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="Linear list"><Icon name="list" />List</button>
|
<div className="tab-group">
|
||||||
|
<button className={view === 'today' ? 'active' : ''} onClick={() => setView('today')}>Today</button>
|
||||||
|
<button className={view === 'week' ? 'active' : ''} onClick={() => setView('week')}>Week</button>
|
||||||
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}>List</button>
|
||||||
</div>
|
</div>
|
||||||
{view === 'list' && (
|
|
||||||
<div className="tab-group" style={{ marginRight: 8 }}>
|
|
||||||
<button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
|
|
||||||
<button className={filter === 'past' ? 'active' : ''} onClick={() => setFilter('past')}>Past</button>
|
|
||||||
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
|
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
|
||||||
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
|
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
|
||||||
<Icon name="plus" />New schedule
|
<Icon name="plus" />New schedule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-body">
|
{schedules === null && (
|
||||||
{view === 'calendar' && (
|
<div className="epg-empty">Loading…</div>
|
||||||
<>
|
)}
|
||||||
<div className="cal-toolbar">
|
|
||||||
<button className="btn ghost sm" onClick={() => setViewMonth(_addMonths(viewMonth, -1))} title="Previous month">
|
|
||||||
<Icon name="chevron" style={{ transform: 'rotate(90deg)' }} />
|
|
||||||
</button>
|
|
||||||
<div className="cal-month-label">{monthLabel}</div>
|
|
||||||
<button className="btn ghost sm" onClick={() => setViewMonth(_addMonths(viewMonth, 1))} title="Next month">
|
|
||||||
<Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
<button className="btn ghost sm" onClick={() => setViewMonth(_startOfMonth(new Date()))} style={{ marginLeft: 8 }}>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
{recorders.length === 0 && (
|
|
||||||
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Create a recorder before scheduling.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{schedules === null
|
|
||||||
? <div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
|
|
||||||
: <ScheduleCalendar
|
|
||||||
schedules={schedules}
|
|
||||||
viewMonth={viewMonth}
|
|
||||||
onDayClick={openNewOnDay}
|
|
||||||
onEventClick={(s) => setEditing(s)} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{view === 'list' && schedules === null && (
|
{schedules !== null && recorders.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
|
<div className="epg-empty">
|
||||||
)}
|
<div className="epg-empty-title">No recorders configured</div>
|
||||||
{view === 'list' && schedules !== null && schedules.length === 0 && (
|
<div className="epg-empty-sub">Create a recorder before scheduling.</div>
|
||||||
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
|
<button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
|
||||||
<div style={{ fontSize: 32, marginBottom: 12 }}>📅</div>
|
</div>
|
||||||
<div style={{ fontWeight: 500, fontSize: 14 }}>No {filter} recordings</div>
|
)}
|
||||||
{filter === 'upcoming' && recorders.length === 0 && (
|
|
||||||
<div style={{ fontSize: 12, marginTop: 6 }}>Create a recorder first, then schedule it here.</div>
|
{schedules !== null && recorders.length > 0 && view === 'today' && (
|
||||||
)}
|
<div className="epg" ref={canvasRef}>
|
||||||
{filter === 'upcoming' && recorders.length > 0 && (
|
<div className="epg-corner">
|
||||||
<div style={{ fontSize: 12, marginTop: 6 }}>Click <em>New schedule</em> to plan a future recording.</div>
|
<span className="mono">{_fmtDay(day)}</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="epg-gutter">
|
||||||
{view === 'list' && schedules !== null && schedules.length > 0 && (
|
<_RecorderGutter recorders={recorders} projects={projects} />
|
||||||
<div className="panel">
|
</div>
|
||||||
<div className="schedule-row head">
|
<div className="epg-canvas-head">
|
||||||
<div>Name</div>
|
<_EpgRuler pph={pph} />
|
||||||
<div>Recorder</div>
|
</div>
|
||||||
<div>Starts</div>
|
<div className="epg-canvas" style={{ width: 24 * pph }}>
|
||||||
<div>Duration</div>
|
<div className="epg-rows">
|
||||||
<div>Recurrence</div>
|
{recorders.map(r => (
|
||||||
<div>Status</div>
|
<_EpgRow key={r.id}
|
||||||
<div></div>
|
recorder={r}
|
||||||
|
schedules={schedules}
|
||||||
|
dayStart={dayStart}
|
||||||
|
dayEnd={dayEnd}
|
||||||
|
pph={pph}
|
||||||
|
now={now}
|
||||||
|
projects={projects}
|
||||||
|
onEventClick={(s) => setEditing(s)}
|
||||||
|
onEmptyClick={openNewAt} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{schedules.map(s => {
|
<_NowLine now={now} dayStart={dayStart} pph={pph} />
|
||||||
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
|
|
||||||
return (
|
|
||||||
<div key={s.id} className="schedule-row">
|
|
||||||
<div style={{ fontWeight: 500, fontSize: 13 }}>
|
|
||||||
{s.name}
|
|
||||||
{s.error_message && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
|
|
||||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
|
|
||||||
<div><span className={`badge ${badge.cls}`}>{badge.label}</span></div>
|
|
||||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
|
||||||
{s.status === 'pending' && (
|
|
||||||
<button className="btn ghost sm" onClick={() => setEditing(s)} title="Edit name / time / recurrence">Edit</button>
|
|
||||||
)}
|
|
||||||
{(s.status === 'pending' || s.status === 'running') && (
|
|
||||||
<button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
|
|
||||||
)}
|
|
||||||
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
|
|
||||||
<button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{schedules !== null && recorders.length > 0 && view === 'week' && (
|
||||||
|
<div className="epg-week">
|
||||||
|
{weekDays.map(d => {
|
||||||
|
const dEnd = _dayEnd(d);
|
||||||
|
const isToday = _sameDay(d, new Date());
|
||||||
|
return (
|
||||||
|
<div key={d.toISOString()} className={'epg-week-day' + (isToday ? ' today' : '')}>
|
||||||
|
<div className="epg-week-dayhead">
|
||||||
|
<span className="epg-week-dayname">{_fmtDay(d)}</span>
|
||||||
|
{isToday && <span className="epg-week-todaypip">today</span>}
|
||||||
|
</div>
|
||||||
|
<div className="epg-week-row-wrap" style={{ width: 24 * pph }}>
|
||||||
|
<_EpgRuler pph={pph} />
|
||||||
|
<div className="epg-rows">
|
||||||
|
{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} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isToday && <_NowLine now={now} dayStart={d} pph={pph} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{schedules !== null && recorders.length > 0 && view === 'list' && (
|
||||||
|
<div className="epg-list">
|
||||||
|
<div className="tab-group" style={{ marginBottom: 12 }}>
|
||||||
|
<button className={listFilter === 'upcoming' ? 'active' : ''} onClick={() => setListFilter('upcoming')}>Upcoming</button>
|
||||||
|
<button className={listFilter === 'past' ? 'active' : ''} onClick={() => setListFilter('past')}>Past</button>
|
||||||
|
<button className={listFilter === 'all' ? 'active' : ''} onClick={() => setListFilter('all')}>All</button>
|
||||||
|
</div>
|
||||||
|
{(listSchedules || []).length === 0 ? (
|
||||||
|
<div className="epg-empty"><div className="epg-empty-title">No {listFilter} schedules</div></div>
|
||||||
|
) : (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="schedule-row head">
|
||||||
|
<div>Name</div><div>Recorder</div><div>Starts</div><div>Duration</div><div>Recurrence</div><div>Status</div><div></div>
|
||||||
|
</div>
|
||||||
|
{listSchedules.map(s => {
|
||||||
|
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="schedule-row">
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>
|
||||||
|
{s.name}
|
||||||
|
{s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
|
||||||
|
<div><span className={'badge ' + badge.cls}>{badge.label}</span></div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||||
|
{s.status === 'pending' && <button className="btn ghost sm" onClick={() => setEditing(s)}>Edit</button>}
|
||||||
|
{(s.status === 'pending' || s.status === 'running') && <button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>}
|
||||||
|
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') &&
|
||||||
|
<button className="btn ghost sm" onClick={() => remove(s)}>Delete</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNew && <NewScheduleModal
|
{showNew && <NewScheduleModal
|
||||||
recorders={recorders}
|
recorders={recorders}
|
||||||
|
defaultRecorderId={newDefaults?.recorder_id}
|
||||||
defaultStart={newDefaults?.start}
|
defaultStart={newDefaults?.start}
|
||||||
defaultEnd={newDefaults?.end}
|
defaultEnd={newDefaults?.end}
|
||||||
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
onClose={() => { setShowNew(false); setNewDefaults(null); }}
|
||||||
|
|
@ -947,6 +1180,7 @@ function Schedule({ navigate }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function EditScheduleModal({ schedule, onClose, onSaved }) {
|
function EditScheduleModal({ schedule, onClose, onSaved }) {
|
||||||
const toLocalInput = (iso) => {
|
const toLocalInput = (iso) => {
|
||||||
const d = new Date(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
|
// 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.
|
// to "start in 5 minutes, run for 30 min" so the modal is immediately usable.
|
||||||
const toLocalInput = (d) => {
|
const toLocalInput = (d) => {
|
||||||
|
|
@ -1050,7 +1284,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
|
||||||
|
|
||||||
const [form, setForm] = React.useState({
|
const [form, setForm] = React.useState({
|
||||||
name: '',
|
name: '',
|
||||||
recorder_id: recorders[0]?.id || '',
|
recorder_id: defaultRecorderId || recorders[0]?.id || '',
|
||||||
start_at: toLocalInput(startDefault),
|
start_at: toLocalInput(startDefault),
|
||||||
end_at: toLocalInput(endDefault),
|
end_at: toLocalInput(endDefault),
|
||||||
recurrence: 'none',
|
recurrence: 'none',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue