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);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="cal">
|
||||
<div className="cal-weekheads">
|
||||
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(w => <div key={w}>{w}</div>)}
|
||||
</div>
|
||||
<div className="cal-grid">
|
||||
{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 (
|
||||
<div key={d.toISOString()}
|
||||
className={'cal-cell' + (inMonth ? '' : ' off-month') + (isToday ? ' today' : '')}
|
||||
onClick={() => onDayClick(d)}
|
||||
title="Click to schedule on this day">
|
||||
<div className="cal-cell-head">
|
||||
<span className="cal-daynum">{d.getDate()}</span>
|
||||
{isToday && <span className="cal-today-pip">today</span>}
|
||||
</div>
|
||||
<div className="cal-events">
|
||||
{visible.map(s => {
|
||||
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
|
||||
return (
|
||||
<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 className="epg-status">
|
||||
<div className="epg-status-row">
|
||||
{active.length === 0 ? (
|
||||
<>
|
||||
<span className="epg-status-dot idle" />
|
||||
<span className="epg-status-label">Nothing scheduled right now</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="epg-status-dot live" />
|
||||
<span className="epg-status-label">On air</span>
|
||||
<div className="epg-status-active">
|
||||
{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 (
|
||||
<span key={s.id} className="epg-status-pill">
|
||||
{color && <span className="epg-status-pill-bar" style={{ background: color }} />}
|
||||
<span className="epg-status-pill-name">{s.name}</span>
|
||||
<span className="epg-status-pill-rec mono">{rec?.name || s.recorder_id.slice(0, 8)}</span>
|
||||
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="epg-ruler" style={{ width: 24 * pph }}>
|
||||
{hours.map(h => (
|
||||
<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
|
||||
// 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 (
|
||||
<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(() => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Schedule</h1>
|
||||
<span className="subtitle">
|
||||
{view === 'calendar'
|
||||
? 'Click a day to schedule · ' + (schedules?.length || 0) + ' total'
|
||||
: 'Plan recorder windows · ' + (schedules?.length || 0) + ' ' + filter}
|
||||
</span>
|
||||
<div className="spacer" />
|
||||
<div className="tab-group" style={{ marginRight: 8 }}>
|
||||
<button className={view === 'calendar' ? 'active' : ''} onClick={() => setView('calendar')} title="Month calendar"><Icon name="jobs" />Calendar</button>
|
||||
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="Linear list"><Icon name="list" />List</button>
|
||||
<div className="epg-page" style={{ '--epg-pph': pph + 'px' }}>
|
||||
<_StatusStrip schedules={schedules || []} recorders={recorders} now={now} projects={projects} />
|
||||
|
||||
<div className="epg-toolbar">
|
||||
<div className="epg-date">
|
||||
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
|
||||
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
|
||||
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
|
||||
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<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>
|
||||
{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 primary" onClick={openNewBlank} disabled={recorders.length === 0}>
|
||||
<Icon name="plus" />New schedule
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
{view === 'calendar' && (
|
||||
<>
|
||||
<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)} />}
|
||||
</>
|
||||
)}
|
||||
{schedules === null && (
|
||||
<div className="epg-empty">Loading…</div>
|
||||
)}
|
||||
|
||||
{view === 'list' && schedules === null && (
|
||||
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
|
||||
)}
|
||||
{view === 'list' && schedules !== null && schedules.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 12 }}>📅</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>
|
||||
)}
|
||||
{filter === 'upcoming' && recorders.length > 0 && (
|
||||
<div style={{ fontSize: 12, marginTop: 6 }}>Click <em>New schedule</em> to plan a future recording.</div>
|
||||
)}
|
||||
{schedules !== null && recorders.length === 0 && (
|
||||
<div className="epg-empty">
|
||||
<div className="epg-empty-title">No recorders configured</div>
|
||||
<div className="epg-empty-sub">Create a recorder before scheduling.</div>
|
||||
<button className="btn primary sm" onClick={() => navigate('recorders')}>Go to Recorders</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schedules !== null && recorders.length > 0 && view === 'today' && (
|
||||
<div className="epg" ref={canvasRef}>
|
||||
<div className="epg-corner">
|
||||
<span className="mono">{_fmtDay(day)}</span>
|
||||
</div>
|
||||
)}
|
||||
{view === 'list' && schedules !== null && schedules.length > 0 && (
|
||||
<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 className="epg-gutter">
|
||||
<_RecorderGutter recorders={recorders} projects={projects} />
|
||||
</div>
|
||||
<div className="epg-canvas-head">
|
||||
<_EpgRuler pph={pph} />
|
||||
</div>
|
||||
<div className="epg-canvas" style={{ width: 24 * pph }}>
|
||||
<div className="epg-rows">
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
{schedules.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)} 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>
|
||||
);
|
||||
})}
|
||||
<_NowLine now={now} dayStart={dayStart} pph={pph} />
|
||||
</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
|
||||
recorders={recorders}
|
||||
defaultRecorderId={newDefaults?.recorder_id}
|
||||
defaultStart={newDefaults?.start}
|
||||
defaultEnd={newDefaults?.end}
|
||||
onClose={() => { 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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue