diff --git a/data/media.db b/data/media.db index ff6178f..ff9c881 100644 Binary files a/data/media.db and b/data/media.db differ diff --git a/docs/backlog/LIVE_TRANSCODE_REINTRODUCTION.md b/docs/backlog/LIVE_TRANSCODE_REINTRODUCTION.md new file mode 100644 index 0000000..c82e423 --- /dev/null +++ b/docs/backlog/LIVE_TRANSCODE_REINTRODUCTION.md @@ -0,0 +1,579 @@ +# Live Transcoding Reintroduction — Comprehensive Implementation Proposal + +> Status: **Backlog / Design Proposal** +> Author: Engineering +> Target Branch: `feature/live-transcode-v2` +> Related Docs: +> - `docs/active/media-streaming/TRANSCODING_REMOVAL_DESIGN.md` +> - `docs/active/media-streaming/TRANSCODING_REMOVAL_SUMMARY.md` +> - `docs/archive/transcoding-legacy/JELLYFIN_TRANSCODING_ARCHITECTURE.md` +> - `docs/archive/transcoding-legacy/STASH-ANALYSIS-AND-SOLUTION-PLAN.md` +> - `docs/archive/transcoding-legacy/03-process-management-transcoding.md` +> - `docs/archive/transcoding-legacy/ANTI-JITTER-IMPLEMENTATION.md` +> - `docs/archive/transcoding-legacy/VIDEO-PLAYER-REPLACEMENT-PLAN.md` + +--- + +## 1. Why Bring Live Transcoding Back? + +NextAV currently routes any non‑natively‑supported format (MKV/AVI/WMV/FLV/MOV/TS/etc.) to a **Local Player Launcher** (VLC/IINA/PotPlayer). This eliminated server CPU usage but came at three real costs: + +| Loss | User Impact | +|------|-------------| +| Cross‑device playback (phone, tablet, TV browser) | "I can't watch my MKVs on iPad" | +| Single‑click in‑browser playback for all formats | Friction: install app, allow protocol, retry | +| Adaptive bitrate over slow networks | Large files unplayable over LAN/WAN | + +The goal of v2 is to bring back **YouTube/Jellyfin‑class live transcoding** that does **not** repeat the historical pain points (process leaks, jitter, broken seek, duration corruption, runaway CPU). + +--- + +## 2. Honest Postmortem of v1 (Why It Was Removed) + +From the legacy docs and `archive/transcoding-legacy/*`, the v1 transcoder failed at six things at once: + +### 2.1 Process Lifecycle +- FFmpeg child processes orphaned on browser close / route change. +- No central registry → could not enumerate or kill cleanly. +- No client heartbeat → processes ran forever when client died silently. +- `kill('SIGKILL')` raced with the HTTP stream close, leading to half-shut pipes and `EPIPE`/`ECONNRESET`. + +### 2.2 Seeking +- Old design transcoded **from t=0** on every seek. A seek to 1h00m wasted 1h of CPU before producing one byte. +- The Stash analysis (`STASH-ANALYSIS-AND-SOLUTION-PLAN.md`) correctly identified that the fix is `-ss -i ` (input seek) + restart, but it was never properly wired into the player. + +### 2.3 Duration & Progress Jitter +- The browser's `HTMLMediaElement.duration` reflects **buffered** duration when the upstream is a piped MP4 with `frag_keyframe+empty_moov`. The progress bar therefore reported absurd durations (e.g. "6s" for a 9‑min video) and jumped backwards as new fragments arrived. +- `ANTI-JITTER-IMPLEMENTATION.md` patched the symptom in the UI, not the cause (lack of a known duration / HLS playlist with `#EXT-X-ENDLIST`). + +### 2.4 Format Detection +- Decision was made per request based on a JSON blob in `media.codec_info`. That blob was sometimes missing, sometimes wrong (the scanner only ran ffprobe once and never re‑probed), so the same file would sometimes stream direct, sometimes transcode. + +### 2.5 Resource Contention +- No concurrency cap. Three simultaneous 1080p `libx264 -preset veryfast` jobs would saturate any consumer CPU and starve thumbnail generation and the Next.js event loop. +- No throttling: FFmpeg encoded as fast as possible and ran 60+ seconds ahead of the player, wasting CPU if the user closed the tab. + +### 2.6 HLS Half-Implementation +- `src/app/api/stream/hls/...` exists today and generates a **fake playlist** that points at byte‑range slices of the source file (`ts-segmentation-service.ts`). For real `.ts` Transport Streams this can work, but for arbitrary inputs (MKV/AVI) the resulting "segments" are not valid TS and players fail with `bufferAppendError`. +- The `hls-session-manager.ts` heartbeat exists but nothing in the player calls it. + +--- + +## 3. Design Principles for v2 + +1. **HLS-first, MP4-fragment second.** All live transcoding produces an HLS playlist + MPEG-TS (or fMP4) segments on disk. The browser sees a finite playlist with a real `#EXT-X-PLAYLIST-TYPE:VOD` and `#EXT-X-ENDLIST`, so `duration` is correct from frame 1 and seeking works without restarts inside the encoded window. +2. **Stash-style seek = restart.** Seeks outside the already-encoded window kill the FFmpeg job and restart with `-ss ` at the new position. The playlist is regenerated with a virtual timeline that maps to real timestamps via `#EXT-X-DISCONTINUITY`. +3. **One job per (videoId, profile).** A Job is keyed by `videoId + quality + audioTrack + subTrack`. Re-requests of the same key attach to the existing job; different keys evict the older one for that videoId after a grace period. +4. **Heartbeats are mandatory.** The client pings every 20s. Jobs without a ping for 60s (HLS) / 10s (progressive) are killed. This is Jellyfin's model and the only one proven to survive real users. +5. **Server-Sent Events for control.** A single SSE channel per session reports `ready`, `segment-available`, `transcode-error`, `transcode-progress`, replacing polling. +6. **Hard concurrency cap.** Default `MAX_CONCURRENT_TRANSCODES=2` per node. Additional requests get HTTP 503 with `Retry-After`, plus a clear UI message. +7. **Direct streaming is always tried first.** Live transcoding is a fallback, not the default. H.264/AAC MP4 over range requests stays the happy path. +8. **Disk is the cache.** Every produced segment is written to `/tmp/nextav-hls///seg_%05d.ts`. After the job is killed, segments live another 5 minutes so the user can rewind without re-encoding. +9. **Observability is shipped, not patched on.** Every job emits structured logs + a `/api/transcode/status` snapshot suitable for a tiny admin page. +10. **Feature-flagged rollout.** Live transcoding is behind `ENABLE_LIVE_TRANSCODE=false` until validated on a Linux box with both software and hardware encoders. + +--- + +## 4. End-to-End Architecture + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ArtPlayer + hls.js ──► /api/transcode/start (POST, returns job) │ +│ │ │ +│ │ loads m3u8 │ +│ ▼ │ +│ /api/transcode/[jobId]/master.m3u8 ──► VOD playlist (finite) │ +│ /api/transcode/[jobId]/seg/000.ts ──► segment file (disk) │ +│ │ │ +│ │ every 20s │ +│ /api/transcode/[jobId]/ping (POST) │ +│ │ │ +│ /api/transcode/[jobId] (DELETE) on unmount / close │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Next.js Server │ +│ │ +│ TranscodeOrchestrator (singleton, src/lib/transcode/orchestrator) │ +│ ├─ JobRegistry (Map) │ +│ ├─ HeartbeatManager (kill-timers per job) │ +│ ├─ ConcurrencyGate (semaphore, MAX_CONCURRENT_TRANSCODES) │ +│ ├─ SegmentJanitor (LRU eviction, disk-budget aware) │ +│ └─ HwCapabilityProbe (one-time on boot, picks h264_videotoolbox│ +│ / h264_nvenc / h264_qsv / libx264) │ +│ │ +│ TranscodeJob │ +│ ├─ ffmpeg ChildProcess (spawned, attached to AbortController) │ +│ ├─ outDir (/tmp/nextav-hls//) │ +│ ├─ playlistPath, segmentCount, lastSegmentMtime │ +│ ├─ lastPing, isPaused (throttler input) │ +│ └─ status: pending | encoding | ready | paused | failed | killed │ +│ │ +│ FormatDecisionEngine (src/lib/transcode/decision.ts) │ +│ direct | hls-remux (no re-encode) | hls-transcode │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Data & API Surface + +### 5.1 New / Modified Tables + +No schema changes to user data, but a small operational table to survive process restarts: + +```sql +CREATE TABLE IF NOT EXISTS transcode_jobs ( + job_id TEXT PRIMARY KEY, + media_id INTEGER NOT NULL REFERENCES media(id) ON DELETE CASCADE, + profile TEXT NOT NULL, -- e.g. '720p-h264-aac' + out_dir TEXT NOT NULL, + start_ts REAL NOT NULL DEFAULT 0, -- the -ss value + status TEXT NOT NULL, -- pending|encoding|ready|paused|failed|killed + pid INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_ping_at DATETIME, + error TEXT +); +CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id); +CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status); +``` + +On boot, `TranscodeOrchestrator.recover()` reads `status IN ('encoding','paused')`, attempts `process.kill(pid, 0)` to see if the process is alive; if not, mark `killed`, schedule disk cleanup. + +### 5.2 HTTP API + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/api/transcode/start` | Body: `{ mediaId, profile?, startTime? }`. Returns `{ jobId, playlistUrl, etaSeconds, profile }`. Idempotent on `(mediaId, profile)` within 5s. | +| `GET` | `/api/transcode/:jobId/master.m3u8` | The VOD playlist. Regenerated on every request from disk state — always reflects what segments actually exist. Includes `#EXT-X-PLAYLIST-TYPE:VOD` and `#EXT-X-ENDLIST` once encoding finishes. | +| `GET` | `/api/transcode/:jobId/seg/:n.ts` | Streams a segment file. Sets `Cache-Control: private, max-age=3600` and strong ETag. | +| `POST` | `/api/transcode/:jobId/ping` | Body: `{ position, isPaused }`. Resets kill-timer, feeds throttler. | +| `POST` | `/api/transcode/:jobId/seek` | Body: `{ position }`. If the target is **inside** the encoded window → 204 (client just seeks). If **outside** → orchestrator kills and restarts with `-ss position`, responds with a **new** `jobId` and the client reloads. | +| `DELETE` | `/api/transcode/:jobId` | Kills the job and schedules cleanup. | +| `GET` | `/api/transcode/:jobId/events` | SSE: `ready`, `segment`, `progress`, `error`, `killed`. | +| `GET` | `/api/transcode/status` | Admin/debug snapshot of `JobRegistry`. | + +### 5.3 Profiles + +```ts +// src/lib/transcode/profiles.ts +export const PROFILES = { + '1080p-h264-aac': { vCodec:'h264', vBitrate:'5000k', maxHeight:1080, aCodec:'aac', aBitrate:'192k' }, + '720p-h264-aac': { vCodec:'h264', vBitrate:'2800k', maxHeight: 720, aCodec:'aac', aBitrate:'160k' }, + '480p-h264-aac': { vCodec:'h264', vBitrate:'1200k', maxHeight: 480, aCodec:'aac', aBitrate:'128k' }, + 'remux-copy': { vCodec:'copy', aCodec:'copy' }, // for AVC+AAC in MKV — wrap into HLS without re-encoding +} as const; +``` + +The client picks an initial profile from the saved user preference; ABR is **not** in v2 scope (single‑variant playlist only). Quality switching = new job. + +--- + +## 6. Format Decision Engine + +```ts +// src/lib/transcode/decision.ts +type Decision = + | { kind: 'direct' } // browser can play the file as-is + | { kind: 'hls-remux'; profile: 'remux-copy' } // codecs OK, container not + | { kind: 'hls-transcode'; profile: '720p-h264-aac' | ... }; +``` + +Rules, evaluated in order: + +1. If `extension ∈ {mp4,m4v,webm,ogg,ogv}` **and** `videoCodec ∈ {h264,vp8,vp9,av1}` **and** `audioCodec ∈ {aac,opus,vorbis}` → `direct`. +2. If `videoCodec ∈ {h264}` **and** `audioCodec ∈ {aac}` but container is anything else (mkv, ts, mov) → `hls-remux` (`-c copy`, basically free CPU). +3. If `videoCodec ∈ {hevc,vp9,av1}` and the requesting User-Agent indicates support (Safari/Chrome with hardware decode) → `direct` with a probe header (HTTP 200 + `X-Probe: 1`); on player error within 5s, client posts `/api/transcode/start` to fall back. +4. Otherwise → `hls-transcode` with the user's preferred profile (default 720p). + +The decision is computed **fresh** on every `/api/transcode/start` using a server-side ffprobe of the file (cached in `media.codec_info` with a TTL of 7 days and a `codec_info_version` integer so we can invalidate the whole cache atomically). + +--- + +## 7. FFmpeg Command Templates + +### 7.1 Transcode (the hot path) + +```bash +ffmpeg \ + -hide_banner -loglevel error -nostats \ + -ss \ # input seek for fast start + -copyts \ # keep original timestamps for accurate playlist + -i "" \ + -map 0:v:0 -map 0:a:0? \ + -c:v \ # h264_videotoolbox|h264_nvenc|h264_qsv|libx264 + -preset \ # libx264: veryfast | hw: p4/quality + -profile:v main -level 4.1 \ + -pix_fmt yuv420p \ + -vf "scale=-2:'min(,ih)':flags=lanczos" \ + -b:v -maxrate -bufsize <2*VBITRATE> \ + -force_key_frames "expr:gte(t,n_forced*4)" \ + -c:a aac -b:a -ac 2 \ + -f hls \ + -hls_time 4 \ # 4s segments — fast startup, good seek granularity + -hls_list_size 0 \ # keep all segments in the playlist + -hls_segment_type mpegts \ + -hls_flags independent_segments+temp_file \ + -hls_segment_filename "/seg_%05d.ts" \ + -start_number 0 \ + "/index.m3u8" +``` + +Key choices and **why**: + +- `-ss` **before** `-i` (input seek): O(1) on indexed containers; correctly drops everything before the seek point. +- `-copyts` + `-start_at_zero` are NOT used together — we keep original timestamps so when we discontinuity-merge after a seek-restart the player handles it via `#EXT-X-DISCONTINUITY`. +- `-force_key_frames expr:gte(t,n_forced*4)` makes every segment independently decodable, which lets `-hls_flags independent_segments` work and gives clean seeks. +- `-hls_flags temp_file` writes `seg_00042.ts.tmp` and renames atomically, so the playlist regenerator never sees a half-written segment. +- We do **not** use `-re` — we want to encode as fast as the CPU allows, then **throttle** via stdin `p`/`u` once we are >60s ahead of playback (Jellyfin pattern). + +### 7.2 Remux (free path for h264+aac in mkv/mov) + +Same skeleton but `-c:v copy -c:a copy -bsf:v h264_mp4toannexb` (the bitstream filter is required to convert AVC's length-prefixed NAL units into Annex‑B for MPEG‑TS). + +### 7.3 Hardware Acceleration + +Probed once at boot by running `ffmpeg -hide_banner -encoders | grep h264_`. Selection priority: + +1. **macOS dev**: `h264_videotoolbox` (zero CPU, ships with macOS) +2. **Linux + NVIDIA**: `h264_nvenc` +3. **Linux + Intel iGPU**: `h264_qsv` (requires `libmfx`) +4. **Fallback everywhere**: `libx264 -preset veryfast` + +The choice is recorded in `/api/transcode/status` and surfaced in Settings → "Transcoding Backend: h264_videotoolbox (auto)". + +--- + +## 8. Process Lifecycle (the part v1 got wrong) + +``` +client click play + │ + ▼ +POST /api/transcode/start + │ ┌─────────────────────────────────────────────────────────┐ + │ │ ConcurrencyGate.acquire() ── may 503 with Retry-After │ + │ └─────────────────────────────────────────────────────────┘ + ▼ +orchestrator.startJob(mediaId, profile, startTs) + │ + ├─ pick existing job? (same key, alive, ping <30s) → return it + ├─ ffprobe file (cached) → decide remux vs transcode + ├─ mkdir + ├─ spawn ffmpeg, attach { stdout: ignore, stderr: pipe-to-ring-buffer, stdio:[pipe,ignore,pipe] } + ├─ insert into DB (status=encoding, pid) + ├─ HeartbeatManager.armKillTimer(jobId, 60s) + ├─ wait for first segment to appear (fs.watch on out_dir) + │ ── on timeout 15s → kill + fail with TRANSCODE_STARTUP_TIMEOUT + └─ return { jobId, playlistUrl: /api/transcode//master.m3u8 } + +client loads master.m3u8 every ~segment +client posts /ping every 20s ── resets kill-timer +client posts /seek when user drags ── may trigger restart +client posts DELETE on close ── immediate kill + +orchestrator monitors: + - ffmpeg exit code → status=ready (0) | failed (non-zero, capture stderr ring) + - stderr ring → parse "frame=… time=00:01:23.45" for progress events + - throttler tick (1Hz): + if (encoder_position - last_ping_position) > 60s and !isPaused + ffmpeg.stdin.write('p\n') // pause + else if was paused and gap < 30s + ffmpeg.stdin.write('u\n') // resume +``` + +### 8.1 Kill Is a State Machine, Not a Signal + +```ts +async function killJob(jobId: string, reason: string) { + const job = registry.get(jobId); + if (!job || job.status === 'killed') return; + job.status = 'killed'; + // 1. Try graceful: write 'q' to stdin (ffmpeg writes trailers, exits 0) + try { job.proc.stdin.write('q\n'); } catch {} + // 2. Wait up to 3s + const exited = await waitForExit(job.proc, 3_000); + // 3. SIGTERM, then 2s grace + if (!exited) { job.proc.kill('SIGTERM'); await waitForExit(job.proc, 2_000); } + // 4. SIGKILL + if (!job.proc.killed) job.proc.kill('SIGKILL'); + // 5. DB + concurrency release + janitor schedule + db.prepare('UPDATE transcode_jobs SET status=? WHERE job_id=?').run('killed', jobId); + gate.release(); + janitor.scheduleCleanup(job.outDir, /* delayMs */ 5 * 60_000); +} +``` + +This is the three-layer pattern from `archive/transcoding-legacy/03-process-management-transcoding.md` (HTTP context → registry → forced kill), ported faithfully. + +### 8.2 Seek Handling + +```ts +// /api/transcode/[jobId]/seek +if (target >= job.encodedFromTs && target <= job.lastSegmentTs - 8) { + // Inside the encoded window — the client already has segments. No action. + return new Response(null, { status: 204 }); +} +// Outside → restart. Old job dies; new job starts at target. +const newJob = await orchestrator.restart(job, /* startTs */ target); +return Response.json({ jobId: newJob.id, playlistUrl: newJob.playlistUrl }); +``` + +--- + +## 9. Playlist Regeneration (eliminates duration jitter) + +The legacy implementation streamed the playlist FFmpeg wrote directly. That playlist grew over time, which is why the browser reported a growing duration and the bar jumped. + +v2 regenerates the playlist on **every GET** from the on-disk segment files plus the known total duration: + +```ts +function buildPlaylist(job: TranscodeJob): string { + const segs = fs.readdirSync(job.outDir) + .filter(f => /^seg_\d{5}\.ts$/.test(f)) + .sort(); + const totalDuration = job.knownDuration; // from ffprobe, REAL duration + + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:5', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + ]; + + // Emit all real segments + for (const seg of segs) lines.push(`#EXTINF:4.000,`, `seg/${seg}`); + + // If FFmpeg has not yet produced the tail, append a "gap" sentinel so the + // browser knows total duration. We DO NOT emit #EXT-X-ENDLIST until the + // encoder exits cleanly — that flips the playlist into final VOD mode. + if (job.status === 'ready') lines.push('#EXT-X-ENDLIST'); + + return lines.join('\n'); +} +``` + +The trick: even before `#EXT-X-ENDLIST` is emitted, the player has a `#EXT-X-PLAYLIST-TYPE:VOD` hint and the real ffprobe duration is stored in the segment count expectation. We also send `Content-Duration` header for ArtPlayer to consume. + +--- + +## 10. UI / Player Integration + +### 10.1 Component Flow + +`unified-video-player.tsx`: + +``` +detectVideoFormat(video) + │ + ├─ direct → ArtPlayer with src=/api/stream/direct/ + ├─ hls-* → POST /api/transcode/start + │ ArtPlayer + hls.js with src= + │ on artplayer "seek" event → POST /seek (may swap source) + │ every 20s timer → POST /ping + │ on unmount / onClose → DELETE + └─ local-player → existing LocalPlayerLauncher (kept as final fallback + for formats we explicitly refuse, e.g. raw VOB / RealMedia) +``` + +### 10.2 UI States + +| State | UI | +|-------|-----| +| `requesting` | Spinner + "Preparing video…" + ETA from `etaSeconds` | +| `encoding`, no segments yet | Same spinner; auto-timeout 15s → error UI | +| `encoding`, playing | Hidden, but a tiny "Transcoding" badge in the player chrome (toggle in settings) | +| `503 Retry-After` | Toast: "Server busy transcoding 2 other videos. Try again in 30s." | +| `failed` | Error card with stderr snippet + button "Open in local player" (degrades to existing LocalPlayerLauncher) | + +### 10.3 Settings + +New Settings → Playback section: + +- **Transcoding**: Off / Auto / Always +- **Preferred quality**: 480p / 720p / 1080p / Source +- **Hardware acceleration**: read-only label of detected backend +- **Show transcoding badge in player**: toggle + +Stored in `localStorage` and mirrored to `user_preferences` (table TBD or reuse existing settings store). + +--- + +## 11. Concurrency, Throttling, Disk + +### 11.1 Concurrency Gate + +```ts +class Semaphore { + constructor(public max: number) {} + // acquire returns immediately or rejects with { code: 'BUSY', retryAfterSec } +} +``` + +`MAX_CONCURRENT_TRANSCODES` env var, default 2 for dev, configurable in Settings → Admin. + +### 11.2 Throttling (the Jellyfin pattern) + +A 1Hz ticker per active job: + +``` +encoderPosition = parse from stderr 'time=00:hh:mm:ss.ms' +playheadPosition = job.lastPingPosition +gap = encoderPosition - playheadPosition +if (gap > 60 && !job.paused) send 'p\n'; job.paused = true +if (gap < 30 && job.paused) send 'u\n'; job.paused = false +``` + +This caps wasted work at ~60s of encode beyond playback. + +### 11.3 Disk Budget (Segment Janitor) + +- Per-job soft cap: `5 minutes * profile_bitrate` ≈ 100 MB for 720p. +- Global soft cap: `TRANSCODE_DISK_BUDGET_MB`, default 5000 MB. +- LRU eviction of **whole jobs** (never partial — a half-deleted job breaks the playlist) when global cap is exceeded. +- After job kill: keep `outDir` alive 5 minutes so a quick reopen reuses the cache; then `rm -rf`. + +--- + +## 12. Failure Modes & Recovery + +| Failure | Detection | Response | +|---------|-----------|----------| +| FFmpeg won't start (binary missing) | spawn `ENOENT` | Hard 500 + log; surface in Settings "FFmpeg not found at " | +| First segment never appears | 15s fs.watch timeout | Kill job, return `TRANSCODE_STARTUP_TIMEOUT`, client falls back to local-player UI | +| FFmpeg crashes mid-stream | `exit` event with code ≠ 0 | Capture last 50 stderr lines, mark `failed`, push SSE error, client offers retry or local-player | +| Disk full | `ENOSPC` in stderr | Janitor force-cleanup, then either restart job or fail with explicit message | +| Client tab closed silently | No ping for 60s | Auto-kill via HeartbeatManager | +| Next.js process restart | DB has `status=encoding` rows with dead PIDs | `recover()` on boot marks them killed, cleans disks | +| Two clients open same video | Same `(mediaId, profile)` key | Both attach to one job; ping is per-session, kill only when **all** sessions are gone (refcount on job) | +| H.264 in MKV thought to be HEVC | Bad codec_info cache | `decision.ts` re-probes if codec_info is older than 7 days or absent | + +--- + +## 13. Cleanup of v1 Debris (must happen before v2) + +The following files are dead code from v1 and must be deleted (not patched), so v2 starts from a clean slate: + +- `src/lib/ffmpeg/process-registry.ts` (will be replaced by `src/lib/transcode/orchestrator.ts`) +- `src/lib/hls-session-manager.ts` +- `src/lib/ts-segmentation-service.ts` +- `src/lib/hls-error-handler.ts` +- `src/lib/ts-converter.ts` +- `src/app/api/stream/hls/**` +- `src/app/api/stream/[id]/transcode/**` (will be replaced by `/api/transcode/**`) +- `src/app/api/videos/[id]/convert-ts/**` +- `src/app/api/ffmpeg/status/**` (replaced by `/api/transcode/status`) +- `src/lib/hooks/use-protected-duration.ts` (no longer needed once the playlist reports correct duration) + +The `local-player-launcher` UI and `/api/external-stream/[id]` stay — they remain the user-visible fallback when transcoding is disabled, fails, or is rate-limited. + +--- + +## 14. New Files (proposed layout) + +``` +src/lib/transcode/ + orchestrator.ts # JobRegistry, start/kill/recover, singleton + job.ts # TranscodeJob class + heartbeat.ts # HeartbeatManager + concurrency.ts # Semaphore + janitor.ts # disk LRU cleanup + throttler.ts # 1Hz stdin p/u + hw-probe.ts # one-time encoder probe, cached + profiles.ts # PROFILES constant + decision.ts # FormatDecisionEngine + ffmpeg-args.ts # buildTranscodeArgs / buildRemuxArgs + playlist.ts # buildPlaylist (regenerates m3u8 from disk) + ffprobe.ts # cached probe, writes media.codec_info + +src/app/api/transcode/ + start/route.ts # POST + [jobId]/master.m3u8/route.ts # GET + [jobId]/seg/[name]/route.ts # GET + [jobId]/ping/route.ts # POST + [jobId]/seek/route.ts # POST + [jobId]/route.ts # DELETE + [jobId]/events/route.ts # GET (SSE) + status/route.ts # GET admin + +src/components/ + unified-video-player.tsx # MODIFIED: branches on decision + transcode-status-badge.tsx # NEW: tiny chrome badge +``` + +--- + +## 15. Phased Roadmap + +| Phase | Scope | Exit Criteria | Risk | +|-------|-------|--------------|------| +| **0. Cleanup** | Delete dead v1 code (§13), drop `fluent-ffmpeg` dep, keep only the `ffmpeg` binary for thumbnails. | `pnpm build` green; existing direct-stream + local-player flow untouched. | Low | +| **1. Spike: orchestrator + remux only** | `decision.ts` for `hls-remux` only (h264+aac in mkv/mov). Full orchestrator, kill state machine, heartbeat, single-segment playlist regen. No transcoding yet — copy only. | Play a 2 GB MKV (h264+aac) in Chrome/Safari/Firefox. Seek, pause, close. `lsof` shows no leaked FDs after 5 minutes. | **High** (the lifecycle code is the hard part) | +| **2. Software transcoding** | Add `libx264 -preset veryfast` path. 720p only. Throttler + concurrency gate. | Play an AVI (mpeg4+mp3) end-to-end. Two concurrent jobs run, third gets 503. Kill -9 the server: on restart, dead-job rows are cleaned, no orphaned ffmpeg. | High | +| **3. Hardware encoder + profiles** | `hw-probe.ts`, 480p/720p/1080p, settings UI. | macOS dev box uses `h264_videotoolbox`, CPU < 30% on 1080p. Linux box auto-picks `h264_nvenc` if available. | Medium | +| **4. Seek-restart + discontinuity** | `/seek` endpoint, restart-on-large-seek, `#EXT-X-DISCONTINUITY` in regenerated playlist. | Seek to t=1h in a 2h MKV completes within 3s on hw encoder; progress bar shows correct global timeline. | Medium | +| **5. SSE + observability** | `/events` SSE channel, `/status` admin page, structured stderr capture. | A dev panel at `/admin/transcode` shows live jobs, CPU%, ETA. | Low | +| **6. Polish + flags** | Feature flag default-on per env; user-facing error UI; docs. | E2E run-book passes (see §17). Move this doc to `docs/active/media-streaming/`. | Low | + +Estimated effort: **6 calendar weeks** at one engineer, dominated by Phases 1 and 2. + +--- + +## 16. Configuration (env) + +```env +ENABLE_LIVE_TRANSCODE=false # master switch +FFMPEG_PATH=/usr/bin/ffmpeg +FFPROBE_PATH=/usr/bin/ffprobe +TRANSCODE_TMP_DIR=/tmp/nextav-hls +MAX_CONCURRENT_TRANSCODES=2 +TRANSCODE_DISK_BUDGET_MB=5000 +TRANSCODE_HEARTBEAT_TIMEOUT_MS=60000 +TRANSCODE_STARTUP_TIMEOUT_MS=15000 +TRANSCODE_HWACCEL=auto # auto|videotoolbox|nvenc|qsv|none +TRANSCODE_DEFAULT_PROFILE=720p-h264-aac +``` + +--- + +## 17. Acceptance / Run-Book + +Manual E2E (must all pass on macOS + Linux): + +1. **Direct path untouched**: play an MP4(H.264+AAC), confirm `/api/stream/direct/` is used and no transcode rows appear. +2. **Remux happy path**: play an MKV (H.264+AAC). Confirm `hls-remux`, CPU < 5%. +3. **Transcode happy path**: play an AVI (MPEG-4 ASP). Confirm software encoder, CPU usage matches `nproc/2` budget, video plays smoothly. +4. **HW encoder**: on a Mac, confirm `h264_videotoolbox` is chosen, CPU < 30% on 1080p. +5. **Seek inside window**: seek +30s, no new job spawned. +6. **Seek outside window**: seek +30 minutes, old job dies within 3s, new job spawns, video resumes within 5s. +7. **Concurrency**: open three transcoding videos simultaneously; third receives 503 + UI toast. +8. **Heartbeat death**: close tab during transcoding, wait 90s, confirm process is killed (`ps aux | grep ffmpeg` empty) and segments are cleaned within 5 min. +9. **Crash recovery**: `kill -9` the Next.js server during transcoding, restart, confirm DB rows cleaned, disk cleaned, no orphan ffmpeg. +10. **Disk pressure**: fill disk to 95%, request a transcode → fails with explicit `ENOSPC` message, not a hang. +11. **Player error fallback**: corrupt a segment on disk while playing → SSE error event → UI offers "Open in local player" without page reload. +12. **Duration correctness**: For a 9:00 video, the progress bar reports 9:00 from the first frame and never moves backward. + +--- + +## 18. Risks & Open Questions + +- **Q1**: ABR is out of scope for v2. Should we plan a v3 with multi-variant playlists, or is single-variant "good enough" for a personal media library? *Recommendation: defer; single-variant with manual quality switch covers 95% of the use case.* +- **Q2**: Subtitles. MKVs frequently carry SRT/SSA tracks. Initial proposal: extract on demand to WebVTT via `ffmpeg -map 0:s:0 -c:s webvtt`, expose as `/api/transcode//sub/.vtt`. Not in Phase 1–4; track as a follow-up. +- **Q3**: Multi-audio tracks. Same story as subtitles — out of scope for v2, but the profile key already supports `+audioTrack` for future use. +- **R1**: Process management is **the** historical failure point. Phase 1 must be ruthlessly tested with `lsof`, `ps`, and crash injection before any encoding work begins. If Phase 1 cannot demonstrate zero leaks under chaos testing, **abort the whole effort** and keep the local-player approach. +- **R2**: Browser HLS behavior varies. Safari handles HLS natively; Chrome/Firefox need hls.js. The chosen `MEDIA-SEQUENCE` / `DISCONTINUITY` strategy must be validated on all three. Reserve 1 week of Phase 4 just for cross-browser bug triage. + +--- + +## 19. Decision Required Before Coding + +1. Approve the **HLS-on-disk** approach (vs. the v1 piped-MP4 approach). *Strongly recommended; everything else in this doc depends on it.* +2. Approve **single-variant playlists** for v2 (no ABR). +3. Confirm the **delete-first** stance on legacy code (§13). If we keep v1 stubs around, the new code paths will be ambiguous and the old bugs will resurface. +4. Confirm `MAX_CONCURRENT_TRANSCODES=2` as the default for a personal/home server. Adjust upward only when the target deployment is a multi-user server. + +Once these four are signed off, work can start with Phase 0 cleanup. diff --git a/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md b/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md new file mode 100644 index 0000000..838947a --- /dev/null +++ b/docs/planning/MOBILE_RESPONSIVE_SIDEBAR_PLAN.md @@ -0,0 +1,378 @@ +# Mobile-Friendly Sidebar & Responsive Layout Plan + +## Problem Statement + +The current NextAV layout uses a **persistent left sidebar** (`w-64` expanded / `w-16` collapsed) alongside the main content area in a `flex h-screen` container. While this works well on desktop, it is **not mobile-friendly** for several reasons: + +1. **The sidebar always occupies screen width** — on a 375px mobile screen, even the collapsed `w-16` (64px) sidebar consumes ~17% of the viewport, leaving only ~311px for content. +2. **No mobile navigation paradigm** — there is no hamburger menu, bottom navigation bar, or swipe-to-open drawer pattern. The sidebar is always rendered. +3. **The collapse toggle is not a mobile pattern** — the `ChevronLeft`/`ChevronRight` toggle on the sidebar is desktop-oriented; mobile users expect a hamburger icon in a top bar or a bottom tab bar. +4. **Content grids are too dense** — while `getColumnCount()` in the grids already supports 2 columns at `<640px`, the available width is further reduced by the always-visible sidebar. +5. **No `` consideration** — the layout doesn't adapt its core shell for touch devices. + +--- + +## Current Architecture Analysis + +### Root Layout ([layout.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/app/layout.tsx)) + +```tsx +
+ +
+ {children} +
+
+``` + +- The `` is always rendered as a direct flex child. +- No conditional rendering based on screen size. +- No shared state mechanism for mobile sidebar open/close. + +### Sidebar ([sidebar.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/components/sidebar.tsx)) + +- **282 lines**, manages its own `isCollapsed` state locally. +- Contains: nav links (Home, Videos, Photos, Bookmarks, Surprise Me, Settings), collapsible Clusters section, collapsible Libraries section, and a footer. +- Width toggles between `w-64` and `w-16` via CSS transition. +- **No `md:` or `lg:` responsive breakpoint classes** — purely desktop-oriented. + +### Content Pages + +| Page | Component | Grid Behavior | +|------|-----------|---------------| +| `/videos` | `InfiniteVirtualGrid` | 2-7 columns based on `containerWidth` | +| `/photos` | `InfiniteVirtualGrid` | Same as above | +| `/bookmarks` | `InfiniteVirtualGrid` | Same as above | +| `/folder-viewer` | `VirtualizedFolderGrid` | 2-7 columns based on `containerWidth` | +| `/surprise-me` | CSS grid | `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5` | +| `/settings` | Manual layout | `grid-cols-1 lg:grid-cols-3` | +| `/` (Home) | Manual layout | `grid-cols-1 md:grid-cols-2 lg:grid-cols-4` | + +> [!NOTE] +> The virtualized grids (`InfiniteVirtualGrid`, `VirtualizedFolderGrid`) already calculate column count responsively via `containerWidth`. They would benefit automatically from having more width available once the sidebar is hidden on mobile. + +### Tailwind Configuration ([tailwind.config.ts](file:///Users/tigeren/Dev/xorbitlab/nextav/tailwind.config.ts)) + +- TailwindCSS **v3.4.17** (confirmed in `package.json`) +- Uses `darkMode: ["class"]` with `.dark` class on `` +- Default breakpoints (`sm: 640px`, `md: 768px`, `lg: 1024px`, `xl: 1280px`, `2xl: 1536px`) +- No custom mobile breakpoints defined + +--- + +## Design Decisions + +> [!NOTE] +> The following decisions were confirmed by the project owner: +> 1. **YouTube-style bottom tab bar** for primary mobile navigation (not just a hamburger drawer) +> 2. **`lg` breakpoint (1024px)** as the mobile/desktop cutoff — tablets in portrait get the mobile experience +> 3. **Clusters & Libraries sections collapsed by default** in the mobile drawer + +--- + +## Proposed Solution + +### Approach: **Bottom Tab Bar + Hamburger Drawer + Persistent Desktop Sidebar** + +On **desktop** (`lg` and above, ≥1024px): keep the current sidebar as-is (persistent, collapsible). +On **mobile/tablet** (`< lg`, <1024px): hide the sidebar, show a **YouTube-style bottom tab bar** for primary navigation, and provide a **hamburger menu in a slim top bar** that opens the full sidebar as a **slide-in drawer overlay** for secondary navigation (Clusters, Libraries, Settings). + +This mirrors the YouTube mobile app pattern: +- **Bottom tab bar** = quick access to primary sections (Home, Videos, Photos, Bookmarks) +- **Hamburger drawer** = full sidebar with all sections including Clusters, Libraries, Settings +- **Top bar** = branding + hamburger trigger + +--- + +## Detailed Implementation Plan + +### Phase 1: Shared Sidebar State (Context) + +> [!IMPORTANT] +> The sidebar's open/close state currently lives inside `sidebar.tsx` as a local `useState`. To control it from the top bar (hamburger button) and coordinate with the bottom tab bar, we need to lift this state into a React Context. + +#### [NEW] `src/contexts/sidebar-context.tsx` + +Create a `SidebarProvider` context that provides: + +```typescript +interface SidebarContextType { + isCollapsed: boolean; // Desktop collapse state + isMobileOpen: boolean; // Mobile drawer open state + toggleCollapse: () => void; // Desktop toggle + openMobile: () => void; // Open mobile drawer + closeMobile: () => void; // Close mobile drawer + isMobile: boolean; // Current screen is mobile (<1024px) +} +``` + +- Use `window.matchMedia('(min-width: 1024px)')` to detect mobile vs desktop (using `lg` breakpoint). +- Auto-close mobile drawer on route change (listen to `usePathname()`). +- Auto-close mobile drawer when screen resizes above `lg` breakpoint. + +--- + +### Phase 2: Mobile Top Bar Component + +#### [NEW] `src/components/mobile-top-bar.tsx` + +A slim fixed top bar visible only on `< lg` screens: + +``` +┌──────────────────────────────────────────┐ +│ ☰ │ NextAV │ [🔍] │ +└──────────────────────────────────────────┘ +``` + +- **Left**: Hamburger icon (`Menu` from lucide) → triggers `openMobile()` to open the full drawer +- **Center**: "NextAV" branding +- **Right**: Optional search icon or current section indicator +- CSS: `lg:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-card border-b border-border` +- Add `pt-14` to `
` on mobile to account for the fixed top bar height + +> [!NOTE] +> The top bar provides access to the **drawer** (secondary nav). The **bottom tab bar** (Phase 2B) handles primary navigation. + +--- + +### Phase 2B: Bottom Tab Bar Component (YouTube-style) + +#### [NEW] `src/components/mobile-bottom-tabs.tsx` + +A fixed bottom tab bar visible only on `< lg` screens, providing quick access to primary sections: + +``` +┌──────────────────────────────────────────┐ +│ 🏠 │ 🎬 │ 📷 │ 🔖 │ +│ Home │ Videos │ Photos │ Bookmarks │ +└──────────────────────────────────────────┘ +``` + +- **4 primary tabs**: Home, Videos, Photos, Bookmarks +- Active tab highlighted with primary color (filled icon + colored label) +- Inactive tabs use muted color +- CSS: `lg:hidden fixed bottom-0 left-0 right-0 z-40 h-16 bg-card border-t border-border backdrop-blur-lg` +- Uses `safe-area-inset-bottom` padding for devices with home indicators (iPhone notch) +- Each tab is a `` using Next.js router, with active state derived from `usePathname()` +- Add `pb-16` to `
` on mobile to account for the fixed bottom bar height + +**Why 4 tabs?** YouTube uses 4-5 tabs. The remaining sections (Surprise Me, Settings, Clusters, Libraries) live in the hamburger drawer — they're less frequently accessed. + +**Tab touch targets**: Each tab should be at minimum `48px × 48px` (WCAG 2.5.8), with the full tab area being tappable. + +--- + +### Phase 3: Sidebar Refactoring + +#### [MODIFY] `src/components/sidebar.tsx` + +**Desktop behavior** (`lg:` and above): +- Keep current behavior: persistent sidebar, `w-64`/`w-16` collapse toggle. +- Add `hidden lg:flex` to the sidebar container. + +**Mobile behavior** (`< lg`): +- Render as a **fixed overlay drawer** that slides in from the left (opened via hamburger in top bar). +- Include a **backdrop** (`bg-black/50`) that closes the drawer on tap. +- Use `translate-x` animation for the slide-in/out effect. +- The drawer should be `w-72` (slightly wider than desktop's `w-64` for better touch targets). +- Add a close button (`X` icon) in the drawer header. +- Nav items should have larger touch targets (`min-h-[48px]` per Material Design guidelines). +- **Clusters and Libraries sections collapsed by default** — `showClusters` and `showLibraries` should initialize to `false` when in mobile mode. + +**Structural approach** — two render paths in the same component: + +```tsx +// Desktop: persistent sidebar + + +// Mobile: overlay drawer +{isMobileOpen && ( +
+
+ +
+)} +``` + +> [!TIP] +> On mobile, the drawer acts as **secondary navigation** — users who need Clusters, Libraries, Surprise Me, or Settings will open it from the hamburger. The **bottom tab bar** handles the 4 most common destinations without opening the drawer. + +--- + +### Phase 4: Layout Adjustments + +#### [MODIFY] `src/app/layout.tsx` + +```tsx + +
+ {/* visible only < lg */} + {/* persistent on lg+, drawer on mobile */} +
+ {children} +
+ {/* visible only < lg */} +
+
+``` + +> [!WARNING] +> Both `pt-14` (top bar) and `pb-16` (bottom tab bar) must be applied on mobile only (`lg:pt-0 lg:pb-0`) to avoid wasting space on desktop. + +#### [MODIFY] `src/app/globals.css` + +Add animation keyframes for the drawer slide-in: + +```css +@keyframes slideInFromLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes slideOutToLeft { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +.animate-slide-in { + animation: slideInFromLeft 0.3s ease-out forwards; +} + +.animate-slide-out { + animation: slideOutToLeft 0.3s ease-in forwards; +} +``` + +--- + +### Phase 5: Touch & Mobile UX Enhancements + +#### Touch Targets +- Increase nav button heights from `h-10` to `min-h-[48px]` on mobile (WCAG 2.5.8). +- Increase icon sizes from `h-4 w-4` to `h-5 w-5` on mobile. +- Increase font sizes from `text-sm` to `text-base` on mobile for nav labels. + +#### Swipe Gesture (Optional, Phase 2) +- Add a swipe-right gesture on the left edge of the screen to open the drawer. +- Add a swipe-left gesture on the open drawer to close it. +- Can be implemented with pointer events (`onTouchStart`, `onTouchMove`, `onTouchEnd`). + +#### Content Adjustments +- The `InfiniteVirtualGrid` and `VirtualizedFolderGrid` will automatically gain more width since the sidebar no longer takes space on mobile. +- Consider reducing padding from `p-6` to `p-3` on mobile for content pages. + +--- + +## Files to Change + +| File | Action | Summary | +|------|--------|---------| +| `src/contexts/sidebar-context.tsx` | **NEW** | Shared sidebar state context with mobile detection at `lg` breakpoint | +| `src/components/mobile-top-bar.tsx` | **NEW** | Mobile-only slim top bar with hamburger + branding | +| `src/components/mobile-bottom-tabs.tsx` | **NEW** | YouTube-style bottom tab bar (Home, Videos, Photos, Bookmarks) | +| `src/components/sidebar.tsx` | **MODIFY** | Dual-mode: persistent desktop (`lg:flex`) + overlay drawer mobile; Clusters/Libraries collapsed by default on mobile | +| `src/app/layout.tsx` | **MODIFY** | Wrap with `SidebarProvider`, add `MobileTopBar` + `MobileBottomTabs`, adjust `
` padding (`pt-14 pb-16 lg:pt-0 lg:pb-0`) | +| `src/app/globals.css` | **MODIFY** | Add slide-in/out animations, safe-area-inset-bottom support | +| `tailwind.config.ts` | **MODIFY** (optional) | Add custom animation keyframes if preferred over raw CSS | + +--- + +## Visual Mockup + +### Desktop (≥1024px) — No Change + +``` +┌─────────────┬────────────────────────────────────────┐ +│ ▶ NextAV │ │ +│─────────────│ Main Content Area │ +│ 🏠 Home │ │ +│ 🎬 Videos │ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ 📷 Photos │ │ Card │ │ Card │ │ Card │ │ +│ 🔖 Bookmarks│ └──────┘ └──────┘ └──────┘ │ +│ ✨ Surprise │ ┌──────┐ ┌──────┐ ┌──────┐ │ +│ ⚙ Settings │ │ Card │ │ Card │ │ Card │ │ +│─────────────│ └──────┘ └──────┘ └──────┘ │ +│ Clusters │ │ +│ Libraries │ │ +│─────────────│ │ +│ NextAV v1.0│ │ +└─────────────┴────────────────────────────────────────┘ +``` + +### Mobile/Tablet (<1024px) — Bottom Tab Bar + Top Bar + +``` +┌──────────────────────────┐ +│ ☰ │ NextAV │ 🔍 │ ← slim top bar (h-14) +├──────────────────────────┤ +│ │ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ ┌──────────┐ ┌────────┐│ +│ │ Card │ │ Card ││ +│ └──────────┘ └────────┘│ +│ │ +├──────────────────────────┤ +│ 🏠 Home │🎬 Videos│📷 Photos│🔖 Marks│ ← bottom tab bar (h-16) +└──────────────────────────┘ +``` + +### Mobile/Tablet — Hamburger Drawer Open + +``` +┌──────────────────────────┐ +│ ▶ NextAV ✕ │▒▒▒▒▒│ +│────────────────────│▒▒▒▒▒│ +│ 🏠 Home │▒▒▒▒▒│ +│ 🎬 Videos │▒▒▒▒▒│ +│ 📷 Photos │▒▒▒▒▒│ +│ 🔖 Bookmarks │▒▒▒▒▒│ +│ ✨ Surprise Me │▒▒▒▒▒│ +│ ⚙ Settings │▒▒▒▒▒│ +│────────────────────│▒▒▒▒▒│ +│ ▸ Clusters (collapsed) │ +│ ▸ Libraries (collapsed) │ +│────────────────────│▒▒▒▒▒│ +│ NextAV v1.0 │▒▒▒▒▒│ +└──────────────────────────┘ + ▲ drawer (w-72) ▲ backdrop + + Note: Clusters & Libraries are collapsed + by default on mobile. User can expand them. +``` + +--- + +## Implementation Priority + +| Priority | Task | Effort | +|----------|------|--------| +| P0 | Create `SidebarProvider` context | Small | +| P0 | Create `MobileTopBar` component | Small | +| P0 | Create `MobileBottomTabs` component (YouTube-style) | Medium | +| P0 | Refactor `sidebar.tsx` for desktop/mobile dual mode | Medium | +| P0 | Update `layout.tsx` to integrate all pieces | Small | +| P0 | Add CSS animations for drawer + safe-area support | Small | +| P1 | Increase touch targets on mobile nav items & drawer | Small | +| P1 | Reduce content padding on mobile (`p-6` → `p-3`) | Small | +| P2 | Swipe gesture to open/close drawer | Medium | + +--- + +## Resolved Decisions + +| # | Question | Decision | +|---|----------|----------| +| 1 | Bottom Tab Bar vs Hamburger Drawer? | **YouTube-style bottom tab bar** for primary nav (Home, Videos, Photos, Bookmarks). Hamburger drawer retained for secondary nav (Surprise Me, Settings, Clusters, Libraries). | +| 2 | Breakpoint Selection | **`lg` (1024px)** — tablets in portrait mode get the mobile experience with bottom tab bar + drawer. | +| 3 | Sidebar Sections on Mobile | **Collapsed by default** — Clusters and Libraries sections start collapsed in the mobile drawer to reduce scroll length. Users can expand them manually. | diff --git a/src/app/api/ffmpeg/status/route.ts b/src/app/api/ffmpeg/status/route.ts deleted file mode 100644 index 021d04c..0000000 --- a/src/app/api/ffmpeg/status/route.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; - -/** - * GET /api/ffmpeg/status - * - * Returns the current status of all FFmpeg processes. - * - * Query parameters: - * - videoId: Filter processes by video ID - * - includeStats: Include additional statistics (default: false) - * - format: Response format - 'json' or 'table' (default: 'json') - * - * Response format: - * { - * "totalProcesses": 3, - * "activeProcesses": [...], - * "stats": { - * "totalUptime": 12345, - * "averageSeekTime": 120.5, - * "mostActiveVideo": "video_123" - * } - * } - */ -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const videoId = searchParams.get('videoId'); - const includeStats = searchParams.get('includeStats') === 'true'; - const format = searchParams.get('format') || 'json'; - - let processes; - if (videoId) { - processes = ffmpegRegistry.getProcessesForVideo(videoId); - } else { - processes = ffmpegRegistry.getAllProcesses(); - } - - const totalProcesses = ffmpegRegistry.getTotalProcessCount(); - - if (format === 'table') { - // Return a simple text table format for debugging - const table = formatAsTable(processes); - return new Response(table, { - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Access-Control-Allow-Origin': '*', - }, - }); - } - - const response: any = { - totalProcesses, - activeProcesses: processes, - timestamp: new Date().toISOString(), - }; - - if (includeStats) { - response.stats = calculateStats(processes); - } - - return NextResponse.json(response, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0', - }, - }); - - } catch (error) { - console.error('FFmpeg status API error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} - -/** - * DELETE /api/ffmpeg/status - * - * Cleanup FFmpeg processes - * Query parameters: - * - videoId: Kill all processes for specific video ID - * - stale: Kill stale processes older than maxAge (default: false) - * - maxAge: Maximum age in milliseconds for stale cleanup (default: 10min) - * - all: Kill all processes (default: false) - */ -export async function DELETE(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const videoId = searchParams.get('videoId'); - const stale = searchParams.get('stale') === 'true'; - const all = searchParams.get('all') === 'true'; - const maxAge = parseInt(searchParams.get('maxAge') || '600000'); // 10 minutes - - let killedCount = 0; - - if (all) { - killedCount = ffmpegRegistry.cleanupAll(); - } else if (videoId) { - killedCount = ffmpegRegistry.killAllForVideo(videoId); - } else if (stale) { - killedCount = ffmpegRegistry.cleanupStaleProcesses(maxAge); - } - - return NextResponse.json({ - success: true, - killedCount, - timestamp: new Date().toISOString(), - }); - - } catch (error) { - console.error('FFmpeg cleanup API error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} - -/** - * POST /api/ffmpeg/status - * - * Trigger cleanup operations - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { operation, videoId, maxAge } = body; - - let result; - - switch (operation) { - case 'cleanup': - result = ffmpegRegistry.cleanupStaleProcesses(maxAge || 600000); - break; - case 'killAll': - result = ffmpegRegistry.cleanupAll(); - break; - case 'killVideo': - if (!videoId) { - return NextResponse.json( - { error: 'videoId is required for killVideo operation' }, - { status: 400 } - ); - } - result = ffmpegRegistry.killAllForVideo(videoId); - break; - default: - return NextResponse.json( - { error: 'Invalid operation. Use: cleanup, killAll, or killVideo' }, - { status: 400 } - ); - } - - return NextResponse.json({ - success: true, - result, - operation, - timestamp: new Date().toISOString(), - }); - - } catch (error) { - console.error('FFmpeg operation API error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} - -/** - * OPTIONS handler for CORS - */ -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization', - 'Access-Control-Max-Age': '86400', - }, - }); -} - -/** - * Calculate statistics for active processes - */ -function calculateStats(processes: any[]) { - if (processes.length === 0) { - return { - totalUptime: 0, - averageSeekTime: 0, - mostActiveVideo: null, - processCount: 0, - }; - } - - const totalUptime = processes.reduce((sum, p) => sum + p.uptime, 0); - const averageSeekTime = processes.reduce((sum, p) => sum + p.seekTime, 0) / processes.length; - - // Find most active video - const videoCounts = processes.reduce((acc, p) => { - acc[p.videoId] = (acc[p.videoId] || 0) + 1; - return acc; - }, {} as Record); - - const mostActiveVideo = Object.entries(videoCounts) - .sort(([, a], [, b]) => (b as number) - (a as number))[0]?.[0] || null; - - return { - totalUptime, - averageSeekTime, - mostActiveVideo, - processCount: processes.length, - videoCounts, - }; -} - -/** - * Format processes as a simple text table for debugging - */ -function formatAsTable(processes: any[]): string { - if (processes.length === 0) { - return 'No active FFmpeg processes'; - } - - const header = 'VIDEO ID'.padEnd(10) + 'SEEK'.padEnd(8) + 'UPTIME'.padEnd(12) + 'QUALITY'.padEnd(8); - const separator = '-'.repeat(header.length); - - const rows = processes.map(p => { - const uptime = formatUptime(p.uptime); - const seek = p.seekTime.toFixed(1).padStart(6) + 's'; - const quality = (p.quality || 'default').padEnd(7); - return p.videoId.padEnd(10) + seek.padEnd(8) + uptime.padEnd(12) + quality; - }); - - return [header, separator, ...rows].join('\n'); -} - -/** - * Format uptime in a human-readable way - */ -function formatUptime(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } else { - return `${seconds}s`; - } -} \ No newline at end of file diff --git a/src/app/api/media-access/[id]/route.ts b/src/app/api/media-access/[id]/route.ts index 23004b9..4ffe46e 100644 --- a/src/app/api/media-access/[id]/route.ts +++ b/src/app/api/media-access/[id]/route.ts @@ -101,8 +101,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ direct: `${baseUrl}/api/stream/direct/${video.id}`, external: `${baseUrl}/api/external-stream/${video.id}`, - // HLS streaming (if supported) - hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`, + // Live transcoding starts with POST /api/transcode/start and returns the playlist URL. + hls: `${baseUrl}/api/transcode/start`, // Protocol-specific URLs for external players protocols: { @@ -116,7 +116,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ // Browser-compatible formats browser: { native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null, - transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`, + transcoded: `${baseUrl}/api/transcode/start`, } }; diff --git a/src/app/api/stream/[id]/transcode/route.ts b/src/app/api/stream/[id]/transcode/route.ts deleted file mode 100644 index 211df9a..0000000 --- a/src/app/api/stream/[id]/transcode/route.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -// TRANSCODING DISABLED: These imports are no longer needed -// import { getDatabase } from '@/db'; -// import fs from 'fs'; -// import { spawn } from 'child_process'; -// import { Readable } from 'stream'; -// import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry'; - -// TRANSCODING DISABLED: Request tracking no longer needed -// const activeRequests = new Map>(); - -export async function HEAD( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // TRANSCODING DISABLED: Return 410 Gone with local player guidance - try { - const { id } = await params; - - return NextResponse.json({ - error: 'Transcoding is disabled. This format requires a local video player.', - suggestedPlayers: ['VLC Media Player', 'Elmedia Player', 'PotPlayer'], - directStreamUrl: `/api/stream/direct/${id}`, - helpUrl: '/help/local-players', - status: 'transcoding-disabled' - }, { status: 410 }); // 410 Gone - } catch (error) { - console.error('HEAD request error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -export async function OPTIONS( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // TRANSCODING DISABLED: Return 410 Gone for OPTIONS as well - return new Response(null, { - status: 410, // Gone - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Range', - 'Access-Control-Max-Age': '86400', - 'X-Status': 'transcoding-disabled', - }, - }); -} - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // TRANSCODING DISABLED: Return 410 Gone with comprehensive local player guidance - try { - const { id } = await params; - - return NextResponse.json({ - error: 'Transcoding is disabled. This format requires a local video player.', - message: 'This video format is not supported for direct browser playback. Please use a local video player application.', - suggestedPlayers: [ - { name: 'VLC Media Player', id: 'vlc', platforms: ['Windows', 'macOS', 'Linux'], url: 'https://www.videolan.org/vlc/' }, - { name: 'IINA', id: 'iina', platforms: ['macOS'], url: 'https://iina.io/' }, - { name: 'Elmedia Player', id: 'elmedia', platforms: ['macOS'], url: 'https://www.elmedia-video-player.com/' }, - { name: 'PotPlayer', id: 'potplayer', platforms: ['Windows'], url: 'https://potplayer.daum.net/' } - ], - directStreamUrl: `/api/stream/direct/${id}`, - streamInfo: { - supportsRangeRequests: true, - contentType: 'video/*', - authentication: 'none' - }, - action: 'use-local-player', - helpUrl: '/help/local-players', - status: 'transcoding-disabled', - alternative: 'direct-stream' - }, { status: 410 }); // 410 Gone - } catch (error) { - console.error('Transcoding API error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} - -// TRANSCODING DISABLED: Comment out transcoding functionality -// This function is no longer used but kept for reference during transition -/* -async function createTranscodeStream( - id: string, - filePath: string, - seekTime: number, - quality: string, - duration: number, - settings: { width: number, height: number, bitrate: string } -): Promise { - // Original transcoding logic disabled - // See git history for implementation details - throw new Error('Transcoding is disabled. Use local player instead.'); -} -*/ - -// TRANSCODING DISABLED: Cleanup function no longer needed -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // TRANSCODING DISABLED: Return 410 Gone - no processes to clean up - try { - const { id } = await params; - - return NextResponse.json({ - error: 'Transcoding cleanup is disabled. No processes to terminate.', - status: 'transcoding-disabled', - message: 'Transcoding functionality has been removed. Use local video players instead.' - }, { status: 410 }); // 410 Gone - } catch (error) { - console.error('Cleanup API error:', error); - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/api/stream/hls/[id]/cleanup/route.ts b/src/app/api/stream/hls/[id]/cleanup/route.ts deleted file mode 100644 index f06b3ca..0000000 --- a/src/app/api/stream/hls/[id]/cleanup/route.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { hlsSessionManager } from "@/lib/hls-session-manager"; -import { tsSegmentationService } from "@/lib/ts-segmentation-service"; - -/** - * Manual cleanup endpoint for HLS segmentation sessions - * POST /api/stream/hls/[id]/cleanup - Force cleanup specific session - * DELETE /api/stream/hls/[id]/cleanup - Same as POST - */ - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - const videoId = parseInt(id); - - if (isNaN(videoId)) { - return NextResponse.json({ error: "Invalid video ID" }, { status: 400 }); - } - - // Check if this is a reference release or force cleanup - const body = await request.json().catch(() => ({ action: 'force_cleanup' })); - const { action = 'force_cleanup' } = body; - - if (action === 'release_reference') { - // Release a reference to the session - await hlsSessionManager.releaseSession(videoId); - console.log(`[HLS-Cleanup] Reference released for video ${videoId}`); - - return NextResponse.json({ - success: true, - message: `Reference released for video ${videoId}`, - videoId - }); - - } else { - // Force cleanup the session (default behavior) - await hlsSessionManager.forceCleanupSession(videoId); - console.log(`[HLS-Cleanup] Manual cleanup completed for video ${videoId}`); - - return NextResponse.json({ - success: true, - message: `Session for video ${videoId} cleaned up successfully` - }); - } - - } catch (error: any) { - console.error("[HLS-Cleanup] Error during cleanup:", error); - return NextResponse.json({ - error: "Cleanup failed", - details: error.message - }, { status: 500 }); - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - // Delegate to POST handler - return POST(request, { params }); -} - -/** - * Get session status and statistics - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - - try { - const videoId = parseInt(id); - - if (isNaN(videoId)) { - return NextResponse.json({ error: "Invalid video ID" }, { status: 400 }); - } - - // Get session information - const session = await tsSegmentationService.getSession(videoId); - const heartbeat = hlsSessionManager.getHeartbeat(videoId); - const sessionStats = hlsSessionManager.getSessionStats(videoId); - const isActive = hlsSessionManager.isSessionActive(videoId); - - // Validate session health - const healthCheck = await hlsSessionManager.validateSessionHealth(videoId); - - const response = { - videoId, - session: session ? { - videoId: session.videoId, - videoPath: session.videoPath, - status: session.status, - segmentCount: session.segmentCount, - totalDuration: session.totalDuration, - referenceCount: session.referenceCount, - createdAt: session.createdAt, - lastAccessed: session.lastAccessed, - tempDir: session.tempDir, - playlistPath: session.playlistPath, - } : null, - heartbeat, - sessionStats, - isActive, - healthCheck, - }; - - return NextResponse.json(response); - - } catch (error: any) { - console.error("[HLS-Cleanup] Error getting session status:", error); - return NextResponse.json({ - error: "Failed to get session status", - details: error.message - }, { status: 500 }); - } -} - -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }, - }); -} \ No newline at end of file diff --git a/src/app/api/stream/hls/[id]/playlist.m3u8/route.ts b/src/app/api/stream/hls/[id]/playlist.m3u8/route.ts deleted file mode 100644 index ee188f3..0000000 --- a/src/app/api/stream/hls/[id]/playlist.m3u8/route.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getDatabase } from "@/db"; -import fs from "fs"; -import path from "path"; -import { hlsSessionManager } from "@/lib/hls-session-manager"; -import { tsSegmentationService } from "@/lib/ts-segmentation-service"; - -/** - * Generate HLS playlist for a video file - .m3u8 extension handler - * Enhanced to handle .ts files properly as single-segment streams - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const db = getDatabase(); - - try { - const videoId = parseInt(id); - - const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { - path: string, - codec_info: string, - duration: number, - title: string - } | undefined; - - if (!video) { - return NextResponse.json({ error: "Video not found" }, { status: 404 }); - } - - const videoPath = video.path; - - if (!fs.existsSync(videoPath)) { - return NextResponse.json({ error: "Video file not found" }, { status: 404 }); - } - - // Check if this is a .ts file (enhanced HLS handling) - const fileExtension = path.extname(videoPath).toLowerCase(); - - if (fileExtension === '.ts') { - // Check if .ts file needs re-segmentation - const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath); - - if (needsSegmentation) { - return generateSegmentedTSPlaylist(video, videoId, videoPath, request); - } else { - // Use existing virtual segmentation for proper .ts files - return generateTSFilePlaylist(video, videoId, request); - } - } else { - // Other formats use generic playlist generation - return generateGenericPlaylist(video, videoId, request); - } - - } catch (error: any) { - console.error("Error generating HLS playlist:", error); - return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 }); - } -} - -/** - * Generate HLS playlist for merged .ts files that need FFmpeg re-segmentation - */ -async function generateSegmentedTSPlaylist(video: any, videoId: number, videoPath: string, request: NextRequest): Promise { - try { - console.log(`[HLS-Playlist] Creating segmented playlist for merged .ts file: ${videoPath}`); - - // Get or create segmentation session - const session = await hlsSessionManager.getOrCreateSession(videoId, videoPath); - - if (session.status === 'processing') { - // Return a simple response indicating processing - return new Response('Processing...', { - status: 202, - headers: { - 'Content-Type': 'text/plain', - 'Retry-After': '5', - }, - }); - } - - if (session.status === 'error') { - console.error(`[HLS-Playlist] Segmentation failed for video ${videoId}: ${session.error}`); - // Fall back to virtual segmentation - return generateTSFilePlaylist(video, videoId, request); - } - - if (session.status === 'ready') { - // Serve the generated playlist - if (fs.existsSync(session.playlistPath)) { - const playlistContent = fs.readFileSync(session.playlistPath, 'utf8'); - - // Update heartbeat for session management - hlsSessionManager.updateHeartbeat(videoId); - - console.log(`[HLS-Playlist] Serving generated playlist for video ${videoId} (${session.segmentCount} segments)`); - - return new Response(playlistContent, { - status: 200, - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=30', // Short cache for generated playlists - }, - }); - } else { - console.error(`[HLS-Playlist] Playlist file not found: ${session.playlistPath}`); - // Fall back to virtual segmentation - return generateTSFilePlaylist(video, videoId, request); - } - } - - // Fallback case - console.warn(`[HLS-Playlist] Unexpected session status ${session.status}, falling back to virtual segmentation`); - return generateTSFilePlaylist(video, videoId, request); - - } catch (error: any) { - console.error(`[HLS-Playlist] Error creating segmented playlist for video ${videoId}:`, error); - // Fall back to virtual segmentation on any error - return generateTSFilePlaylist(video, videoId, request); - } -} - -/** - * Generate HLS playlist for .ts files (virtual segmentation approach) - */ -function generateTSFilePlaylist(video: any, videoId: number, request: NextRequest): Response { - // Parse codec info to get duration - let duration = 0; - try { - const codecInfo = JSON.parse(video.codec_info || '{}'); - duration = codecInfo.duration || 0; - } catch { - // Fallback: use file size estimation - const stat = fs.statSync(video.path); - duration = Math.max(60, Math.floor(stat.size / (1024 * 1024)) * 30); // Rough estimate - } - - // Calculate virtual segments (matching segment route implementation) - const stat = fs.statSync(video.path); - const fileSize = stat.size; - const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (same as segment route) - const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE); - const segmentDuration = duration / totalSegments; // Distribute duration across segments - - // Generate absolute segment URL with proper video ID - const host = request.headers.get('host') || request.nextUrl.host; - const protocol = request.nextUrl.protocol; - - console.log(`[HLS-Playlist] Virtual TS segmentation: ${totalSegments} segments, ${segmentDuration.toFixed(2)}s each`); - - // Create multi-segment playlist for virtual .ts segmentation - const playlist = [ - '#EXTM3U', - '#EXT-X-VERSION:3', - `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}`, - '#EXT-X-MEDIA-SEQUENCE:0', - ...Array.from({ length: totalSegments }, (_, i) => [ - `#EXTINF:${segmentDuration.toFixed(3)},`, - `${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts` - ]).flat(), - '#EXT-X-ENDLIST' - ].join('\n'); - - return new Response(playlist, { - status: 200, - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=300', - }, - }); -} - -/** - * Generate HLS playlist for other formats (multi-segment approach) - */ -function generateGenericPlaylist(video: any, videoId: number, request: NextRequest): Response { - // Parse codec info to get duration - let duration = 0; - try { - const codecInfo = JSON.parse(video.codec_info || '{}'); - duration = codecInfo.duration || 0; - } catch { - // Fallback: estimate duration from file size (rough approximation) - const stat = fs.statSync(video.path); - // Assume ~1MB per minute for standard video (very rough) - duration = Math.floor(stat.size / (1024 * 1024)) * 60; - } - - // If we still don't have duration, use a default - if (duration <= 0) { - duration = 3600; // 1 hour default - } - - // Generate HLS playlist - // For now, create a simple playlist with 10-second segments - const segmentDuration = 10; - const numSegments = Math.ceil(duration / segmentDuration); - - // Generate absolute URLs with proper video ID - const host = request.headers.get('host') || request.nextUrl.host; - const protocol = request.nextUrl.protocol; - - // Create playlist content with absolute segment URLs - const playlist = [ - '#EXTM3U', - '#EXT-X-VERSION:3', - '#EXT-X-TARGETDURATION:10', - '#EXT-X-MEDIA-SEQUENCE:0', - ...Array.from({ length: numSegments }, (_, i) => [ - `#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`, - `${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts` - ]).flat(), - '#EXT-X-ENDLIST' - ].join('\n'); - - return new Response(playlist, { - status: 200, - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes - }, - }); -} - -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }, - }); -} \ No newline at end of file diff --git a/src/app/api/stream/hls/[id]/playlist/route.ts b/src/app/api/stream/hls/[id]/playlist/route.ts deleted file mode 100644 index b9c8ef1..0000000 --- a/src/app/api/stream/hls/[id]/playlist/route.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getDatabase } from "@/db"; -import fs from "fs"; -import path from "path"; - -/** - * Generate HLS playlist for a video file - * This creates a simple single-bitrate playlist for direct file streaming - * For multi-bitrate streaming, this would need to be enhanced with FFmpeg - * - * Supports both /playlist and /playlist.m3u8 URL patterns for compatibility - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const db = getDatabase(); - - try { - const videoId = parseInt(id); - - const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { - path: string, - codec_info: string, - duration: number, - title: string - } | undefined; - - if (!video) { - return NextResponse.json({ error: "Video not found" }, { status: 404 }); - } - - const videoPath = video.path; - - if (!fs.existsSync(videoPath)) { - return NextResponse.json({ error: "Video file not found" }, { status: 404 }); - } - - // Parse codec info to get duration - let duration = 0; - try { - const codecInfo = JSON.parse(video.codec_info || '{}'); - duration = codecInfo.duration || 0; - } catch { - // Fallback: estimate duration from file size (rough approximation) - const stat = fs.statSync(videoPath); - // Assume ~1MB per minute for standard video (very rough) - duration = Math.floor(stat.size / (1024 * 1024)) * 60; - } - - // If we still don't have duration, use a default - if (duration <= 0) { - duration = 3600; // 1 hour default - } - - // Generate HLS playlist - // For now, create a simple playlist with 10-second segments - const segmentDuration = 10; - const numSegments = Math.ceil(duration / segmentDuration); - - // Generate absolute URLs with proper video ID - const host = request.headers.get('host') || request.nextUrl.host; - const protocol = request.nextUrl.protocol; - - // Create playlist content with absolute segment URLs - const playlist = [ - '#EXTM3U', - '#EXT-X-VERSION:3', - '#EXT-X-TARGETDURATION:10', - '#EXT-X-MEDIA-SEQUENCE:0', - ...Array.from({ length: numSegments }, (_, i) => [ - `#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`, - `${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts` - ]).flat(), - '#EXT-X-ENDLIST' - ].join('\n'); - - return new Response(playlist, { - status: 200, - headers: { - 'Content-Type': 'application/vnd.apple.mpegurl', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=300', // Cache for 5 minutes - }, - }); - - } catch (error) { - console.error("Error generating HLS playlist:", error); - return NextResponse.json({ error: "Internal server error" }, { status: 500 }); - } -} - -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }, - }); -} \ No newline at end of file diff --git a/src/app/api/stream/hls/[id]/segment/[segment]/route.ts b/src/app/api/stream/hls/[id]/segment/[segment]/route.ts deleted file mode 100644 index d45a29a..0000000 --- a/src/app/api/stream/hls/[id]/segment/[segment]/route.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getDatabase } from "@/db"; -import fs from "fs"; -import path from "path"; -import { hlsSessionManager } from "@/lib/hls-session-manager"; -import { tsSegmentationService } from "@/lib/ts-segmentation-service"; - -/** - * Serve a segment from FFmpeg-generated segmentation - */ -async function serveGeneratedSegment( - session: any, - segmentIndex: number, - request: NextRequest -): Promise { - try { - // Update heartbeat - hlsSessionManager.updateHeartbeat(session.videoId, segmentIndex); - - // Get segment file path - const segmentPath = await tsSegmentationService.getSegmentPath(session.videoId, segmentIndex); - - if (!segmentPath || !fs.existsSync(segmentPath)) { - console.log(`[HLS-Segment] Generated segment ${segmentIndex} not found for video ${session.videoId}`); - return new NextResponse(null, { status: 404 }); - } - - const stat = fs.statSync(segmentPath); - const segmentSize = stat.size; - - console.log(`[HLS-Segment] Serving generated segment ${segmentIndex} for video ${session.videoId} (${segmentSize} bytes)`); - - // Create read stream for the segment - const stream = fs.createReadStream(segmentPath); - - return new Response(stream as any, { - status: 200, - headers: { - 'Content-Type': 'video/mp2t', - 'Content-Length': segmentSize.toString(), - 'Accept-Ranges': 'bytes', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=3600', // Cache generated segments for 1 hour - 'ETag': `"generated-${session.videoId}-${segmentIndex}"`, - }, - }); - - } catch (error: any) { - console.error(`[HLS-Segment] Error serving generated segment ${segmentIndex}:`, error); - return new NextResponse(null, { status: 500 }); - } -} - -/** - * Serve a virtual segment from a large .ts file using byte ranges - * This mimics how streaming sites serve small .ts segments - */ -async function serveTSSegment(videoPath: string, segmentIndex: number, request: NextRequest): Promise { - const stat = fs.statSync(videoPath); - const fileSize = stat.size; - - // Configuration for virtual segmentation - const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (like streaming sites) - const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE); - - console.log(`[TS-Segment] Virtual segmentation: segment ${segmentIndex}/${totalSegments - 1}, file size: ${fileSize}`); - - // Check if segment is valid - if (segmentIndex >= totalSegments) { - console.log(`[TS-Segment] Segment ${segmentIndex} out of range (max: ${totalSegments - 1})`); - return new NextResponse(null, { status: 404 }); - } - - // Calculate byte range for this segment - const start = segmentIndex * SEGMENT_SIZE; - const end = Math.min(start + SEGMENT_SIZE - 1, fileSize - 1); - const segmentLength = end - start + 1; - - console.log(`[TS-Segment] Serving bytes ${start}-${end} (${segmentLength} bytes)`); - - // Check for client disconnect to cancel streaming - const controller = new AbortController(); - request.signal?.addEventListener('abort', () => { - console.log(`[TS-Segment] Client disconnected, cancelling segment ${segmentIndex}`); - controller.abort(); - }); - - try { - // Create a read stream for the specific byte range - const stream = fs.createReadStream(videoPath, { - start, - end - }); - - // Handle stream errors - stream.on('error', (error) => { - console.error(`[TS-Segment] Stream error for segment ${segmentIndex}:`, error); - controller.abort(); - }); - - return new Response(stream as any, { - status: 200, - headers: { - 'Content-Type': 'video/mp2t', - 'Content-Length': segmentLength.toString(), - 'Content-Range': `bytes ${start}-${end}/${fileSize}`, - 'Accept-Ranges': 'bytes', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Cache-Control': 'public, max-age=31536000', // Cache segments aggressively - 'ETag': `"${segmentIndex}-${start}-${end}"`, // Unique identifier for caching - }, - }); - - } catch (error: any) { - if (error.code === 'ABORT_ERR') { - console.log(`[TS-Segment] Segment ${segmentIndex} cancelled by client`); - return new NextResponse(null, { status: 499 }); // Client closed connection - } - - console.error(`[TS-Segment] Error serving segment ${segmentIndex}:`, error); - return new NextResponse(null, { status: 500 }); - } -} - -/** - * Serve HLS segments for video streaming - * For .ts files: serve virtual segments using byte ranges (like streaming sites) - * For other formats: return error and suggest alternatives - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string; segment: string }> } -) { - const { id, segment } = await params; - const db = getDatabase(); - - console.log(`[HLS-Segment] Request for video ${id}, segment ${segment}`); - - try { - const videoId = parseInt(id); - const segmentIndex = parseInt(segment.replace('.ts', '')); - - console.log(`[HLS-Segment] Parsed videoId: ${videoId}, segmentIndex: ${segmentIndex} (original: ${segment})`); - - if (isNaN(videoId)) { - console.log(`[HLS-Segment] Invalid video ID: ${id}`); - return NextResponse.json({ error: "Invalid video ID" }, { status: 400 }); - } - - if (isNaN(segmentIndex) || segmentIndex < 0) { - console.log(`[HLS-Segment] Invalid segment index: ${segment}`); - return NextResponse.json({ error: "Invalid segment index" }, { status: 400 }); - } - - const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as { - path: string, - codec_info: string - } | undefined; - - if (!video) { - console.log(`[HLS-Segment] Video not found in database: ${videoId}`); - return NextResponse.json({ error: "Video not found" }, { status: 404 }); - } - - const videoPath = video.path; - console.log(`[HLS-Segment] Video path: ${videoPath}`); - - if (!fs.existsSync(videoPath)) { - console.log(`[HLS-Segment] Video file not found on disk: ${videoPath}`); - return NextResponse.json({ error: "Video file not found" }, { status: 404 }); - } - - // Check if the file is already a .ts file (MPEG-TS) - const fileExtension = path.extname(videoPath).toLowerCase(); - console.log(`[HLS-Segment] File extension: ${fileExtension}`); - - if (fileExtension === '.ts') { - // Check if we have a segmentation session for this video - const session = await tsSegmentationService.getSession(videoId); - - if (session && session.status === 'ready') { - // Serve from generated segments - return serveGeneratedSegment(session, segmentIndex, request); - } else { - // Fall back to virtual segmentation for regular .ts files - return serveTSSegment(videoPath, segmentIndex, request); - } - } else { - // For non-.ts files, we need to either: - // 1. Convert to .ts segments on-the-fly (resource intensive) - // 2. Return an error indicating HLS is not supported for this format - // 3. Fall back to direct streaming - - console.log(`[HLS] Non-TS file requested for HLS streaming: ${videoPath}`); - - // Return a comprehensive error with alternatives - return NextResponse.json({ - error: "HLS streaming not implemented for this format", - format: fileExtension, - message: "This video format requires container conversion for HLS streaming", - alternatives: { - direct_stream: `/api/stream/direct/${videoId}`, - external_player: `/api/external-stream/${videoId}`, - container_conversion: "Consider converting to .mp4 format for browser compatibility" - }, - suggested_action: "Use direct streaming or external player for this format" - }, { status: 422 }); // Unprocessable Entity - } - } catch (error: any) { - console.error("[HLS-Segment] Error serving HLS segment:", error); - return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 }); - } -} - -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }, - }); -} \ No newline at end of file diff --git a/src/app/api/stream/hls/status/route.ts b/src/app/api/stream/hls/status/route.ts deleted file mode 100644 index 4816e8c..0000000 --- a/src/app/api/stream/hls/status/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { hlsSessionManager } from "@/lib/hls-session-manager"; -import { tsSegmentationService } from "@/lib/ts-segmentation-service"; - -/** - * Global HLS segmentation status and management endpoint - * GET /api/stream/hls/status - Get overall statistics - * POST /api/stream/hls/status - Trigger cleanup of expired sessions - */ - -export async function GET(request: NextRequest) { - try { - // Get overall statistics - const overallStats = hlsSessionManager.getOverallStats(); - const segmentationStats = tsSegmentationService.getStats(); - const debugInfo = hlsSessionManager.getDebugInfo(); - - // Get active and expired sessions - const activeSessions = hlsSessionManager.getActiveSessions(); - const expiredSessions = hlsSessionManager.getExpiredSessions(); - - const response = { - timestamp: new Date().toISOString(), - overallStats, - segmentationStats, - activeSessions: activeSessions.length, - expiredSessions: expiredSessions.length, - activeSessionIds: activeSessions, - expiredSessionIds: expiredSessions, - debug: debugInfo, - }; - - return NextResponse.json(response); - - } catch (error: any) { - console.error("[HLS-Status] Error getting status:", error); - return NextResponse.json({ - error: "Failed to get HLS status", - details: error.message - }, { status: 500 }); - } -} - -/** - * Trigger manual cleanup of expired sessions - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json().catch(() => ({})); - const maxIdleTime = body.maxIdleTime || 30 * 60 * 1000; // Default 30 minutes - - console.log(`[HLS-Status] Triggering manual cleanup with maxIdleTime: ${maxIdleTime}ms`); - - // Perform cleanup - const cleanedUpCount = await hlsSessionManager.cleanupExpiredSessions(maxIdleTime); - - // Get updated statistics - const overallStats = hlsSessionManager.getOverallStats(); - const segmentationStats = tsSegmentationService.getStats(); - - const response = { - timestamp: new Date().toISOString(), - cleanupPerformed: true, - cleanedUpCount, - maxIdleTime, - overallStats, - segmentationStats, - }; - - return NextResponse.json(response); - - } catch (error: any) { - console.error("[HLS-Status] Error during manual cleanup:", error); - return NextResponse.json({ - error: "Manual cleanup failed", - details: error.message - }, { status: 500 }); - } -} - -export async function OPTIONS() { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': '86400', - }, - }); -} \ No newline at end of file diff --git a/src/app/api/transcode/[jobId]/events/route.ts b/src/app/api/transcode/[jobId]/events/route.ts new file mode 100644 index 0000000..e45e4c1 --- /dev/null +++ b/src/app/api/transcode/[jobId]/events/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; +import { listSegmentFiles } from '@/lib/transcode/playlist'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) { + const { jobId } = await params; + const job = transcodeOrchestrator.get(jobId); + + if (!job) { + return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 }); + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + const send = () => { + const currentJob = transcodeOrchestrator.get(jobId); + if (!currentJob) { + controller.enqueue(encoder.encode('event: killed\ndata: {}\n\n')); + controller.close(); + clearInterval(interval); + return; + } + + controller.enqueue(encoder.encode(`event: status\ndata: ${JSON.stringify({ + status: currentJob.status, + segmentCount: listSegmentFiles(currentJob.outDir).length, + position: currentJob.lastClientPosition, + error: currentJob.error, + })}\n\n`)); + }; + + const interval = setInterval(send, 2000); + send(); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Connection: 'keep-alive', + }, + }); +} diff --git a/src/app/api/transcode/[jobId]/master.m3u8/route.ts b/src/app/api/transcode/[jobId]/master.m3u8/route.ts new file mode 100644 index 0000000..b081571 --- /dev/null +++ b/src/app/api/transcode/[jobId]/master.m3u8/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; +import { buildPlaylist } from '@/lib/transcode/playlist'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) { + const { jobId } = await params; + const job = transcodeOrchestrator.get(jobId); + + if (!job) { + return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 }); + } + + return new Response(buildPlaylist(job), { + status: 200, + headers: { + 'Content-Type': 'application/vnd.apple.mpegurl', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Access-Control-Allow-Origin': '*', + 'X-Content-Duration': job.knownDuration.toString(), + 'X-Transcode-Status': job.status, + }, + }); +} diff --git a/src/app/api/transcode/[jobId]/ping/route.ts b/src/app/api/transcode/[jobId]/ping/route.ts new file mode 100644 index 0000000..fc36ed7 --- /dev/null +++ b/src/app/api/transcode/[jobId]/ping/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) { + const { jobId } = await params; + const body = await request.json().catch(() => ({})); + const position = Number(body.position || 0); + const isPaused = Boolean(body.isPaused); + + if (!Number.isFinite(position) || position < 0) { + return NextResponse.json({ error: 'Invalid position' }, { status: 400 }); + } + + const ok = transcodeOrchestrator.ping(jobId, position, isPaused); + if (!ok) { + return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/transcode/[jobId]/route.ts b/src/app/api/transcode/[jobId]/route.ts new file mode 100644 index 0000000..279b2f2 --- /dev/null +++ b/src/app/api/transcode/[jobId]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function DELETE(_request: Request, { params }: { params: Promise<{ jobId: string }> }) { + const { jobId } = await params; + // markKilled() synchronously removes the job from the in-memory map so that + // segment/playlist routes return 404 immediately. The FFmpeg process is then + // terminated in the background so this response returns in <10ms. + const proc = transcodeOrchestrator.markKilled(jobId); + if (proc) { + transcodeOrchestrator.killProcess(proc).catch(() => {}); + } else { + // Job not found via markKilled (may have already been cleaned up) — still + // try the normal kill path in case state differs. + transcodeOrchestrator.kill(jobId, 'client closed').catch(() => {}); + } + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/transcode/[jobId]/seek/route.ts b/src/app/api/transcode/[jobId]/seek/route.ts new file mode 100644 index 0000000..fbb1e9a --- /dev/null +++ b/src/app/api/transcode/[jobId]/seek/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ConcurrencyLimitError, transcodeOrchestrator } from '@/lib/transcode/orchestrator'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) { + try { + const { jobId } = await params; + const body = await request.json(); + const position = Number(body.position); + + if (!Number.isFinite(position) || position < 0) { + return NextResponse.json({ error: 'Invalid position' }, { status: 400 }); + } + + const result = await transcodeOrchestrator.seek(jobId, position); + if (result.reused) { + return new Response(null, { status: 204 }); + } + + return NextResponse.json({ + jobId: result.job.id, + playlistUrl: `/api/transcode/${result.job.id}/master.m3u8`, + profile: result.job.profile, + duration: result.job.knownDuration, + startTime: result.job.startTime, + }); + } catch (error) { + if (error instanceof ConcurrencyLimitError) { + return NextResponse.json( + { error: error.message, code: 'TRANSCODE_BUSY', retryAfterSeconds: error.retryAfterSeconds }, + { status: 503, headers: { 'Retry-After': error.retryAfterSeconds.toString() } }, + ); + } + + console.error('[TranscodeAPI] seek failed:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Seek failed' }, { status: 500 }); + } +} diff --git a/src/app/api/transcode/[jobId]/seg/[name]/route.ts b/src/app/api/transcode/[jobId]/seg/[name]/route.ts new file mode 100644 index 0000000..7e48481 --- /dev/null +++ b/src/app/api/transcode/[jobId]/seg/[name]/route.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import { NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; +import { segmentPath } from '@/lib/transcode/playlist'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +async function waitForSegment(filePath: string, timeoutMs = 5000): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + if (stat.size > 0) return true; + } + await new Promise(resolve => setTimeout(resolve, 150)); + } + return false; +} + +export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string; name: string }> }) { + const { jobId, name } = await params; + const job = transcodeOrchestrator.get(jobId); + + if (!job) { + return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 }); + } + + const resolvedPath = segmentPath(job.outDir, name); + if (!resolvedPath || !(await waitForSegment(resolvedPath))) { + return new Response(null, { status: 404 }); + } + + const stat = fs.statSync(resolvedPath); + const stream = fs.createReadStream(resolvedPath); + return new Response(stream as any, { + status: 200, + headers: { + 'Content-Type': 'video/mp2t', + 'Content-Length': stat.size.toString(), + 'Cache-Control': 'private, max-age=3600', + 'ETag': `"${jobId}-${name}-${stat.size}-${stat.mtimeMs}"`, + 'Access-Control-Allow-Origin': '*', + }, + }); +} diff --git a/src/app/api/transcode/start/route.ts b/src/app/api/transcode/start/route.ts new file mode 100644 index 0000000..cb9ab1b --- /dev/null +++ b/src/app/api/transcode/start/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { ConcurrencyLimitError, transcodeOrchestrator, TranscodeDisabledError } from '@/lib/transcode/orchestrator'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const mediaId = Number(body.mediaId); + const startTime = Number(body.startTime || 0); + const profile = typeof body.profile === 'string' ? body.profile : undefined; + + if (!Number.isInteger(mediaId) || mediaId <= 0) { + return NextResponse.json({ error: 'Invalid mediaId' }, { status: 400 }); + } + + if (!Number.isFinite(startTime) || startTime < 0) { + return NextResponse.json({ error: 'Invalid startTime' }, { status: 400 }); + } + + const job = await transcodeOrchestrator.start(mediaId, profile, startTime); + return NextResponse.json({ + jobId: job.id, + playlistUrl: `/api/transcode/${job.id}/master.m3u8`, + etaSeconds: 1, + profile: job.profile, + decision: job.decision.kind, + duration: job.knownDuration, + startTime: job.startTime, + }); + } catch (error) { + if (error instanceof TranscodeDisabledError) { + return NextResponse.json({ error: error.message, code: 'TRANSCODE_DISABLED' }, { status: 403 }); + } + + if (error instanceof ConcurrencyLimitError) { + return NextResponse.json( + { error: error.message, code: 'TRANSCODE_BUSY', retryAfterSeconds: error.retryAfterSeconds }, + { status: 503, headers: { 'Retry-After': error.retryAfterSeconds.toString() } }, + ); + } + + console.error('[TranscodeAPI] start failed:', error); + return NextResponse.json({ error: error instanceof Error ? error.message : 'Failed to start transcode' }, { status: 500 }); + } +} diff --git a/src/app/api/transcode/status/route.ts b/src/app/api/transcode/status/route.ts new file mode 100644 index 0000000..90ab1ba --- /dev/null +++ b/src/app/api/transcode/status/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { transcodeOrchestrator } from '@/lib/transcode/orchestrator'; +import { probeHardwareEncoder } from '@/lib/transcode/hw-probe'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + transcodeOrchestrator.recover(); + return NextResponse.json({ + ...transcodeOrchestrator.status(), + encoder: probeHardwareEncoder(), + }); +} diff --git a/src/app/api/video/[id]/player-config/route.ts b/src/app/api/video/[id]/player-config/route.ts index 3354291..66f9528 100644 --- a/src/app/api/video/[id]/player-config/route.ts +++ b/src/app/api/video/[id]/player-config/route.ts @@ -74,9 +74,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: }, streaming: { direct_url: `/api/stream/direct/${video.id}`, - hls_url: format.type === 'hls' ? `/api/stream/hls/${video.id}/playlist.m3u8` : null, + hls_url: format.type === 'hls' ? format.url : null, fallback_url: `/api/stream/${video.id}`, - transcoding_url: `/api/stream/${video.id}/transcode`, + transcoding_url: `/api/transcode/start`, supports_range_requests: format.supportLevel === 'native', supports_adaptive_bitrate: format.type === 'hls' } diff --git a/src/app/api/videos/[id]/convert-ts/route.ts b/src/app/api/videos/[id]/convert-ts/route.ts deleted file mode 100644 index 5c359d6..0000000 --- a/src/app/api/videos/[id]/convert-ts/route.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { getDatabase } from '@/db'; -import { convertTSToMP4, analyzeTSFile } from '@/lib/ts-converter'; -import path from 'path'; -import fs from 'fs'; - -/** - * API endpoint for .ts file container conversion - * Converts .ts files to .mp4 containers without re-encoding for better browser compatibility - */ - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const db = getDatabase(); - - try { - const videoId = parseInt(id); - - if (isNaN(videoId)) { - return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 }); - } - - // Get video information - const video = db.prepare(` - SELECT m.*, l.path as library_path - FROM media m - JOIN libraries l ON m.library_id = l.id - WHERE m.id = ? AND m.type = 'video' - `).get(videoId) as { - id: number; - path: string; - title: string; - codec_info?: string; - } | undefined; - - if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); - } - - const inputPath = video.path; - - if (!fs.existsSync(inputPath)) { - return NextResponse.json({ error: 'Video file not found on disk' }, { status: 404 }); - } - - // Check if it's a .ts file - const extension = path.extname(inputPath).toLowerCase(); - if (extension !== '.ts') { - return NextResponse.json({ - error: 'Not a .ts file', - message: 'This endpoint only converts .ts (MPEG Transport Stream) files', - current_format: extension - }, { status: 400 }); - } - - // Parse request options - const requestBody = await request.json().catch(() => ({})); - const options = { - fastStart: requestBody.fastStart !== false, // Default to true - deleteOriginal: requestBody.deleteOriginal === true, // Default to false - }; - - // Generate output path in the same directory - const outputPath = inputPath.replace(/\.ts$/i, '_web.mp4'); - - // Check if converted file already exists - if (fs.existsSync(outputPath)) { - return NextResponse.json({ - success: true, - message: 'File already converted', - original_path: inputPath, - converted_path: outputPath, - converted_size: fs.statSync(outputPath).size, - conversion_time: 0 - }); - } - - console.log(`[TSConvert] Starting conversion for video ${videoId}: ${inputPath}`); - - // Analyze the file first - const analysis = await analyzeTSFile(inputPath); - - if (!analysis.isConvertible) { - return NextResponse.json({ - error: 'File not suitable for conversion', - reason: analysis.reason, - video_codec: analysis.videoCodec, - audio_codec: analysis.audioCodec - }, { status: 422 }); - } - - // Perform the conversion - const result = await convertTSToMP4(inputPath, { - outputPath, - ...options - }); - - if (result.success) { - // Update database to reference the new file - // Note: We keep the original record but could add a reference to the converted file - console.log(`[TSConvert] Successfully converted video ${videoId} in ${result.duration.toFixed(2)}s`); - - return NextResponse.json({ - success: true, - message: 'File converted successfully', - original_path: inputPath, - converted_path: result.outputPath, - conversion_time: result.duration, - original_size: result.originalSize, - converted_size: result.convertedSize, - size_change_percent: result.convertedSize ? - ((result.convertedSize - result.originalSize) / result.originalSize * 100).toFixed(1) : 0, - video_codec: analysis.videoCodec, - audio_codec: analysis.audioCodec, - web_compatible: true, - recommended_action: 'Use the converted file for better browser compatibility' - }); - } else { - console.error(`[TSConvert] Failed to convert video ${videoId}:`, result.error); - - return NextResponse.json({ - error: 'Conversion failed', - details: result.error, - conversion_time: result.duration - }, { status: 500 }); - } - - } catch (error: any) { - console.error('[TSConvert] API error:', error); - return NextResponse.json({ - error: 'Internal server error', - details: error.message - }, { status: 500 }); - } -} - -/** - * Get conversion status and file information - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params; - const db = getDatabase(); - - try { - const videoId = parseInt(id); - - if (isNaN(videoId)) { - return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 }); - } - - const video = db.prepare(` - SELECT m.*, l.path as library_path - FROM media m - JOIN libraries l ON m.library_id = l.id - WHERE m.id = ? AND m.type = 'video' - `).get(videoId) as { - path: string; - title: string; - } | undefined; - - if (!video) { - return NextResponse.json({ error: 'Video not found' }, { status: 404 }); - } - - const inputPath = video.path; - const extension = path.extname(inputPath).toLowerCase(); - - if (extension !== '.ts') { - return NextResponse.json({ - convertible: false, - reason: 'Not a .ts file', - current_format: extension - }); - } - - // Check if converted file exists - const convertedPath = inputPath.replace(/\.ts$/i, '_web.mp4'); - const hasConverted = fs.existsSync(convertedPath); - - // Analyze the original file - const analysis = await analyzeTSFile(inputPath); - - return NextResponse.json({ - convertible: analysis.isConvertible, - original_path: inputPath, - converted_path: convertedPath, - has_converted_file: hasConverted, - converted_file_size: hasConverted ? fs.statSync(convertedPath).size : null, - analysis: { - video_codec: analysis.videoCodec, - audio_codec: analysis.audioCodec, - duration: analysis.duration, - reason: analysis.reason - }, - recommendations: { - use_hls: 'Stream via HLS for best compatibility', - convert_container: analysis.isConvertible ? 'Convert to MP4 container for direct browser playback' : null, - external_player: 'Use VLC or similar player for guaranteed playback' - } - }); - - } catch (error: any) { - console.error('[TSConvert] Status check error:', error); - return NextResponse.json({ - error: 'Internal server error', - details: error.message - }, { status: 500 }); - } -} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 8cff6d4..ccb1407 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -206,3 +206,27 @@ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; } + +/* Mobile sidebar drawer animations */ +@keyframes slideInFromLeft { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +@keyframes slideOutToLeft { + from { transform: translateX(0); } + to { transform: translateX(-100%); } +} + +.animate-slide-in { + animation: slideInFromLeft 0.3s ease-out forwards; +} + +.animate-slide-out { + animation: slideOutToLeft 0.3s ease-in forwards; +} + +/* Safe area bottom padding for devices with home indicator */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0px); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 7464b60..75883d7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,9 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; import Sidebar from "@/components/sidebar"; +import MobileTopBar from "@/components/mobile-top-bar"; +import MobileBottomTabs from "@/components/mobile-bottom-tabs"; +import { SidebarProvider } from "@/contexts/sidebar-context"; const inter = Inter({ variable: "--font-inter", @@ -23,12 +26,16 @@ export default function RootLayout({ -
- -
- {children} -
-
+ +
+ + +
+ {children} +
+ +
+
); diff --git a/src/components/artplayer-wrapper.tsx b/src/components/artplayer-wrapper.tsx index 20ba7e2..045c882 100644 --- a/src/components/artplayer-wrapper.tsx +++ b/src/components/artplayer-wrapper.tsx @@ -24,6 +24,7 @@ interface ArtPlayerWrapperProps { showBookmarks?: boolean; showRatings?: boolean; autoplay?: boolean; + formatOverride?: VideoFormat; } export default function ArtPlayerWrapper({ @@ -41,11 +42,14 @@ export default function ArtPlayerWrapper({ avgRating = 0, showBookmarks = false, showRatings = false, - autoplay = true + autoplay = true, + formatOverride }: ArtPlayerWrapperProps) { const containerRef = useRef(null); const playerRef = useRef(null); const hlsInstanceRef = useRef(null); // Store HLS instance for cleanup + const formatOverrideRef = useRef(formatOverride); + const mediaElementsRef = useRef>(new Set()); const [format, setFormat] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -59,6 +63,102 @@ export default function ArtPlayerWrapper({ const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount); const [localAvgRating, setLocalAvgRating] = useState(avgRating); const hlsErrorHandlerRef = useRef(null); + const hlsShuttingDownRef = useRef(false); // Blocks HLS error-recovery after teardown + + const cloneQualities = (qualities: VideoFormat['qualities']) => ( + qualities?.map(quality => ({ ...quality })) || [] + ); + + const resetArtPlayerContainer = () => { + if (!containerRef.current) return; + const el = containerRef.current as unknown as Record; + for (const prop of ['$control_option', '$video', '$player', '$art']) { + try { delete el[prop]; } catch (_) { /* non-configurable */ } + } + }; + + const stopTrackedMedia = (clearSource: boolean) => { + mediaElementsRef.current.forEach(videoEl => { + videoEl.pause(); + if (clearSource) { + videoEl.removeAttribute('src'); + videoEl.load(); + } + }); + + containerRef.current?.querySelectorAll('video, audio').forEach(mediaEl => { + mediaEl.pause(); + if (clearSource) { + mediaEl.removeAttribute('src'); + mediaEl.load(); + } + }); + + if (clearSource) { + document.querySelectorAll('video, audio').forEach(mediaEl => { + mediaEl.pause(); + mediaEl.removeAttribute('src'); + mediaEl.load(); + }); + } + }; + + const releasePlayer = () => { + // Mark HLS as shutting down BEFORE stopping/destroying so that any + // in-flight error callbacks (NETWORK_ERROR → startLoad recovery) are blocked. + hlsShuttingDownRef.current = true; + + // ──── Step 1: Immediately pause all media to stop audio output ──── + // Only pause here — do NOT remove src or call load() yet, because + // HLS.js still owns the MediaSource on the
); +} + +function getTranscodeJobId(url?: string): string | null { + if (!url) return null; + const match = url.match(/\/api\/transcode\/([^/]+)\//); + return match?.[1] || null; } \ No newline at end of file diff --git a/src/components/local-player-launcher.tsx b/src/components/local-player-launcher.tsx index 35e293d..9104cbf 100644 --- a/src/components/local-player-launcher.tsx +++ b/src/components/local-player-launcher.tsx @@ -37,6 +37,7 @@ interface LocalPlayerLauncherProps { onRate?: (id: number, rating: number) => Promise; showBookmarks?: boolean; showRatings?: boolean; + notice?: string; } interface PlayerInfo { @@ -149,7 +150,8 @@ export default function LocalPlayerLauncher({ onUnbookmark, onRate, showBookmarks = true, - showRatings = true + showRatings = true, + notice }: LocalPlayerLauncherProps) { const [copied, setCopied] = useState(false); const [detectedPlayers, setDetectedPlayers] = useState([]); @@ -549,6 +551,12 @@ export default function LocalPlayerLauncher({ + {notice && ( + + {notice} + + )} + {/* Video Info with Bookmark & Rating */}
diff --git a/src/components/mobile-bottom-tabs.tsx b/src/components/mobile-bottom-tabs.tsx new file mode 100644 index 0000000..69979e9 --- /dev/null +++ b/src/components/mobile-bottom-tabs.tsx @@ -0,0 +1,44 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Home, Film, Image as ImageIcon, Bookmark } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const tabs = [ + { href: "/", label: "Home", icon: Home }, + { href: "/videos", label: "Videos", icon: Film }, + { href: "/photos", label: "Photos", icon: ImageIcon }, + { href: "/bookmarks", label: "Bookmarks", icon: Bookmark }, +]; + +export default function MobileBottomTabs() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/mobile-top-bar.tsx b/src/components/mobile-top-bar.tsx new file mode 100644 index 0000000..33adcc6 --- /dev/null +++ b/src/components/mobile-top-bar.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Menu, Play } from "lucide-react"; +import { useSidebar } from "@/contexts/sidebar-context"; + +export default function MobileTopBar() { + const { openMobile } = useSidebar(); + + return ( +
+ + +
+
+ +
+ NextAV +
+
+ ); +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 788020a..3a92b8c 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { ChevronLeft, ChevronRight, + X, Home, Settings, Video, @@ -24,13 +25,14 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/utils"; import { Suspense } from "react"; +import { useSidebar } from "@/contexts/sidebar-context"; const SidebarContent = () => { - const [isCollapsed, setIsCollapsed] = useState(false); + const { isCollapsed, isMobileOpen, toggleCollapse, closeMobile, isMobile } = useSidebar(); const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]); const [clusters, setClusters] = useState([]); - const [showClusters, setShowClusters] = useState(true); - const [showLibraries, setShowLibraries] = useState(true); + const [showClusters, setShowClusters] = useState(!isMobile); + const [showLibraries, setShowLibraries] = useState(!isMobile); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -75,9 +77,13 @@ const SidebarContent = () => { return icons[iconName] || Folder; }; - const toggleSidebar = () => { - setIsCollapsed(!isCollapsed); - }; + // When switching to mobile, collapse sections by default + useEffect(() => { + if (isMobile) { + setShowClusters(false); + setShowLibraries(false); + } + }, [isMobile]); const navItems = [ { href: "/", label: "Home", icon: Home }, @@ -88,16 +94,16 @@ const SidebarContent = () => { { href: "/settings", label: "Settings", icon: Settings }, ]; - return ( -
+ const sidebarInner = ( +
{/* Header */}
- {!isCollapsed && ( + {(!isCollapsed || isMobile) && (
@@ -105,14 +111,25 @@ const SidebarContent = () => {

NextAV

)} - + {isMobile ? ( + + ) : ( + + )}
{/* Navigation */} @@ -123,15 +140,24 @@ const SidebarContent = () => { ))} @@ -144,11 +170,11 @@ const SidebarContent = () => { onClick={() => setShowClusters(!showClusters)} className={cn( "flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors", - isCollapsed && "justify-center" + isCollapsed && !isMobile && "justify-center" )} > - {!isCollapsed && ( + {(!isCollapsed || isMobile) && ( <>

Clusters @@ -166,31 +192,23 @@ const SidebarContent = () => { const IconComponent = getIconComponent(cluster.icon); const isActive = pathname === `/clusters/${cluster.id}`; return ( - + @@ -208,11 +226,11 @@ const SidebarContent = () => { onClick={() => setShowLibraries(!showLibraries)} className={cn( "flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors", - isCollapsed && "justify-center" + isCollapsed && !isMobile && "justify-center" )} > - {!isCollapsed && ( + {(!isCollapsed || isMobile) && ( <>

Libraries @@ -235,14 +253,15 @@ const SidebarContent = () => {

); + + return ( + <> + {/* Desktop: persistent sidebar, hidden on mobile */} + + + {/* Mobile: overlay drawer */} + {isMobileOpen && ( +
+ {/* Backdrop */} +
+ {/* Drawer */} + +
+ )} + + ); }; const Sidebar = () => { return ( - }> + }> ); diff --git a/src/components/unified-video-player.tsx b/src/components/unified-video-player.tsx index e3ba9d8..8ddceab 100644 --- a/src/components/unified-video-player.tsx +++ b/src/components/unified-video-player.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector'; import ArtPlayerWrapper from '@/components/artplayer-wrapper'; import LocalPlayerLauncher from '@/components/local-player-launcher'; @@ -44,6 +44,41 @@ export default function UnifiedVideoPlayer({ const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true); const [currentRating, setCurrentRating] = useState(0); const [ratingCheckLoading, setRatingCheckLoading] = useState(true); + const [transcodeLoading, setTranscodeLoading] = useState(false); + const [transcodeError, setTranscodeError] = useState(null); + const [activeTranscodeJobId, setActiveTranscodeJobId] = useState(null); + const activeTranscodeJobIdRef = useRef(null); + + // Keep ref in sync with state + useEffect(() => { + activeTranscodeJobIdRef.current = activeTranscodeJobId; + }, [activeTranscodeJobId]); + + const releaseTranscodeJob = useCallback((jobId: string | null) => { + if (!jobId) return; + fetch(`/api/transcode/${jobId}`, { method: 'DELETE', keepalive: true }).catch(error => { + console.warn('[UnifiedVideoPlayer] Failed to release transcode job:', error); + }); + }, []); + + const resetDetectedFormat = useCallback(() => { + const detectedFormat = detectVideoFormat(video); + setFormat(detectedFormat); + setTranscodeError(null); + setTranscodeLoading(false); + return detectedFormat; + }, [video]); + + const handleClose = useCallback(() => { + // Read from ref to get the latest value, not a stale closure + const jobId = activeTranscodeJobIdRef.current; + if (jobId) { + releaseTranscodeJob(jobId); + setActiveTranscodeJobId(null); + } + resetDetectedFormat(); + onClose(); + }, [onClose, releaseTranscodeJob, resetDetectedFormat]); // Check current bookmark status and rating when video opens useEffect(() => { @@ -95,16 +130,79 @@ export default function UnifiedVideoPlayer({ } }; - // Detect format on mount + // Detect/reset format whenever a video is opened. This avoids reusing a killed HLS job URL + // after closing and reopening the same video. useEffect(() => { - if (video) { + if (video && isOpen) { console.log('[UnifiedVideoPlayer] Detecting format for video:', video); - const detectedFormat = detectVideoFormat(video); + // Read from ref to get latest jobId without needing it in deps + const prevJobId = activeTranscodeJobIdRef.current; + if (prevJobId) { + releaseTranscodeJob(prevJobId); + setActiveTranscodeJobId(null); + } + const detectedFormat = resetDetectedFormat(); console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat); - setFormat(detectedFormat); setIsLoading(false); } - }, [video]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [video, isOpen]); + + useEffect(() => { + if (!isOpen || format?.type !== 'local-player' || process.env.NEXT_PUBLIC_ENABLE_LIVE_TRANSCODE !== 'true') { + return; + } + + let cancelled = false; + setTranscodeLoading(true); + setTranscodeError(null); + + fetch('/api/transcode/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mediaId: video.id }), + }) + .then(async response => { + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || 'Failed to start live transcode'); + } + if (!cancelled) { + setActiveTranscodeJobId(data.jobId || null); + setFormat({ + type: 'hls', + supportLevel: 'hls', + url: data.playlistUrl, + warning: `Live transcoding (${data.profile})`, + qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }], + }); + } + }) + .catch(error => { + if (!cancelled) { + console.warn('[UnifiedVideoPlayer] Live transcode unavailable, falling back to local player:', error); + setTranscodeError(error instanceof Error ? error.message : 'Live transcode unavailable'); + } + }) + .finally(() => { + if (!cancelled) setTranscodeLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [isOpen, format?.type, video.id]); + + // When the modal closes while a transcode job is active, kill it. + useEffect(() => { + if (isOpen) return; + const jobId = activeTranscodeJobIdRef.current; + if (!jobId) return; + releaseTranscodeJob(jobId); + setActiveTranscodeJobId(null); + resetDetectedFormat(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); // Handle ArtPlayer errors with recovery const handleArtPlayerError = useCallback((error: string) => { @@ -170,7 +268,7 @@ export default function UnifiedVideoPlayer({ { console.log(`Selected player: ${playerId}`); }} @@ -180,6 +278,7 @@ export default function UnifiedVideoPlayer({ onRate={handleRatingUpdate} showBookmarks={showBookmarks} showRatings={showRatings} + notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined} /> ); } @@ -190,7 +289,7 @@ export default function UnifiedVideoPlayer({ ); }; - if (isLoading || bookmarkCheckLoading || ratingCheckLoading) { + if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) { return (
-

Loading ArtPlayer...

+

{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}

); diff --git a/src/contexts/sidebar-context.tsx b/src/contexts/sidebar-context.tsx new file mode 100644 index 0000000..e454e2e --- /dev/null +++ b/src/contexts/sidebar-context.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; +import { usePathname } from "next/navigation"; + +interface SidebarContextType { + isCollapsed: boolean; + isMobileOpen: boolean; + toggleCollapse: () => void; + openMobile: () => void; + closeMobile: () => void; + isMobile: boolean; +} + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [isCollapsed, setIsCollapsed] = useState(false); + const [isMobileOpen, setIsMobileOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const pathname = usePathname(); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 1023px)"); + const handler = (e: MediaQueryListEvent | MediaQueryList) => { + setIsMobile(e.matches); + if (!e.matches) setIsMobileOpen(false); + }; + handler(mq); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // Auto-close mobile drawer on route change + useEffect(() => { + setIsMobileOpen(false); + }, [pathname]); + + const toggleCollapse = useCallback(() => setIsCollapsed((v) => !v), []); + const openMobile = useCallback(() => setIsMobileOpen(true), []); + const closeMobile = useCallback(() => setIsMobileOpen(false), []); + + return ( + + {children} + + ); +} + +export function useSidebar() { + const ctx = useContext(SidebarContext); + if (!ctx) throw new Error("useSidebar must be used within SidebarProvider"); + return ctx; +} diff --git a/src/db/index.ts b/src/db/index.ts index 0c053e3..21429f9 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -140,6 +140,24 @@ function initializeDatabase() { ); `); + // Operational state for live transcoding jobs. Jobs are short-lived, but + // persisting them lets the server recover cleanly after a restart. + db.exec(` + CREATE TABLE IF NOT EXISTS transcode_jobs ( + job_id TEXT PRIMARY KEY, + media_id INTEGER NOT NULL, + profile TEXT NOT NULL, + out_dir TEXT NOT NULL, + start_ts REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK (status IN ('pending', 'encoding', 'ready', 'paused', 'failed', 'killed')), + pid INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_ping_at DATETIME, + error TEXT, + FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE + ); + `); + // Create indexes for performance db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`); @@ -165,6 +183,8 @@ function initializeDatabase() { db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`); db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id);`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status);`); return db; } diff --git a/src/lib/ffmpeg/process-registry.ts b/src/lib/ffmpeg/process-registry.ts deleted file mode 100644 index 34adadb..0000000 --- a/src/lib/ffmpeg/process-registry.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { ChildProcess } from 'child_process'; - -interface FFmpegProcessInfo { - process: ChildProcess; - startTime: Date; - seekTime: number; - videoId: string; - command: string[]; - quality?: string; -} - -/** - * Enhanced FFmpeg Process Registry for seek-optimized transcoding - * Inspired by Stash's approach: kill old processes, start new ones with -ss parameter - */ -export class FFmpegProcessRegistry { - private processes = new Map(); - - /** - * Register a new FFmpeg process - * @param videoId The video ID - * @param seekTime The seek time in seconds - * @param process The FFmpeg child process - * @param command The FFmpeg command arguments - * @param quality Optional quality setting - */ - register( - videoId: string, - seekTime: number, - process: ChildProcess, - command: string[], - quality?: string - ): string { - const key = `${videoId}_${seekTime}_${quality || 'default'}`; - - // Kill existing process for this video if different seek time - this.killExisting(videoId, seekTime, quality); - - this.processes.set(key, { - process, - startTime: new Date(), - seekTime, - videoId, - command, - quality - }); - - console.log(`[FFMPEG_REGISTRY] Registered process: ${key} (seek: ${seekTime}s)`); - return key; - } - - /** - * Kill existing processes for a video that have different seek times - */ - private killExisting(videoId: string, newSeekTime: number, quality?: string): void { - const processesToKill: string[] = []; - - for (const [key, entry] of this.processes.entries()) { - const [entryVideoId, entrySeekTime, entryQuality] = key.split('_'); - - if (entryVideoId === videoId && - (entrySeekTime !== newSeekTime.toString() || - (entryQuality && entryQuality !== (quality || 'default')))) { - processesToKill.push(key); - } - } - - processesToKill.forEach(key => this.killProcess(key)); - } - - /** - * Kill a specific process - */ - killProcess(key: string): boolean { - const entry = this.processes.get(key); - if (entry && !entry.process.killed) { - try { - console.log(`[FFMPEG_REGISTRY] Killing process: ${key}`); - entry.process.kill('SIGKILL'); - this.processes.delete(key); - return true; - } catch (error) { - console.error(`[FFMPEG_REGISTRY] Error killing process ${key}:`, error); - return false; - } - } - return false; - } - - /** - * Kill all processes for a specific video - */ - killAllForVideo(videoId: string): number { - const processesToKill: string[] = []; - - for (const [key, entry] of this.processes.entries()) { - if (entry.videoId === videoId) { - processesToKill.push(key); - } - } - - let killedCount = 0; - processesToKill.forEach(key => { - if (this.killProcess(key)) { - killedCount++; - } - }); - - if (killedCount > 0) { - console.log(`[FFMPEG_REGISTRY] Killed ${killedCount} processes for video: ${videoId}`); - } - - return killedCount; - } - - /** - * Get all active processes for a video - */ - getProcessesForVideo(videoId: string): Array<{ - key: string; - seekTime: number; - uptime: number; - command: string[]; - quality?: string; - }> { - return Array.from(this.processes.entries()) - .filter(([_, entry]) => entry.videoId === videoId) - .map(([key, entry]) => ({ - key, - seekTime: entry.seekTime, - uptime: Date.now() - entry.startTime.getTime(), - command: entry.command, - quality: entry.quality - })); - } - - /** - * Get all active processes - */ - getAllProcesses(): Array<{ - key: string; - videoId: string; - seekTime: number; - uptime: number; - command: string[]; - quality?: string; - }> { - return Array.from(this.processes.entries()).map(([key, entry]) => ({ - key, - videoId: entry.videoId, - seekTime: entry.seekTime, - uptime: Date.now() - entry.startTime.getTime(), - command: entry.command, - quality: entry.quality - })); - } - - /** - * Get process count for a video - */ - getProcessCountForVideo(videoId: string): number { - return Array.from(this.processes.values()) - .filter(entry => entry.videoId === videoId) - .length; - } - - /** - * Get total process count - */ - getTotalProcessCount(): number { - return this.processes.size; - } - - /** - * Cleanup stale processes (older than maxAge milliseconds) - */ - cleanupStaleProcesses(maxAge: number = 10 * 60 * 1000): number { - const now = Date.now(); - const staleProcesses: string[] = []; - - for (const [key, entry] of this.processes.entries()) { - if (now - entry.startTime.getTime() > maxAge) { - staleProcesses.push(key); - } - } - - let cleanedCount = 0; - staleProcesses.forEach(key => { - if (this.killProcess(key)) { - cleanedCount++; - } - }); - - if (cleanedCount > 0) { - console.log(`[FFMPEG_REGISTRY] Cleaned up ${cleanedCount} stale processes`); - } - - return cleanedCount; - } - - /** - * Cleanup all processes - */ - cleanupAll(): number { - const processKeys = Array.from(this.processes.keys()); - let cleanedCount = 0; - - processKeys.forEach(key => { - if (this.killProcess(key)) { - cleanedCount++; - } - }); - - console.log(`[FFMPEG_REGISTRY] Cleaned up all ${cleanedCount} processes`); - return cleanedCount; - } -} - -// Export singleton instance -export const ffmpegRegistry = new FFmpegProcessRegistry(); - -// Cleanup on process exit -if (typeof process !== 'undefined') { - process.on('exit', () => { - ffmpegRegistry.cleanupAll(); - }); - - process.on('SIGINT', () => { - ffmpegRegistry.cleanupAll(); - process.exit(0); - }); - - process.on('SIGTERM', () => { - ffmpegRegistry.cleanupAll(); - process.exit(0); - }); -} \ No newline at end of file diff --git a/src/lib/hls-session-manager.ts b/src/lib/hls-session-manager.ts deleted file mode 100644 index dd305d1..0000000 --- a/src/lib/hls-session-manager.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * HLS Session Manager - * Manages lifecycle of HLS segmentation sessions with heartbeat and TTL - */ - -import { EventEmitter } from 'events'; -import { tsSegmentationService, SegmentationSession } from './ts-segmentation-service'; - -export interface SessionHeartbeat { - videoId: number; - timestamp: Date; - segmentIndex?: number; - clientInfo?: { - userAgent?: string; - ip?: string; - }; -} - -export interface SessionStats { - totalSessions: number; - activeSessions: number; - expiredSessions: number; - totalSegmentRequests: number; - averageSessionDuration: number; -} - -class HLSSessionManager extends EventEmitter { - private heartbeats = new Map(); - private sessionStats = new Map(); - - constructor() { - super(); - this.setupEventHandlers(); - } - - /** - * Create or get existing session for video - */ - async getOrCreateSession(videoId: number, videoPath: string): Promise { - // Check if video needs segmentation - const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath); - - if (!needsSegmentation) { - throw new Error('Video does not need segmentation'); - } - - let session = await tsSegmentationService.getSession(videoId); - - if (session) { - // Add reference to existing session - const updatedSession = await tsSegmentationService.addReference(videoId); - if (updatedSession) { - session = updatedSession; - console.log(`[HLSSessionManager] Using existing session for video ${videoId} (refs: ${session.referenceCount})`); - } - } else { - // Create new session - console.log(`[HLSSessionManager] Creating new segmentation session for video ${videoId}`); - session = await tsSegmentationService.createSegmentationSession(videoId, videoPath); - this.emit('sessionCreated', { videoId, session }); - } - - if (!session) { - throw new Error('Failed to create or get session'); - } - - // Update heartbeat - this.updateHeartbeat(videoId); - - // Update stats - this.updateSessionStats(videoId); - - return session! - } - - /** - * Update heartbeat for a session - */ - updateHeartbeat(videoId: number, segmentIndex?: number, clientInfo?: SessionHeartbeat['clientInfo']): void { - const heartbeat: SessionHeartbeat = { - videoId, - timestamp: new Date(), - segmentIndex, - clientInfo, - }; - - this.heartbeats.set(videoId, heartbeat); - this.emit('heartbeat', heartbeat); - - console.log(`[HLSSessionManager] Heartbeat updated for video ${videoId} ${segmentIndex !== undefined ? `(segment ${segmentIndex})` : ''}`); - } - - /** - * Get session heartbeat - */ - getHeartbeat(videoId: number): SessionHeartbeat | null { - return this.heartbeats.get(videoId) || null; - } - - /** - * Check if session is active (recent heartbeat) - */ - isSessionActive(videoId: number, maxIdleTime: number = 30 * 60 * 1000): boolean { - const heartbeat = this.heartbeats.get(videoId); - if (!heartbeat) return false; - - const timeSinceLastHeartbeat = Date.now() - heartbeat.timestamp.getTime(); - return timeSinceLastHeartbeat < maxIdleTime; - } - - /** - * Get all active sessions - */ - getActiveSessions(maxIdleTime: number = 30 * 60 * 1000): number[] { - const activeSessions: number[] = []; - - for (const videoId of this.heartbeats.keys()) { - if (this.isSessionActive(videoId, maxIdleTime)) { - activeSessions.push(videoId); - } - } - - return activeSessions; - } - - /** - * Get expired sessions - */ - getExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): number[] { - const expiredSessions: number[] = []; - - for (const videoId of this.heartbeats.keys()) { - if (!this.isSessionActive(videoId, maxIdleTime)) { - expiredSessions.push(videoId); - } - } - - return expiredSessions; - } - - /** - * Cleanup expired sessions - */ - async cleanupExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): Promise { - const expiredSessions = this.getExpiredSessions(maxIdleTime); - let cleanedUpCount = 0; - - for (const videoId of expiredSessions) { - try { - await this.forceCleanupSession(videoId); - cleanedUpCount++; - } catch (error) { - console.error(`[HLSSessionManager] Error cleaning up session ${videoId}:`, error); - } - } - - if (cleanedUpCount > 0) { - console.log(`[HLSSessionManager] Cleaned up ${cleanedUpCount} expired sessions`); - this.emit('sessionsCleanedUp', { count: cleanedUpCount, videoIds: expiredSessions }); - } - - return cleanedUpCount; - } - - /** - * Force cleanup a specific session - */ - async forceCleanupSession(videoId: number): Promise { - try { - // Remove reference from segmentation service - await tsSegmentationService.removeReference(videoId); - - // Remove heartbeat - this.heartbeats.delete(videoId); - - // Remove stats - this.sessionStats.delete(videoId); - - console.log(`[HLSSessionManager] Force cleaned up session for video ${videoId}`); - this.emit('sessionCleanedUp', { videoId }); - - } catch (error) { - console.error(`[HLSSessionManager] Error force cleaning up session ${videoId}:`, error); - throw error; - } - } - - /** - * Release reference to a session - */ - async releaseSession(videoId: number): Promise { - try { - await tsSegmentationService.removeReference(videoId); - console.log(`[HLSSessionManager] Released reference for video ${videoId}`); - } catch (error) { - console.error(`[HLSSessionManager] Error releasing session ${videoId}:`, error); - } - } - - /** - * Get session statistics - */ - getSessionStats(videoId: number): { requestCount: number; firstRequest: Date; lastRequest: Date; } | null { - return this.sessionStats.get(videoId) || null; - } - - /** - * Get overall statistics - */ - getOverallStats(): SessionStats { - const totalSessions = this.sessionStats.size; - const activeSessions = this.getActiveSessions().length; - const expiredSessions = totalSessions - activeSessions; - - let totalSegmentRequests = 0; - let totalSessionDuration = 0; - let validSessions = 0; - - for (const stats of this.sessionStats.values()) { - totalSegmentRequests += stats.requestCount; - - const duration = stats.lastRequest.getTime() - stats.firstRequest.getTime(); - if (duration > 0) { - totalSessionDuration += duration; - validSessions++; - } - } - - const averageSessionDuration = validSessions > 0 ? totalSessionDuration / validSessions : 0; - - return { - totalSessions, - activeSessions, - expiredSessions, - totalSegmentRequests, - averageSessionDuration: Math.round(averageSessionDuration / 1000), // Convert to seconds - }; - } - - /** - * Update session statistics - */ - private updateSessionStats(videoId: number): void { - const now = new Date(); - const stats = this.sessionStats.get(videoId); - - if (stats) { - stats.requestCount++; - stats.lastRequest = now; - } else { - this.sessionStats.set(videoId, { - requestCount: 1, - firstRequest: now, - lastRequest: now, - }); - } - } - - /** - * Setup event handlers - */ - private setupEventHandlers(): void { - // Listen to segmentation service events - this.on('sessionCreated', ({ videoId }) => { - console.log(`[HLSSessionManager] Session created for video ${videoId}`); - }); - - this.on('heartbeat', ({ videoId, segmentIndex }) => { - // Optional: Log heartbeats in debug mode - if (process.env.NODE_ENV === 'development') { - console.debug(`[HLSSessionManager] Heartbeat: video ${videoId}, segment ${segmentIndex}`); - } - }); - - this.on('sessionCleanedUp', ({ videoId }) => { - console.log(`[HLSSessionManager] Session cleaned up for video ${videoId}`); - }); - - this.on('sessionsCleanedUp', ({ count }) => { - console.log(`[HLSSessionManager] Batch cleanup completed: ${count} sessions`); - }); - } - - /** - * Schedule automatic cleanup - */ - startAutoCleanup(intervalMs: number = 5 * 60 * 1000, maxIdleTimeMs: number = 30 * 60 * 1000): NodeJS.Timeout { - const interval = setInterval(async () => { - try { - await this.cleanupExpiredSessions(maxIdleTimeMs); - } catch (error) { - console.error('[HLSSessionManager] Auto cleanup error:', error); - } - }, intervalMs); - - console.log(`[HLSSessionManager] Auto cleanup started (interval: ${intervalMs}ms, maxIdle: ${maxIdleTimeMs}ms)`); - return interval; - } - - /** - * Get debug information - */ - getDebugInfo(): { - heartbeats: SessionHeartbeat[]; - stats: SessionStats; - segmentationStats: any; - } { - return { - heartbeats: Array.from(this.heartbeats.values()), - stats: this.getOverallStats(), - segmentationStats: tsSegmentationService.getStats(), - }; - } - - /** - * Validate session health - */ - async validateSessionHealth(videoId: number): Promise<{ - isValid: boolean; - issues: string[]; - session?: SegmentationSession; - }> { - const issues: string[] = []; - - try { - const session = await tsSegmentationService.getSession(videoId); - - if (!session) { - issues.push('Session not found'); - return { isValid: false, issues }; - } - - if (session.status === 'error') { - issues.push(`Session in error state: ${session.error}`); - } - - if (session.status === 'processing') { - const age = Date.now() - session.createdAt.getTime(); - if (age > 5 * 60 * 1000) { // 5 minutes - issues.push('Session stuck in processing state'); - } - } - - if (session.status === 'ready') { - // Check if playlist file exists - const fs = require('fs'); - if (!fs.existsSync(session.playlistPath)) { - issues.push('Playlist file missing'); - } - } - - const isActive = this.isSessionActive(videoId); - if (!isActive) { - issues.push('Session inactive (no recent heartbeat)'); - } - - return { - isValid: issues.length === 0, - issues, - session, - }; - - } catch (error) { - issues.push(`Validation error: ${error}`); - return { isValid: false, issues }; - } - } -} - -// Export singleton instance -export const hlsSessionManager = new HLSSessionManager(); -export default hlsSessionManager; \ No newline at end of file diff --git a/src/lib/hooks/use-protected-duration.ts b/src/lib/hooks/use-protected-duration.ts index f5d9a38..e73ad05 100644 --- a/src/lib/hooks/use-protected-duration.ts +++ b/src/lib/hooks/use-protected-duration.ts @@ -100,20 +100,7 @@ export function useProtectedDuration({ console.log(`[DURATION] API response not ok:`, response.status, response.statusText); } - // 2. Try transcoding headers if transcoding - const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`); - const headerDuration = transcodingResponse.headers.get('X-Content-Duration'); - if (headerDuration) { - const durationValue = parseFloat(headerDuration); - if (durationValue > 0 && !isNaN(durationValue)) { - setDuration(durationValue); - hasRealDuration.current = true; - console.log(`[DURATION] Using header duration: ${durationValue}s`); - return; - } - } - - // 3. Use fallback + // 2. Use fallback when database duration is unavailable. console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`); setDuration(fallbackDuration); @@ -157,16 +144,6 @@ export async function getRealDuration(videoId: string): Promise { } } - // 2. Try transcoding headers - const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`); - const headerDuration = transcodingResponse.headers.get('X-Content-Duration'); - if (headerDuration) { - const durationValue = parseFloat(headerDuration); - if (durationValue > 0 && !isNaN(durationValue)) { - return durationValue; - } - } - return 0; } catch (error) { console.error('[DURATION] Error getting real duration:', error); diff --git a/src/lib/transcode/concurrency.ts b/src/lib/transcode/concurrency.ts new file mode 100644 index 0000000..566f48f --- /dev/null +++ b/src/lib/transcode/concurrency.ts @@ -0,0 +1,31 @@ +export class ConcurrencyLimitError extends Error { + constructor(public retryAfterSeconds: number) { + super('Transcoding capacity is full'); + this.name = 'ConcurrencyLimitError'; + } +} + +export class ConcurrencyGate { + private active = 0; + + constructor(private readonly max: number) {} + + acquire(): void { + if (this.active >= this.max) { + throw new ConcurrencyLimitError(30); + } + this.active += 1; + } + + release(): void { + this.active = Math.max(0, this.active - 1); + } + + snapshot() { + return { + active: this.active, + max: this.max, + available: Math.max(0, this.max - this.active), + }; + } +} diff --git a/src/lib/transcode/config.ts b/src/lib/transcode/config.ts new file mode 100644 index 0000000..2a327b9 --- /dev/null +++ b/src/lib/transcode/config.ts @@ -0,0 +1,37 @@ +export interface TranscodeConfig { + enabled: boolean; + ffmpegPath: string; + ffprobePath: string; + tmpDir: string; + maxConcurrent: number; + diskBudgetMb: number; + heartbeatTimeoutMs: number; + startupTimeoutMs: number; + hwAccel: 'auto' | 'videotoolbox' | 'nvenc' | 'qsv' | 'none'; + defaultProfile: string; + segmentDuration: number; + cacheGraceMs: number; +} + +function parseInteger(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function getTranscodeConfig(): TranscodeConfig { + return { + enabled: process.env.ENABLE_LIVE_TRANSCODE === 'true', + ffmpegPath: process.env.FFMPEG_PATH || 'ffmpeg', + ffprobePath: process.env.FFPROBE_PATH || 'ffprobe', + tmpDir: process.env.TRANSCODE_TMP_DIR || '/tmp/nextav-hls', + maxConcurrent: parseInteger(process.env.MAX_CONCURRENT_TRANSCODES, 2), + diskBudgetMb: parseInteger(process.env.TRANSCODE_DISK_BUDGET_MB, 5000), + heartbeatTimeoutMs: parseInteger(process.env.TRANSCODE_HEARTBEAT_TIMEOUT_MS, 60_000), + startupTimeoutMs: parseInteger(process.env.TRANSCODE_STARTUP_TIMEOUT_MS, 15_000), + hwAccel: (process.env.TRANSCODE_HWACCEL as TranscodeConfig['hwAccel']) || 'auto', + defaultProfile: process.env.TRANSCODE_DEFAULT_PROFILE || '720p-h264-aac', + segmentDuration: 4, + cacheGraceMs: 5 * 60_000, + }; +} diff --git a/src/lib/transcode/decision.ts b/src/lib/transcode/decision.ts new file mode 100644 index 0000000..547ec5f --- /dev/null +++ b/src/lib/transcode/decision.ts @@ -0,0 +1,34 @@ +import path from 'path'; +import { TranscodeProfileName } from './profiles'; +import { ProbeInfo } from './ffprobe'; + +export type TranscodeDecision = + | { kind: 'direct' } + | { kind: 'hls-remux'; profile: 'remux-copy' } + | { kind: 'hls-transcode'; profile: TranscodeProfileName }; + +const DIRECT_EXTENSIONS = new Set(['.mp4', '.m4v', '.webm', '.ogg', '.ogv']); +const DIRECT_VIDEO_CODECS = new Set(['h264', 'avc1', 'avc', 'vp8', 'vp9', 'av1']); +const DIRECT_AUDIO_CODECS = new Set(['aac', 'mp4a', 'opus', 'vorbis']); +const H264_CODECS = new Set(['h264', 'avc1', 'avc']); +const AAC_CODECS = new Set(['aac', 'mp4a']); + +export function decideTranscode(filePath: string, probe: ProbeInfo, preferredProfile: TranscodeProfileName): TranscodeDecision { + const extension = path.extname(filePath).toLowerCase(); + const videoCodec = probe.videoCodec.toLowerCase(); + const audioCodec = probe.audioCodec?.toLowerCase(); + + if ( + DIRECT_EXTENSIONS.has(extension) && + DIRECT_VIDEO_CODECS.has(videoCodec) && + (!audioCodec || DIRECT_AUDIO_CODECS.has(audioCodec)) + ) { + return { kind: 'direct' }; + } + + if (H264_CODECS.has(videoCodec) && (!audioCodec || AAC_CODECS.has(audioCodec))) { + return { kind: 'hls-remux', profile: 'remux-copy' }; + } + + return { kind: 'hls-transcode', profile: preferredProfile }; +} diff --git a/src/lib/transcode/ffmpeg-args.ts b/src/lib/transcode/ffmpeg-args.ts new file mode 100644 index 0000000..f7379fb --- /dev/null +++ b/src/lib/transcode/ffmpeg-args.ts @@ -0,0 +1,107 @@ +import path from 'path'; +import { getTranscodeConfig } from './config'; +import { probeHardwareEncoder } from './hw-probe'; +import { PROFILES, TranscodeProfileName } from './profiles'; + +function bitrateToNumber(bitrate: string): number { + return Number.parseInt(bitrate.replace(/k$/i, ''), 10); +} + +export function buildRemuxArgs(inputPath: string, outDir: string, startTime: number): string[] { + const config = getTranscodeConfig(); + return [ + '-hide_banner', + '-loglevel', + 'error', + '-nostats', + ...(startTime > 0 ? ['-ss', startTime.toString()] : []), + '-i', + inputPath, + '-map', + '0:v:0', + '-map', + '0:a:0?', + '-c:v', + 'copy', + '-bsf:v', + 'h264_mp4toannexb', + '-c:a', + 'copy', + '-f', + 'hls', + '-hls_time', + config.segmentDuration.toString(), + '-hls_list_size', + '0', + '-hls_segment_type', + 'mpegts', + '-hls_flags', + 'independent_segments+temp_file', + '-hls_segment_filename', + path.join(outDir, 'seg_%05d.ts'), + '-start_number', + '0', + path.join(outDir, 'index.m3u8'), + ]; +} + +export function buildTranscodeArgs(inputPath: string, outDir: string, profileName: Exclude, startTime: number): string[] { + const config = getTranscodeConfig(); + const profile = PROFILES[profileName]; + const encoder = probeHardwareEncoder(); + const bitrate = bitrateToNumber(profile.vBitrate); + + return [ + '-hide_banner', + '-loglevel', + 'error', + '-nostats', + ...(startTime > 0 ? ['-ss', startTime.toString()] : []), + '-i', + inputPath, + '-map', + '0:v:0', + '-map', + '0:a:0?', + '-c:v', + encoder.codec, + ...encoder.presetArgs, + '-profile:v', + 'main', + '-level', + '4.1', + '-pix_fmt', + 'yuv420p', + '-vf', + `scale=-2:min(${profile.maxHeight}\\,ih):flags=lanczos`, + '-b:v', + profile.vBitrate, + '-maxrate', + profile.vBitrate, + '-bufsize', + `${bitrate * 2}k`, + '-force_key_frames', + `expr:gte(t,n_forced*${config.segmentDuration})`, + '-c:a', + profile.aCodec, + '-b:a', + profile.aBitrate, + '-ac', + '2', + '-f', + 'hls', + '-hls_time', + config.segmentDuration.toString(), + '-hls_list_size', + '0', + '-hls_segment_type', + 'mpegts', + '-hls_flags', + 'independent_segments+temp_file', + '-hls_segment_filename', + path.join(outDir, 'seg_%05d.ts'), + '-start_number', + '0', + path.join(outDir, 'index.m3u8'), + ]; +} diff --git a/src/lib/transcode/ffprobe.ts b/src/lib/transcode/ffprobe.ts new file mode 100644 index 0000000..8b4ee88 --- /dev/null +++ b/src/lib/transcode/ffprobe.ts @@ -0,0 +1,78 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { getTranscodeConfig } from './config'; + +const execFileAsync = promisify(execFile); + +export interface ProbeInfo { + duration: number; + container: string; + videoCodec: string; + audioCodec?: string; + width?: number; + height?: number; +} + +interface FFProbeStream { + codec_type?: string; + codec_name?: string; + width?: number; + height?: number; +} + +interface FFProbeOutput { + format?: { + duration?: string; + format_name?: string; + }; + streams?: FFProbeStream[]; +} + +export function parseStoredProbe(codecInfo?: string | null): ProbeInfo | null { + if (!codecInfo) return null; + try { + const parsed = JSON.parse(codecInfo); + const videoCodec = parsed.videoCodec || parsed.codec || parsed.video_codec; + if (!videoCodec) return null; + return { + duration: Number(parsed.duration || 0), + container: String(parsed.container || parsed.format || ''), + videoCodec: String(videoCodec).toLowerCase(), + audioCodec: parsed.audioCodec || parsed.audio_codec ? String(parsed.audioCodec || parsed.audio_codec).toLowerCase() : undefined, + width: parsed.width ? Number(parsed.width) : undefined, + height: parsed.height ? Number(parsed.height) : undefined, + }; + } catch { + return null; + } +} + +export async function probeMedia(filePath: string): Promise { + const config = getTranscodeConfig(); + const { stdout } = await execFileAsync(config.ffprobePath, [ + '-v', + 'error', + '-print_format', + 'json', + '-show_format', + '-show_streams', + filePath, + ], { encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 }); + + const parsed = JSON.parse(stdout) as FFProbeOutput; + const video = parsed.streams?.find(stream => stream.codec_type === 'video'); + const audio = parsed.streams?.find(stream => stream.codec_type === 'audio'); + + if (!video?.codec_name) { + throw new Error('No video stream found'); + } + + return { + duration: Number(parsed.format?.duration || 0), + container: String(parsed.format?.format_name || '').toLowerCase(), + videoCodec: video.codec_name.toLowerCase(), + audioCodec: audio?.codec_name?.toLowerCase(), + width: video.width, + height: video.height, + }; +} diff --git a/src/lib/transcode/heartbeat.ts b/src/lib/transcode/heartbeat.ts new file mode 100644 index 0000000..b966f64 --- /dev/null +++ b/src/lib/transcode/heartbeat.ts @@ -0,0 +1,24 @@ +export class HeartbeatManager { + private timers = new Map(); + + arm(jobId: string, timeoutMs: number, onTimeout: () => void): void { + this.clear(jobId); + const timer = setTimeout(onTimeout, timeoutMs); + timer.unref(); + this.timers.set(jobId, timer); + } + + clear(jobId: string): void { + const timer = this.timers.get(jobId); + if (timer) { + clearTimeout(timer); + this.timers.delete(jobId); + } + } + + clearAll(): void { + for (const jobId of this.timers.keys()) { + this.clear(jobId); + } + } +} diff --git a/src/lib/transcode/hw-probe.ts b/src/lib/transcode/hw-probe.ts new file mode 100644 index 0000000..bf4b04f --- /dev/null +++ b/src/lib/transcode/hw-probe.ts @@ -0,0 +1,51 @@ +import { execFileSync } from 'child_process'; +import { getTranscodeConfig } from './config'; + +export interface HardwareEncoder { + codec: string; + presetArgs: string[]; + label: string; +} + +let cachedEncoder: HardwareEncoder | null = null; + +export function probeHardwareEncoder(): HardwareEncoder { + if (cachedEncoder) return cachedEncoder; + + const config = getTranscodeConfig(); + const fallback: HardwareEncoder = { + codec: 'libx264', + presetArgs: ['-preset', 'veryfast'], + label: 'libx264 software', + }; + + if (config.hwAccel === 'none') { + cachedEncoder = fallback; + return cachedEncoder; + } + + let encoders = ''; + try { + encoders = execFileSync(config.ffmpegPath, ['-hide_banner', '-encoders'], { + encoding: 'utf8', + timeout: 5000, + }); + } catch (error) { + console.warn('[Transcode] Failed to probe FFmpeg encoders; using software fallback', error); + cachedEncoder = fallback; + return cachedEncoder; + } + + const wants = config.hwAccel; + if ((wants === 'auto' || wants === 'videotoolbox') && encoders.includes('h264_videotoolbox')) { + cachedEncoder = { codec: 'h264_videotoolbox', presetArgs: [], label: 'h264_videotoolbox' }; + } else if ((wants === 'auto' || wants === 'nvenc') && encoders.includes('h264_nvenc')) { + cachedEncoder = { codec: 'h264_nvenc', presetArgs: ['-preset', 'p4'], label: 'h264_nvenc' }; + } else if ((wants === 'auto' || wants === 'qsv') && encoders.includes('h264_qsv')) { + cachedEncoder = { codec: 'h264_qsv', presetArgs: [], label: 'h264_qsv' }; + } else { + cachedEncoder = fallback; + } + + return cachedEncoder; +} diff --git a/src/lib/transcode/janitor.ts b/src/lib/transcode/janitor.ts new file mode 100644 index 0000000..58699fc --- /dev/null +++ b/src/lib/transcode/janitor.ts @@ -0,0 +1,49 @@ +import fs from 'fs'; +import path from 'path'; +import { getTranscodeConfig } from './config'; + +function directorySize(dir: string): number { + if (!fs.existsSync(dir)) return 0; + return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return total + directorySize(fullPath); + try { + return total + fs.statSync(fullPath).size; + } catch { + return total; + } + }, 0); +} + +function removeDirectory(dir: string): void { + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +export class SegmentJanitor { + scheduleCleanup(outDir: string, delayMs = getTranscodeConfig().cacheGraceMs): void { + setTimeout(() => { + removeDirectory(outDir); + }, delayMs).unref(); + } + + enforceBudget(): void { + const config = getTranscodeConfig(); + if (!fs.existsSync(config.tmpDir)) return; + + const budgetBytes = config.diskBudgetMb * 1024 * 1024; + if (directorySize(config.tmpDir) <= budgetBytes) return; + + const jobDirs = fs.readdirSync(config.tmpDir) + .map(name => path.join(config.tmpDir, name)) + .filter(dir => fs.statSync(dir).isDirectory()) + .map(dir => ({ dir, mtime: fs.statSync(dir).mtimeMs })) + .sort((a, b) => a.mtime - b.mtime); + + for (const { dir } of jobDirs) { + removeDirectory(dir); + if (directorySize(config.tmpDir) <= budgetBytes) break; + } + } +} diff --git a/src/lib/transcode/orchestrator.ts b/src/lib/transcode/orchestrator.ts new file mode 100644 index 0000000..d9e8e9a --- /dev/null +++ b/src/lib/transcode/orchestrator.ts @@ -0,0 +1,388 @@ +import { ChildProcessWithoutNullStreams, spawn } from 'child_process'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { getDatabase } from '@/db'; +import { getTranscodeConfig } from './config'; +import { ConcurrencyGate, ConcurrencyLimitError } from './concurrency'; +import { decideTranscode, TranscodeDecision } from './decision'; +import { buildRemuxArgs, buildTranscodeArgs } from './ffmpeg-args'; +import { parseStoredProbe, probeMedia, ProbeInfo } from './ffprobe'; +import { HeartbeatManager } from './heartbeat'; +import { SegmentJanitor } from './janitor'; +import { listSegmentFiles } from './playlist'; +import { getDefaultProfileName, TranscodeProfileName } from './profiles'; +import { TranscodeThrottler } from './throttler'; + +export type TranscodeJobStatus = 'pending' | 'encoding' | 'ready' | 'paused' | 'failed' | 'killed'; + +export interface TranscodeJob { + id: string; + mediaId: number; + mediaPath: string; + profile: TranscodeProfileName; + outDir: string; + startTime: number; + status: TranscodeJobStatus; + process?: ChildProcessWithoutNullStreams; + createdAt: number; + lastPingAt: number; + lastClientPosition: number; + knownDuration: number; + error?: string; + stderrRing: string[]; + decision: TranscodeDecision; + throttler?: TranscodeThrottler; +} + +interface MediaRow { + id: number; + path: string; + codec_info?: string; +} + +export class TranscodeDisabledError extends Error { + constructor() { + super('Live transcoding is disabled'); + this.name = 'TranscodeDisabledError'; + } +} + +class TranscodeOrchestrator { + private readonly jobs = new Map(); + private readonly gate = new ConcurrencyGate(getTranscodeConfig().maxConcurrent); + private readonly heartbeat = new HeartbeatManager(); + private readonly janitor = new SegmentJanitor(); + private recovered = false; + + constructor() { + const config = getTranscodeConfig(); + fs.mkdirSync(config.tmpDir, { recursive: true }); + } + + recover(): void { + if (this.recovered) return; + this.recovered = true; + const db = getDatabase(); + db.prepare("UPDATE transcode_jobs SET status = 'killed', error = COALESCE(error, 'server restarted') WHERE status IN ('pending', 'encoding', 'paused')").run(); + } + + async start(mediaId: number, requestedProfile: string | undefined, startTime: number): Promise { + const config = getTranscodeConfig(); + if (!config.enabled) { + throw new TranscodeDisabledError(); + } + + this.recover(); + this.janitor.enforceBudget(); + + const profile = getDefaultProfileName(requestedProfile || config.defaultProfile); + const existing = this.findReusableJob(mediaId, profile, startTime); + if (existing) return existing; + + const media = this.getMedia(mediaId); + const probe = await this.getProbe(media); + const decision = decideTranscode(media.path, probe, profile); + + if (decision.kind === 'direct') { + throw new Error('Media is directly playable and does not need live transcoding'); + } + + this.gate.acquire(); + + const jobId = crypto.randomUUID(); + const outDir = path.join(config.tmpDir, String(mediaId), `${decision.profile}-${jobId}`); + fs.mkdirSync(outDir, { recursive: true }); + + const job: TranscodeJob = { + id: jobId, + mediaId, + mediaPath: media.path, + profile: decision.profile, + outDir, + startTime, + status: 'pending', + createdAt: Date.now(), + lastPingAt: Date.now(), + lastClientPosition: startTime, + knownDuration: probe.duration, + stderrRing: [], + decision, + }; + + this.jobs.set(jobId, job); + this.persistJob(job); + + try { + await this.spawnJob(job); + return job; + } catch (error) { + await this.kill(jobId, error instanceof Error ? error.message : 'startup failed', 0); + throw error; + } + } + + get(jobId: string): TranscodeJob | undefined { + return this.jobs.get(jobId); + } + + status() { + return { + enabled: getTranscodeConfig().enabled, + concurrency: this.gate.snapshot(), + jobs: Array.from(this.jobs.values()).map(job => ({ + id: job.id, + mediaId: job.mediaId, + profile: job.profile, + status: job.status, + pid: job.process?.pid, + startTime: job.startTime, + lastPingAt: job.lastPingAt, + lastClientPosition: job.lastClientPosition, + segmentCount: listSegmentFiles(job.outDir).length, + decision: job.decision.kind, + error: job.error, + })), + }; + } + + ping(jobId: string, position: number, isPaused: boolean): boolean { + const job = this.jobs.get(jobId); + if (!job) return false; + const config = getTranscodeConfig(); + job.lastPingAt = Date.now(); + job.lastClientPosition = position; + job.throttler?.updateClient(position, isPaused); + this.heartbeat.arm(jobId, config.heartbeatTimeoutMs, () => { + this.kill(jobId, 'heartbeat timeout').catch(error => console.error('[Transcode] heartbeat kill failed', error)); + }); + getDatabase().prepare('UPDATE transcode_jobs SET last_ping_at = CURRENT_TIMESTAMP WHERE job_id = ?').run(jobId); + return true; + } + + async seek(jobId: string, position: number): Promise<{ reused: true } | { reused: false; job: TranscodeJob }> { + const job = this.jobs.get(jobId); + if (!job) throw new Error('Transcode job not found'); + + const encodedSeconds = listSegmentFiles(job.outDir).length * getTranscodeConfig().segmentDuration; + if (position >= job.startTime && position <= job.startTime + Math.max(0, encodedSeconds - 8)) { + return { reused: true }; + } + + await this.kill(jobId, 'seek restart', getTranscodeConfig().cacheGraceMs); + const newJob = await this.start(job.mediaId, job.profile, position); + return { reused: false, job: newJob }; + } + + /** + * Synchronously remove the job from the in-memory map so that segment/playlist + * routes start returning 404 immediately. Returns the job's FFmpeg process (if + * still running) so the caller can kill it asynchronously in the background. + */ + markKilled(jobId: string): ChildProcessWithoutNullStreams | null { + const job = this.jobs.get(jobId); + if (!job || job.status === 'killed') return null; + job.status = 'killed'; + job.error = 'client closed'; + this.heartbeat.clear(jobId); + job.throttler?.stop(); + this.gate.release(); + this.jobs.delete(jobId); + // Persist status to DB so recovery on restart doesn't see stale jobs. + try { + getDatabase().prepare('UPDATE transcode_jobs SET status = ?, error = ? WHERE job_id = ?').run('killed', 'client closed', jobId); + } catch { /* best-effort */ } + // Schedule cleanup with the normal grace period. + this.janitor.scheduleCleanup(job.outDir, getTranscodeConfig().cacheGraceMs); + // Return the live process so the caller can terminate it asynchronously. + return (job.process && job.process.exitCode === null) ? job.process : null; + } + + /** Terminate a raw FFmpeg process (graceful → SIGTERM → SIGKILL). */ + async killProcess(proc: ChildProcessWithoutNullStreams): Promise { + if (proc.exitCode !== null) return; + try { proc.stdin.write('q\n'); } catch { /* already gone */ } + const exited = await waitForExit(proc, 3000); + if (!exited && proc.exitCode === null) { + proc.kill('SIGTERM'); + await waitForExit(proc, 2000); + } + if (proc.exitCode === null) { + proc.kill('SIGKILL'); + } + } + + async kill(jobId: string, reason = 'client closed', cleanupDelay = getTranscodeConfig().cacheGraceMs): Promise { + // If markKilled() already ran, the job is gone from the map but we might + // still need to wait for FFmpeg to exit. Re-check via a local reference. + const job = this.jobs.get(jobId); + if (!job) { + // Job was already removed by markKilled(); just return. + return; + } + + job.status = reason === 'completed' ? 'ready' : 'killed'; + job.error = reason === 'completed' ? undefined : reason; + this.heartbeat.clear(jobId); + job.throttler?.stop(); + + const proc = job.process; + if (proc && proc.exitCode === null) { + try { + proc.stdin.write('q\n'); + } catch { + // Process may already be gone. + } + const exited = await waitForExit(proc, 3000); + if (!exited && proc.exitCode === null) { + proc.kill('SIGTERM'); + await waitForExit(proc, 2000); + } + if (proc.exitCode === null) { + proc.kill('SIGKILL'); + } + } + + getDatabase().prepare('UPDATE transcode_jobs SET status = ?, error = ? WHERE job_id = ?').run(job.status, job.error || null, jobId); + this.gate.release(); + this.jobs.delete(jobId); + this.janitor.scheduleCleanup(job.outDir, cleanupDelay); + } + + private findReusableJob(mediaId: number, profile: TranscodeProfileName, startTime: number): TranscodeJob | undefined { + const now = Date.now(); + return Array.from(this.jobs.values()).find(job => + job.mediaId === mediaId && + job.profile === profile && + Math.abs(job.startTime - startTime) < 1 && + (job.status === 'encoding' || job.status === 'ready') && + now - job.lastPingAt < 30_000 + ); + } + + private getMedia(mediaId: number): MediaRow { + const media = getDatabase().prepare("SELECT id, path, codec_info FROM media WHERE id = ? AND type = 'video'").get(mediaId) as MediaRow | undefined; + if (!media) throw new Error('Video not found'); + if (!fs.existsSync(media.path)) throw new Error('Video file not found on disk'); + return media; + } + + private async getProbe(media: MediaRow): Promise { + const stored = parseStoredProbe(media.codec_info); + if (stored) return stored; + const probed = await probeMedia(media.path); + getDatabase().prepare('UPDATE media SET codec_info = ? WHERE id = ?').run(JSON.stringify(probed), media.id); + return probed; + } + + private persistJob(job: TranscodeJob): void { + getDatabase().prepare(` + INSERT INTO transcode_jobs (job_id, media_id, profile, out_dir, start_ts, status, pid, last_ping_at, error) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) + `).run(job.id, job.mediaId, job.profile, job.outDir, job.startTime, job.status, null, null); + } + + private async spawnJob(job: TranscodeJob): Promise { + const config = getTranscodeConfig(); + const args = job.decision.kind === 'hls-remux' + ? buildRemuxArgs(job.mediaPath, job.outDir, job.startTime) + : buildTranscodeArgs(job.mediaPath, job.outDir, job.profile as Exclude, job.startTime); + + const proc = spawn(config.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + job.process = proc; + job.status = 'encoding'; + job.throttler = new TranscodeThrottler(proc); + job.throttler.start(); + + getDatabase().prepare("UPDATE transcode_jobs SET status = 'encoding', pid = ? WHERE job_id = ?").run(proc.pid || null, job.id); + this.heartbeat.arm(job.id, config.heartbeatTimeoutMs, () => { + this.kill(job.id, 'heartbeat timeout').catch(error => console.error('[Transcode] heartbeat kill failed', error)); + }); + + proc.stderr.setEncoding('utf8'); + proc.stderr.on('data', (chunk: string) => { + const lines = chunk.split(/\r?\n/).filter(Boolean); + for (const line of lines) { + job.stderrRing.push(line); + if (job.stderrRing.length > 50) job.stderrRing.shift(); + job.throttler?.updateProgress(line); + } + }); + + proc.on('exit', code => { + job.throttler?.stop(); + if (code === 0 && job.status !== 'killed') { + job.status = 'ready'; + getDatabase().prepare("UPDATE transcode_jobs SET status = 'ready' WHERE job_id = ?").run(job.id); + } else if (job.status !== 'killed') { + job.status = 'failed'; + job.error = job.stderrRing.slice(-10).join('\n') || `ffmpeg exited with code ${code}`; + getDatabase().prepare("UPDATE transcode_jobs SET status = 'failed', error = ? WHERE job_id = ?").run(job.error, job.id); + this.gate.release(); + } + }); + + await waitForFirstSegment(job.outDir, config.startupTimeoutMs); + } +} + +function waitForExit(proc: ChildProcessWithoutNullStreams, timeoutMs: number): Promise { + return new Promise(resolve => { + if (proc.exitCode !== null) { + resolve(true); + return; + } + + const timeout = setTimeout(() => { + cleanup(); + resolve(false); + }, timeoutMs); + timeout.unref(); + + const onExit = () => { + cleanup(); + resolve(true); + }; + + const cleanup = () => { + clearTimeout(timeout); + proc.off('exit', onExit); + }; + + proc.once('exit', onExit); + }); +} + +function waitForFirstSegment(outDir: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (listSegmentFiles(outDir).length > 0) { + resolve(); + return; + } + + const timeout = setTimeout(() => { + watcher.close(); + reject(new Error('TRANSCODE_STARTUP_TIMEOUT')); + }, timeoutMs); + timeout.unref(); + + const watcher = fs.watch(outDir, () => { + if (listSegmentFiles(outDir).length > 0) { + clearTimeout(timeout); + watcher.close(); + resolve(); + } + }); + }); +} + +const globalForTranscode = globalThis as typeof globalThis & { + nextavTranscodeOrchestrator?: TranscodeOrchestrator; +}; + +export const transcodeOrchestrator = + globalForTranscode.nextavTranscodeOrchestrator ?? new TranscodeOrchestrator(); + +globalForTranscode.nextavTranscodeOrchestrator = transcodeOrchestrator; + +export { ConcurrencyLimitError }; diff --git a/src/lib/transcode/playlist.ts b/src/lib/transcode/playlist.ts new file mode 100644 index 0000000..560969e --- /dev/null +++ b/src/lib/transcode/playlist.ts @@ -0,0 +1,50 @@ +import fs from 'fs'; +import path from 'path'; +import { getTranscodeConfig } from './config'; + +export interface PlaylistJob { + id: string; + outDir: string; + status: string; + knownDuration: number; + startTime: number; +} + +export function listSegmentFiles(outDir: string): string[] { + if (!fs.existsSync(outDir)) return []; + return fs.readdirSync(outDir) + .filter(file => /^seg_\d{5}\.ts$/.test(file)) + .sort(); +} + +export function buildPlaylist(job: PlaylistJob): string { + const config = getTranscodeConfig(); + const segments = listSegmentFiles(job.outDir); + const targetDuration = Math.ceil(config.segmentDuration + 1); + const lines = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + `#EXT-X-TARGETDURATION:${targetDuration}`, + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-INDEPENDENT-SEGMENTS', + ]; + + segments.forEach((segment, index) => { + const elapsed = index * config.segmentDuration; + const remaining = job.knownDuration > 0 ? Math.max(config.segmentDuration, job.knownDuration - job.startTime - elapsed) : config.segmentDuration; + lines.push(`#EXTINF:${Math.min(config.segmentDuration, remaining).toFixed(3)},`); + lines.push(`seg/${segment}`); + }); + + if (job.status === 'ready' || job.status === 'killed' || job.status === 'failed') { + lines.push('#EXT-X-ENDLIST'); + } + + return `${lines.join('\n')}\n`; +} + +export function segmentPath(outDir: string, name: string): string | null { + if (!/^seg_\d{5}\.ts$/.test(name)) return null; + return path.join(outDir, name); +} diff --git a/src/lib/transcode/profiles.ts b/src/lib/transcode/profiles.ts new file mode 100644 index 0000000..d435d2c --- /dev/null +++ b/src/lib/transcode/profiles.ts @@ -0,0 +1,37 @@ +export const PROFILES = { + '1080p-h264-aac': { + vCodec: 'h264', + vBitrate: '5000k', + maxHeight: 1080, + aCodec: 'aac', + aBitrate: '192k', + }, + '720p-h264-aac': { + vCodec: 'h264', + vBitrate: '2800k', + maxHeight: 720, + aCodec: 'aac', + aBitrate: '160k', + }, + '480p-h264-aac': { + vCodec: 'h264', + vBitrate: '1200k', + maxHeight: 480, + aCodec: 'aac', + aBitrate: '128k', + }, + 'remux-copy': { + vCodec: 'copy', + aCodec: 'copy', + }, +} as const; + +export type TranscodeProfileName = keyof typeof PROFILES; + +export function isTranscodeProfileName(value: string): value is TranscodeProfileName { + return value in PROFILES; +} + +export function getDefaultProfileName(value: string): TranscodeProfileName { + return isTranscodeProfileName(value) ? value : '720p-h264-aac'; +} diff --git a/src/lib/transcode/throttler.ts b/src/lib/transcode/throttler.ts new file mode 100644 index 0000000..f6a70ea --- /dev/null +++ b/src/lib/transcode/throttler.ts @@ -0,0 +1,51 @@ +import { ChildProcessWithoutNullStreams } from 'child_process'; + +export class TranscodeThrottler { + private timer?: NodeJS.Timeout; + private encoderPosition = 0; + private playheadPosition = 0; + private userPaused = false; + private ffmpegPaused = false; + + constructor(private readonly process: ChildProcessWithoutNullStreams) {} + + start(): void { + this.timer = setInterval(() => this.tick(), 1000); + this.timer.unref(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + updateProgress(stderrLine: string): void { + const match = stderrLine.match(/time=(\d{2}):(\d{2}):(\d{2}(?:\.\d+)?)/); + if (!match) return; + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + this.encoderPosition = hours * 3600 + minutes * 60 + seconds; + } + + updateClient(position: number, isPaused: boolean): void { + this.playheadPosition = Math.max(0, position); + this.userPaused = isPaused; + } + + private tick(): void { + const gap = this.encoderPosition - this.playheadPosition; + if (!this.ffmpegPaused && !this.userPaused && gap > 60) { + this.process.stdin.write('p\n'); + this.ffmpegPaused = true; + return; + } + + if (this.ffmpegPaused && (!this.userPaused || gap < 30)) { + this.process.stdin.write('u\n'); + this.ffmpegPaused = false; + } + } +} diff --git a/src/lib/ts-segmentation-service.ts b/src/lib/ts-segmentation-service.ts deleted file mode 100644 index dad960f..0000000 --- a/src/lib/ts-segmentation-service.ts +++ /dev/null @@ -1,772 +0,0 @@ -/** - * TS Segmentation Service - * Handles FFmpeg-based segmentation of merged .ts files for proper HLS playback - */ - -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { promisify } from 'util'; - -const mkdir = promisify(fs.mkdir); -const access = promisify(fs.access); -const stat = promisify(fs.stat); -const readdir = promisify(fs.readdir); -const unlink = promisify(fs.unlink); -const rmdir = promisify(fs.rmdir); - -export interface SegmentationSession { - videoId: number; - videoPath: string; - tempDir: string; - playlistPath: string; - segmentCount: number; - totalDuration: number; - createdAt: Date; - lastAccessed: Date; - status: 'pending' | 'processing' | 'ready' | 'error'; - error?: string; - referenceCount: number; // Track how many sessions are using this video -} - -export interface SegmentationConfig { - tempDir: string; - segmentDuration: number; - sessionTTL: number; - maxConcurrentJobs: number; - minDiskSpace: number; - cleanupInterval: number; - enableAutoCleanup: boolean; -} - -const DEFAULT_CONFIG: SegmentationConfig = { - tempDir: '/tmp/nextav-hls', - segmentDuration: 4, // Reduce from 6 to 4 seconds for smaller segments - sessionTTL: 30 * 60 * 1000, // 30 minutes - maxConcurrentJobs: 2, - minDiskSpace: 1024 * 1024 * 1024, // 1GB - cleanupInterval: 5 * 60 * 1000, // 5 minutes - enableAutoCleanup: true, -}; - -class TSSegmentationService { - private sessions = new Map(); // Map videoId -> session - private activeJobs = 0; - private config: SegmentationConfig; - private cleanupTimer?: NodeJS.Timeout; - - constructor(config: Partial = {}) { - this.config = { ...DEFAULT_CONFIG, ...config }; - this.ensureTempDir(); - - // Restore existing sessions from temp directories - this.restoreExistingSessions().catch(error => { - console.warn('[TSSegmentation] Error during session restoration:', error); - }); - - if (this.config.enableAutoCleanup) { - this.startCleanupScheduler(); - } - } - - /** - * Restore existing sessions from temp directories - */ - private async restoreExistingSessions(): Promise { - try { - if (!fs.existsSync(this.config.tempDir)) { - return; - } - - const entries = await readdir(this.config.tempDir); - let restoredCount = 0; - - for (const entry of entries) { - // Updated pattern: video-{videoId} (no session ID) - const match = entry.match(/^video-(\d+)$/); - if (match) { - const videoId = parseInt(match[1]); - const tempDir = path.join(this.config.tempDir, entry); - const playlistPath = path.join(tempDir, 'playlist.m3u8'); - - // Check if playlist exists and is valid - if (fs.existsSync(playlistPath)) { - try { - const playlistContent = fs.readFileSync(playlistPath, 'utf8'); - const segmentCount = (playlistContent.match(/\.ts/g) || []).length; - - // Extract duration from playlist - const durationMatch = playlistContent.match(/#EXTINF:([\d.]+)/g); - let totalDuration = 0; - if (durationMatch) { - totalDuration = durationMatch.reduce((total, match) => { - const duration = parseFloat(match.replace('#EXTINF:', '')); - return total + duration; - }, 0); - } - - // Get video path from database - const db = require('@/db').getDatabase(); - const video = db.prepare("SELECT path FROM media WHERE id = ? AND type = 'video'").get(videoId) as { path: string } | undefined; - - if (video) { - const session: SegmentationSession = { - videoId, - videoPath: video.path, - tempDir, - playlistPath, - segmentCount, - totalDuration, - createdAt: new Date(fs.statSync(tempDir).birthtime), - lastAccessed: new Date(), - status: 'ready', - referenceCount: 0, // Start with 0 references - }; - - this.sessions.set(videoId, session); - restoredCount++; - - console.log(`[TSSegmentation] Restored session for video ${videoId} (${segmentCount} segments)`); - } else { - console.warn(`[TSSegmentation] Video ${videoId} not found in database, cleaning up temp files`); - await this.cleanupSessionFiles({ tempDir } as SegmentationSession); - } - - } catch (error) { - console.warn(`[TSSegmentation] Could not restore session from ${entry}:`, error); - } - } - } else { - // Clean up old session-based directories that don't match new pattern - const oldMatch = entry.match(/^video-(\d+)-.+$/); - if (oldMatch) { - console.log(`[TSSegmentation] Cleaning up old session directory: ${entry}`); - const tempDir = path.join(this.config.tempDir, entry); - await this.cleanupSessionFiles({ tempDir } as SegmentationSession); - } - } - } - - if (restoredCount > 0) { - console.log(`[TSSegmentation] Restored ${restoredCount} existing sessions`); - } - - } catch (error) { - console.warn(`[TSSegmentation] Error restoring existing sessions:`, error); - } - } - - /** - * Check if a .ts file needs re-segmentation by detecting timestamp discontinuities - */ - async needsSegmentation(videoPath: string): Promise { - try { - const stat = await this.getFileStats(videoPath); - const fileSizeMB = stat.size / (1024 * 1024); - - console.log(`[TSSegmentation] Analyzing ${path.basename(videoPath)} (${fileSizeMB.toFixed(1)}MB)`); - - // First heuristic: Files larger than 100MB are likely merged from segments - if (fileSizeMB > 100) { - console.log(`[TSSegmentation] Large file detected (${fileSizeMB.toFixed(1)}MB > 100MB), likely merged segments`); - - // For large files, do more comprehensive analysis - const hasDiscontinuities = await this.analyzeTimestampDiscontinuities(videoPath, 200); // Check more packets - - if (hasDiscontinuities) { - console.log(`[TSSegmentation] Timestamp discontinuities found in large file`); - return true; - } - - // Additional check: analyze multiple points in the file - const hasMultipleDiscontinuities = await this.analyzeMultipleFileSegments(videoPath); - if (hasMultipleDiscontinuities) { - console.log(`[TSSegmentation] Multiple discontinuities found throughout file`); - return true; - } - - // For large files without obvious discontinuities, still consider segmentation - // This handles cases where segments were concatenated cleanly - if (fileSizeMB > 200) { - console.log(`[TSSegmentation] Very large file (${fileSizeMB.toFixed(1)}MB > 200MB), assuming merged segments`); - return true; - } - } - - // For smaller files, do standard analysis - const hasDiscontinuities = await this.analyzeTimestampDiscontinuities(videoPath, 100); - - const needsSegmentation = hasDiscontinuities; - console.log(`[TSSegmentation] File ${path.basename(videoPath)} needs segmentation: ${needsSegmentation}`); - - return needsSegmentation; - } catch (error) { - console.warn(`[TSSegmentation] Could not analyze ${videoPath}, assuming needs segmentation:`, error); - return true; // Default to segmentation if analysis fails - } - } - - /** - * Analyze timestamp discontinuities in the file - */ - private async analyzeTimestampDiscontinuities(videoPath: string, packetCount: number = 100): Promise { - try { - // Use ffprobe to check for timestamp discontinuities - const result = await this.executeFFprobe([ - '-v', 'quiet', - '-show_entries', 'packet=pts_time,dts_time,flags', - '-select_streams', 'v:0', - '-of', 'csv=nk=1:p=0', - '-read_intervals', `%+#${packetCount}`, // Check specified number of packets - videoPath - ]); - - const lines = result.split('\n').filter(line => line.trim()); - if (lines.length < 2) return false; - - // Check for significant timestamp jumps (indication of merged segments) - let prevPts = 0; - let discontinuityCount = 0; - let largeJumpCount = 0; - - for (const line of lines) { - const [ptsStr] = line.split(','); - const pts = parseFloat(ptsStr); - - if (pts > 0 && prevPts > 0) { - const diff = Math.abs(pts - prevPts); - // If timestamp jump is > 0.5 seconds, likely a discontinuity - if (diff > 0.5 && prevPts < pts) { - discontinuityCount++; - } - // Very large jumps (> 2 seconds) are strong indicators - if (diff > 2.0 && prevPts < pts) { - largeJumpCount++; - } - } - prevPts = pts; - } - - console.log(`[TSSegmentation] Timestamp analysis: ${discontinuityCount} discontinuities, ${largeJumpCount} large jumps in ${lines.length} packets`); - - // If we found discontinuities or large jumps, likely needs segmentation - return discontinuityCount > 0 || largeJumpCount > 0; - } catch (error) { - console.warn(`[TSSegmentation] Error analyzing timestamps:`, error); - return false; - } - } - - /** - * Analyze multiple segments of a large file to detect discontinuities - */ - private async analyzeMultipleFileSegments(videoPath: string): Promise { - try { - const stat = await this.getFileStats(videoPath); - const fileDuration = await this.getFileDuration(videoPath); - - if (!fileDuration || fileDuration < 10) { - return false; // Can't analyze very short files - } - - // Analyze 3 points: 25%, 50%, 75% through the file - const checkPoints = [0.25, 0.5, 0.75]; - let totalDiscontinuities = 0; - - for (const point of checkPoints) { - const timeOffset = fileDuration * point; - - try { - const result = await this.executeFFprobe([ - '-v', 'quiet', - '-ss', timeOffset.toString(), - '-show_entries', 'packet=pts_time,dts_time', - '-select_streams', 'v:0', - '-of', 'csv=nk=1:p=0', - '-read_intervals', '%+#50', // Check 50 packets at this point - videoPath - ]); - - const lines = result.split('\n').filter(line => line.trim()); - let discontinuities = 0; - let prevPts = 0; - - for (const line of lines) { - const [ptsStr] = line.split(','); - const pts = parseFloat(ptsStr); - - if (pts > 0 && prevPts > 0) { - const diff = Math.abs(pts - prevPts); - if (diff > 1.0) { // 1 second jump - discontinuities++; - } - } - prevPts = pts; - } - - totalDiscontinuities += discontinuities; - console.log(`[TSSegmentation] Analysis at ${(point * 100).toFixed(0)}% (${timeOffset.toFixed(1)}s): ${discontinuities} discontinuities`); - - } catch (error) { - console.warn(`[TSSegmentation] Error analyzing at ${point * 100}%:`, error); - } - } - - console.log(`[TSSegmentation] Total discontinuities across file: ${totalDiscontinuities}`); - return totalDiscontinuities > 0; - - } catch (error) { - console.warn(`[TSSegmentation] Error in multi-segment analysis:`, error); - return false; - } - } - - /** - * Get file statistics - */ - private async getFileStats(videoPath: string): Promise { - return stat(videoPath); - } - - /** - * Get file duration using FFprobe - */ - private async getFileDuration(videoPath: string): Promise { - try { - const result = await this.executeFFprobe([ - '-v', 'quiet', - '-show_entries', 'format=duration', - '-of', 'csv=p=0', - videoPath - ]); - - const duration = parseFloat(result.trim()); - return isNaN(duration) ? null : duration; - } catch (error) { - console.warn(`[TSSegmentation] Could not get duration for ${videoPath}:`, error); - return null; - } - } - - /** - * Create a new segmentation session or get existing one - */ - async createSegmentationSession(videoId: number, videoPath: string): Promise { - // Check if session already exists and is valid - const existingSession = this.sessions.get(videoId); - if (existingSession) { - if (existingSession.status === 'ready') { - existingSession.lastAccessed = new Date(); - existingSession.referenceCount++; - console.log(`[TSSegmentation] Reusing existing session for video ${videoId} (refs: ${existingSession.referenceCount})`); - return existingSession; - } else if (existingSession.status === 'processing') { - // Return the existing processing session - existingSession.referenceCount++; - return existingSession; - } - } - - // Check available disk space - await this.checkDiskSpace(); - - // Check concurrent job limit - if (this.activeJobs >= this.config.maxConcurrentJobs) { - throw new Error('Maximum concurrent segmentation jobs reached'); - } - - // Use video ID as the directory name (no session ID needed) - const tempDir = path.join(this.config.tempDir, `video-${videoId}`); - const playlistPath = path.join(tempDir, 'playlist.m3u8'); - - const session: SegmentationSession = { - videoId, - videoPath, - tempDir, - playlistPath, - segmentCount: 0, - totalDuration: 0, - createdAt: new Date(), - lastAccessed: new Date(), - status: 'pending', - referenceCount: 1, - }; - - this.sessions.set(videoId, session); - - try { - // Create temp directory - await mkdir(tempDir, { recursive: true }); - - // Start segmentation process - session.status = 'processing'; - this.activeJobs++; - - await this.performSegmentation(session, videoPath); - - session.status = 'ready'; - console.log(`[TSSegmentation] Session created successfully for video ${videoId}`); - - } catch (error: any) { - session.status = 'error'; - session.error = error.message; - console.error(`[TSSegmentation] Failed to create session for video ${videoId}:`, error); - - // Cleanup failed session - await this.cleanupSessionFiles(session); - this.sessions.delete(videoId); - throw error; - } finally { - this.activeJobs--; - } - - return session; - } - - /** - * Add a reference to an existing session - */ - async addReference(videoId: number): Promise { - const session = this.sessions.get(videoId); - if (session && session.status === 'ready') { - // Validate that files still exist before adding reference - if (!fs.existsSync(session.playlistPath)) { - console.warn(`[TSSegmentation] Session ${videoId} files missing, removing invalid session`); - this.sessions.delete(videoId); - return null; - } - - session.lastAccessed = new Date(); - session.referenceCount++; - console.log(`[TSSegmentation] Added reference to video ${videoId} (refs: ${session.referenceCount})`); - return session; - } - return null; - } - - /** - * Remove a reference from a session - */ - async removeReference(videoId: number): Promise { - const session = this.sessions.get(videoId); - if (session) { - session.referenceCount = Math.max(0, session.referenceCount - 1); - console.log(`[TSSegmentation] Removed reference from video ${videoId} (refs: ${session.referenceCount})`); - - // If no more references and session is old enough, mark for cleanup - if (session.referenceCount === 0) { - const timeSinceLastAccess = Date.now() - session.lastAccessed.getTime(); - if (timeSinceLastAccess > 300000) { // 5 minute grace period - console.log(`[TSSegmentation] Cleaning up unreferenced session for video ${videoId} after ${Math.round(timeSinceLastAccess/1000)}s`); - await this.cleanupSession(videoId); - } - } - } - } - - /** - * Get an existing session - */ - async getSession(videoId: number): Promise { - const session = this.sessions.get(videoId); - if (session) { - session.lastAccessed = new Date(); - - // Validate that the session files still exist - if (session.status === 'ready' && !fs.existsSync(session.playlistPath)) { - console.warn(`[TSSegmentation] Session ${videoId} exists but files are missing, cleaning up`); - this.sessions.delete(videoId); - return null; - } - - return session; - } - return null; - } - - /** - * Get the file path for a specific segment - */ - async getSegmentPath(videoId: number, segmentIndex: number): Promise { - const session = await this.getSession(videoId); - if (!session || session.status !== 'ready') { - return null; - } - - const segmentPath = path.join(session.tempDir, `segment_${segmentIndex.toString().padStart(3, '0')}.ts`); - - try { - await access(segmentPath); - return segmentPath; - } catch { - return null; - } - } - - /** - * Cleanup a specific session - */ - async cleanupSession(videoId: number): Promise { - const session = this.sessions.get(videoId); - if (session) { - await this.cleanupSessionFiles(session); - this.sessions.delete(videoId); - console.log(`[TSSegmentation] Cleaned up session for video ${videoId}`); - } - } - - /** - * Cleanup expired sessions (now based on reference count and age) - */ - async cleanupExpiredSessions(): Promise { - const now = new Date(); - const expiredSessions: number[] = []; - - for (const [videoId, session] of this.sessions.entries()) { - const timeSinceLastAccess = now.getTime() - session.lastAccessed.getTime(); - - // Only cleanup sessions with no references and that haven't been accessed recently - if (session.referenceCount === 0 && timeSinceLastAccess > this.config.sessionTTL) { - expiredSessions.push(videoId); - } - } - - for (const videoId of expiredSessions) { - await this.cleanupSession(videoId); - } - - if (expiredSessions.length > 0) { - console.log(`[TSSegmentation] Cleaned up ${expiredSessions.length} expired sessions`); - } - } - - /** - * Perform FFmpeg segmentation - */ - private async performSegmentation(session: SegmentationSession, videoPath: string): Promise { - const args = [ - '-i', videoPath, - '-c:v', 'copy', - '-c:a', 'copy', - '-f', 'hls', - '-hls_time', this.config.segmentDuration.toString(), - '-hls_list_size', '0', - '-hls_segment_filename', path.join(session.tempDir, 'segment_%03d.ts'), - '-hls_flags', 'append_list', - '-y', - session.playlistPath - ]; - - console.log(`[TSSegmentation] Starting FFmpeg segmentation: ffmpeg ${args.join(' ')}`); - - const result = await this.executeFFmpeg(args); - - // Parse the generated playlist to get segment info - if (fs.existsSync(session.playlistPath)) { - const playlistContent = fs.readFileSync(session.playlistPath, 'utf8'); - session.segmentCount = (playlistContent.match(/\.ts/g) || []).length; - - // Extract total duration from playlist - const durationMatch = playlistContent.match(/#EXTINF:([\d.]+)/g); - if (durationMatch) { - session.totalDuration = durationMatch.reduce((total, match) => { - const duration = parseFloat(match.replace('#EXTINF:', '')); - return total + duration; - }, 0); - } - - // Update the playlist to use proper API URLs - await this.updatePlaylistUrls(session); - - console.log(`[TSSegmentation] Segmentation complete: ${session.segmentCount} segments, ${session.totalDuration.toFixed(2)}s total`); - } else { - throw new Error('Playlist file was not created'); - } - } - - /** - * Update playlist URLs to use proper API endpoints - */ - private async updatePlaylistUrls(session: SegmentationSession): Promise { - try { - const playlistContent = fs.readFileSync(session.playlistPath, 'utf8'); - - // Replace relative segment filenames with API URLs - // Keep the original padded format (000, 001, etc.) to match the API route expectations - const updatedContent = playlistContent.replace( - /segment_(\d+)\.ts/g, - (match, segmentNum) => { - return `segment/${segmentNum}.ts`; - } - ); - - // Write the updated playlist back - fs.writeFileSync(session.playlistPath, updatedContent); - - console.log(`[TSSegmentation] Updated playlist URLs for video ${session.videoId}`); - console.log(`[TSSegmentation] Sample URLs: segment_000.ts -> segment/000.ts`); - } catch (error) { - console.error(`[TSSegmentation] Error updating playlist URLs:`, error); - throw error; - } - } - - /** - * Execute FFmpeg command - */ - private executeFFmpeg(args: string[]): Promise { - return new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', args); - let stdout = ''; - let stderr = ''; - - ffmpeg.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - ffmpeg.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - ffmpeg.on('close', (code) => { - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(`FFmpeg failed with code ${code}: ${stderr}`)); - } - }); - - ffmpeg.on('error', (error) => { - reject(new Error(`FFmpeg process error: ${error.message}`)); - }); - }); - } - - /** - * Execute FFprobe command - */ - private executeFFprobe(args: string[]): Promise { - return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', args); - let stdout = ''; - let stderr = ''; - - ffprobe.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - ffprobe.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - ffprobe.on('close', (code) => { - if (code === 0) { - resolve(stdout); - } else { - reject(new Error(`FFprobe failed with code ${code}: ${stderr}`)); - } - }); - - ffprobe.on('error', (error) => { - reject(new Error(`FFprobe process error: ${error.message}`)); - }); - }); - } - - /** - * Cleanup session files - */ - private async cleanupSessionFiles(session: SegmentationSession): Promise { - try { - if (fs.existsSync(session.tempDir)) { - const files = await readdir(session.tempDir); - for (const file of files) { - await unlink(path.join(session.tempDir, file)); - } - await rmdir(session.tempDir); - } - } catch (error) { - console.warn(`[TSSegmentation] Error cleaning up session files:`, error); - } - } - - /** - * Ensure temp directory exists - */ - private async ensureTempDir(): Promise { - try { - await mkdir(this.config.tempDir, { recursive: true }); - } catch (error) { - console.error(`[TSSegmentation] Failed to create temp directory:`, error); - } - } - - /** - * Check available disk space - */ - private async checkDiskSpace(): Promise { - try { - const stats = await stat(this.config.tempDir); - // This is a simplified check - in production, you'd want to check actual available space - console.log(`[TSSegmentation] Temp directory exists, assuming sufficient space`); - } catch (error) { - throw new Error('Insufficient disk space for segmentation'); - } - } - - /** - * Generate unique session ID - */ - private generateSessionId(): string { - return Date.now().toString(36) + Math.random().toString(36).substr(2); - } - - /** - * Start cleanup scheduler - */ - private startCleanupScheduler(): void { - this.cleanupTimer = setInterval(() => { - this.cleanupExpiredSessions().catch(error => { - console.error('[TSSegmentation] Cleanup scheduler error:', error); - }); - }, this.config.cleanupInterval); - - console.log(`[TSSegmentation] Cleanup scheduler started (interval: ${this.config.cleanupInterval}ms)`); - console.log(`[TSSegmentation] Service initialized with restoration capability`); - } - - /** - * Stop cleanup scheduler - */ - public stopCleanupScheduler(): void { - if (this.cleanupTimer) { - clearInterval(this.cleanupTimer); - this.cleanupTimer = undefined; - console.log('[TSSegmentation] Cleanup scheduler stopped'); - } - } - - /** - * Get current statistics - */ - public getStats() { - return { - activeSessions: this.sessions.size, - activeJobs: this.activeJobs, - config: this.config, - sessions: Array.from(this.sessions.values()).map(session => ({ - videoId: session.videoId, - status: session.status, - segmentCount: session.segmentCount, - totalDuration: session.totalDuration, - referenceCount: session.referenceCount, - createdAt: session.createdAt, - lastAccessed: session.lastAccessed, - })) - }; - } -} - -// Export singleton instance -export const tsSegmentationService = new TSSegmentationService(); -export default tsSegmentationService; \ No newline at end of file diff --git a/src/lib/video-format-detector.ts b/src/lib/video-format-detector.ts index 4de77ea..f460cfc 100644 --- a/src/lib/video-format-detector.ts +++ b/src/lib/video-format-detector.ts @@ -62,10 +62,10 @@ const HLS_COMPATIBLE_FORMATS = [ // MPEG Transport Stream formats - TEMPORARY: treat as local player required // TODO: Fix the .ts resegmentation implementation for HLS streaming -const TS_STREAM_FORMATS = [ +const TS_STREAM_FORMATS: string[] = [ // 'ts', // TEMPORARILY DISABLED - send to local player instead - 'm2ts', // Blu-ray Transport Stream - 'mts' // AVCHD Transport Stream + // 'm2ts', // Blu-ray Transport Stream + // 'mts' // AVCHD Transport Stream ]; // Formats with limited support (may need transcoding) @@ -191,27 +191,28 @@ function createDirectFormat(video: VideoFile, extension: string): VideoFormat { * Create HLS streaming format configuration */ function createHLSFormat(video: VideoFile, extension: string): VideoFormat { + const url = `/api/transcode/start?mediaId=${video.id}`; return { type: 'hls', supportLevel: 'hls', - url: `/api/stream/hls/${video.id}/playlist.m3u8`, + url, qualities: [ { html: 'Auto', - url: `/api/stream/hls/${video.id}/playlist.m3u8`, + url, default: true }, { html: '1080p', - url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=1080` + url: `${url}&profile=1080p-h264-aac` }, { html: '720p', - url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=720` + url: `${url}&profile=720p-h264-aac` }, { html: '480p', - url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=480` + url: `${url}&profile=480p-h264-aac` } ] }; @@ -222,14 +223,15 @@ function createHLSFormat(video: VideoFile, extension: string): VideoFormat { * .ts files are already in HLS-compatible format, so we use HLS streaming */ function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat { + const url = `/api/transcode/start?mediaId=${video.id}`; return { type: 'hls', supportLevel: 'hls', - url: `/api/stream/hls/${video.id}/playlist.m3u8`, + url, qualities: [ { html: 'Auto (HLS)', - url: `/api/stream/hls/${video.id}/playlist.m3u8`, + url, default: true }, { diff --git a/tests/streaming/test-transcode-lifecycle.mjs b/tests/streaming/test-transcode-lifecycle.mjs new file mode 100644 index 0000000..1240b93 --- /dev/null +++ b/tests/streaming/test-transcode-lifecycle.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +/** + * End-to-end test for the live transcoding lifecycle (v2 rewrite). + * + * Covers the staged changes: + * - POST /api/transcode/start -> job + playlist URL + * - master.m3u8 serves a VOD playlist immediately + * - seg/ waits up to 5s for a segment to exist (waitForSegment) + * - ping keeps the job alive; status reports it + * - seek within the encoded window is reused (204) + * - DELETE is fast (markKilled) and segment/playlist 404 immediately after + * - the FFmpeg process actually exits afterwards + * - repeated DELETE is idempotent + * + * Usage: + * node tests/streaming/test-transcode-lifecycle.mjs [mediaId] + * + * Requires the dev server running with ENABLE_LIVE_TRANSCODE=true and a media + * row whose format decision is hls-remux or hls-transcode (e.g. a .mov). + */ + +const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; +const MEDIA_ID = Number(process.argv[2] || process.env.TEST_MEDIA_ID || 127); + +let failures = 0; + +function ok(condition, label, extra = '') { + if (condition) { + console.log(` ✅ ${label}${extra ? ` (${extra})` : ''}`); + } else { + failures += 1; + console.log(` ❌ ${label}${extra ? ` (${extra})` : ''}`); + } +} + +async function request(path, { method = 'GET', body, timeoutMs = 15_000 } = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = Date.now(); + try { + const response = await fetch(`${BASE_URL}${path}`, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + const text = await response.text(); + return { + status: response.status, + headers: response.headers, + body: text, + json: (() => { try { return JSON.parse(text); } catch { return null; } })(), + elapsedMs: Date.now() - startedAt, + }; + } finally { + clearTimeout(timer); + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function main() { + console.log(`🧪 Transcode lifecycle e2e — media #${MEDIA_ID} @ ${BASE_URL}`); + console.log('======================================================'); + + // ── 1. Start a transcode job ────────────────────────────────────── + console.log('\n1. Start transcode job'); + const start = await request('/api/transcode/start', { method: 'POST', body: { mediaId: MEDIA_ID, startTime: 0 } }); + ok(start.status === 200, 'POST /api/transcode/start -> 200', `elapsed ${start.elapsedMs}ms`); + if (start.status !== 200) { + console.log(` response: ${start.body.slice(0, 300)}`); + process.exit(1); + } + const jobId = start.json.jobId; + const playlistUrl = start.json.playlistUrl; + ok(typeof jobId === 'string' && jobId.length > 0, 'jobId returned'); + ok(typeof playlistUrl === 'string' && playlistUrl.includes(jobId), 'playlistUrl returned'); + ok(start.json.decision === 'hls-remux' || start.json.decision === 'hls-transcode', `decision ${start.json.decision}`); + + // ── 2. Playlist serves a VOD playlist ───────────────────────────── + console.log('\n2. Master playlist'); + const playlist = await request(playlistUrl); + ok(playlist.status === 200, `GET ${playlistUrl} -> 200`, `elapsed ${playlist.elapsedMs}ms`); + ok(playlist.body.includes('#EXTM3U'), 'contains #EXTM3U'); + ok(playlist.body.includes('#EXT-X-PLAYLIST-TYPE:VOD'), 'playlist type VOD'); + const segmentMatch = playlist.body.match(/seg\/seg_\d{5}\.ts/); + ok(!!segmentMatch, 'references a segment file'); + + // ── 3. Segment delivery (includes waitForSegment path) ──────────── + console.log('\n3. Segment delivery'); + const firstSegmentName = segmentMatch[0].replace('seg/', ''); + const firstSeg = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`); + ok(firstSeg.status === 200, `GET ${firstSegmentName} -> 200`, `elapsed ${firstSeg.elapsedMs}ms`); + ok(firstSeg.headers.get('content-type') === 'video/mp2t', 'content-type video/mp2t'); + ok(Number(firstSeg.headers.get('content-length') || 0) > 0, 'segment non-empty'); + + // A segment a bit further ahead: waitForSegment polls until ffmpeg writes it. + const aheadIndex = String(30).padStart(5, '0'); + const aheadName = `seg_${aheadIndex}.ts`; + const ahead = await request(`/api/transcode/${jobId}/seg/${aheadName}`, { timeoutMs: 10_000 }); + ok(ahead.status === 200, `GET ${aheadName} (waitForSegment) -> 200`, `elapsed ${ahead.elapsedMs}ms`); + + // A segment that will never exist: should poll for ~5s then 404. + const neverSeg = await request(`/api/transcode/${jobId}/seg/seg_99999.ts`, { timeoutMs: 10_000 }); + ok(neverSeg.status === 404, 'GET seg_99999.ts -> 404', `elapsed ${neverSeg.elapsedMs}ms (expected ~5000)`); + ok(neverSeg.elapsedMs >= 4500, 'waited for segment before 404ing (poll path exercised)'); + + // ── 4. Ping keeps the job alive ─────────────────────────────────── + console.log('\n4. Heartbeat ping'); + const ping = await request(`/api/transcode/${jobId}/ping`, { method: 'POST', body: { position: 0, isPaused: false } }); + ok(ping.status === 200 && ping.json?.success === true, 'POST ping -> 200'); + + const statusBefore = await request('/api/transcode/status'); + const listed = statusBefore.json?.jobs?.find(j => j.id === jobId); + ok(statusBefore.status === 200 && !!listed, 'job appears in /api/transcode/status', listed ? `status=${listed.status}` : ''); + + // ── 5. Seek within the encoded window is reused ─────────────────── + console.log('\n5. Seek within encoded window'); + const seek = await request(`/api/transcode/${jobId}/seek`, { method: 'POST', body: { position: 0 } }); + ok(seek.status === 204, 'POST seek {position:0} -> 204 (job reused)'); + + // ── 6. DELETE fast-path + immediate 404s ───────────────────────── + console.log('\n6. DELETE (markKilled fast path)'); + const del = await request(`/api/transcode/${jobId}`, { method: 'DELETE' }); + ok(del.status === 200 && del.json?.success === true, 'DELETE -> 200 success', `elapsed ${del.elapsedMs}ms`); + ok(del.elapsedMs < 1000, 'DELETE responds in <1s (markKilled is synchronous)', `${del.elapsedMs}ms`); + + const playlistAfter = await request(playlistUrl); + ok(playlistAfter.status === 404, 'playlist 404 immediately after DELETE'); + const segAfter = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`); + ok(segAfter.status === 404, 'segment 404 immediately after DELETE'); + + const statusAfter = await request('/api/transcode/status'); + ok(!statusAfter.json?.jobs?.some(j => j.id === jobId), 'job removed from status'); + + // ── 7. FFmpeg process actually exits ────────────────────────────── + console.log('\n7. FFmpeg process termination'); + await sleep(5000); + const { execSync } = await import('node:child_process'); + let ffmpegStillRunning = false; + try { + const out = execSync(`pgrep -fl "${jobId}" || true`, { encoding: 'utf8' }).trim(); + ffmpegStillRunning = out.length > 0; + if (ffmpegStillRunning) console.log(` lingering: ${out}`); + } catch { + // pgrep unavailable — treat as pass-through, best-effort check + } + ok(!ffmpegStillRunning, 'no FFmpeg process remains for the job after grace period'); + + // ── 8. Idempotent DELETE ────────────────────────────────────────── + console.log('\n8. Idempotent DELETE'); + const delAgain = await request(`/api/transcode/${jobId}`, { method: 'DELETE' }); + ok(delAgain.status === 200, 'second DELETE -> 200 (no crash)'); + + // ── 9. Unknown job 404s ─────────────────────────────────────────── + console.log('\n9. Unknown job handling'); + const missing = await request('/api/transcode/00000000-0000-0000-0000-000000000000/master.m3u8'); + ok(missing.status === 404, 'playlist for unknown job -> 404'); + + console.log('\n======================================================'); + if (failures === 0) { + console.log(`🎉 ALL CHECKS PASSED (job ${jobId})`); + process.exit(0); + } else { + console.log(`💥 ${failures} check(s) FAILED (job ${jobId})`); + process.exit(1); + } +} + +main().catch(error => { + console.error('❌ Test runner crashed:', error); + process.exit(1); +});