Compare commits

..

3 Commits

Author SHA1 Message Date
tigerenwork c6b15c5791 chore: update media.db 2026-08-07 22:38:55 +08:00
tigerenwork 1732a16ce2 fix(transcode): fast job teardown and resilient HLS delivery
- DELETE markKilled removes the job synchronously; FFmpeg is killed in the background so playlist/segment routes 404 immediately
- segment route waits up to 5s for in-progress segment writes
- centralize player teardown (pause, destroy HLS, clear media sources) to stop lingering audio/requests on close
- track and release transcode jobs on player close/reopen and reset format to avoid reusing killed job URLs
- add e2e transcode lifecycle test
2026-08-06 23:34:14 +08:00
tigerenwork e9150ce0d7 feat: 重写live transcoding 2026-05-23 21:00:23 +08:00
42 changed files with 2332 additions and 2925 deletions

Binary file not shown.

View File

@ -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 nonnativelysupported 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 |
|------|-------------|
| Crossdevice playback (phone, tablet, TV browser) | "I can't watch my MKVs on iPad" |
| Singleclick inbrowser 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/Jellyfinclass 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 <seek> -i <file>` (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 9min 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 reprobed), 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 byterange 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 <t>` 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/<videoId>/<profile>/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<jobId, TranscodeJob>) │
│ ├─ 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/<videoId>/<profile>) │
│ ├─ 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 (singlevariant 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 <START_TS> \ # input seek for fast start
-copyts \ # keep original timestamps for accurate playlist
-i "<INPUT_PATH>" \
-map 0:v:0 -map 0:a:0? \
-c:v <VCODEC> \ # h264_videotoolbox|h264_nvenc|h264_qsv|libx264
-preset <PRESET> \ # libx264: veryfast | hw: p4/quality
-profile:v main -level 4.1 \
-pix_fmt yuv420p \
-vf "scale=-2:'min(<MAX_H>,ih)':flags=lanczos" \
-b:v <VBITRATE> -maxrate <VBITRATE> -bufsize <2*VBITRATE> \
-force_key_frames "expr:gte(t,n_forced*4)" \
-c:a aac -b:a <ABITRATE> -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 "<OUT_DIR>/seg_%05d.ts" \
-start_number 0 \
"<OUT_DIR>/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 AnnexB for MPEGTS).
### 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 <out_dir>
├─ 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/<jobId>/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/<id>
├─ hls-* → POST /api/transcode/start
│ ArtPlayer + hls.js with src=<playlistUrl>
│ 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 <path>" |
| 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/<id>` 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/<jobId>/sub/<idx>.vtt`. Not in Phase 14; 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.

View File

@ -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<string, number>);
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`;
}
}

View File

@ -101,8 +101,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
direct: `${baseUrl}/api/stream/direct/${video.id}`, direct: `${baseUrl}/api/stream/direct/${video.id}`,
external: `${baseUrl}/api/external-stream/${video.id}`, external: `${baseUrl}/api/external-stream/${video.id}`,
// HLS streaming (if supported) // Live transcoding starts with POST /api/transcode/start and returns the playlist URL.
hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`, hls: `${baseUrl}/api/transcode/start`,
// Protocol-specific URLs for external players // Protocol-specific URLs for external players
protocols: { protocols: {
@ -116,7 +116,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Browser-compatible formats // Browser-compatible formats
browser: { browser: {
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null, native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`, transcoded: `${baseUrl}/api/transcode/start`,
} }
}; };

View File

@ -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<string, Promise<Response>>();
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<Response> {
// 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 });
}
}

View File

@ -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',
},
});
}

View File

@ -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<Response> {
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',
},
});
}

View File

@ -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',
},
});
}

View File

@ -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<Response> {
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<Response> {
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',
},
});
}

View File

@ -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',
},
});
}

View File

@ -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',
},
});
}

View File

@ -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,
},
});
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}
}

View File

@ -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<boolean> {
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': '*',
},
});
}

View File

@ -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 });
}
}

View File

@ -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(),
});
}

View File

@ -74,9 +74,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
}, },
streaming: { streaming: {
direct_url: `/api/stream/direct/${video.id}`, 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}`, 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_range_requests: format.supportLevel === 'native',
supports_adaptive_bitrate: format.type === 'hls' supports_adaptive_bitrate: format.type === 'hls'
} }

View File

@ -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 });
}
}

View File

@ -24,6 +24,7 @@ interface ArtPlayerWrapperProps {
showBookmarks?: boolean; showBookmarks?: boolean;
showRatings?: boolean; showRatings?: boolean;
autoplay?: boolean; autoplay?: boolean;
formatOverride?: VideoFormat;
} }
export default function ArtPlayerWrapper({ export default function ArtPlayerWrapper({
@ -41,11 +42,14 @@ export default function ArtPlayerWrapper({
avgRating = 0, avgRating = 0,
showBookmarks = false, showBookmarks = false,
showRatings = false, showRatings = false,
autoplay = true autoplay = true,
formatOverride
}: ArtPlayerWrapperProps) { }: ArtPlayerWrapperProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<Artplayer | null>(null); const playerRef = useRef<Artplayer | null>(null);
const hlsInstanceRef = useRef<Hls | null>(null); // Store HLS instance for cleanup const hlsInstanceRef = useRef<Hls | null>(null); // Store HLS instance for cleanup
const formatOverrideRef = useRef<VideoFormat | undefined>(formatOverride);
const mediaElementsRef = useRef<Set<HTMLVideoElement>>(new Set());
const [format, setFormat] = useState<VideoFormat | null>(null); const [format, setFormat] = useState<VideoFormat | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -59,6 +63,102 @@ export default function ArtPlayerWrapper({
const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount); const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount);
const [localAvgRating, setLocalAvgRating] = useState(avgRating); const [localAvgRating, setLocalAvgRating] = useState(avgRating);
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null); const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
const hlsShuttingDownRef = useRef<boolean>(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<string, unknown>;
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<HTMLMediaElement>('video, audio').forEach(mediaEl => {
mediaEl.pause();
if (clearSource) {
mediaEl.removeAttribute('src');
mediaEl.load();
}
});
if (clearSource) {
document.querySelectorAll<HTMLMediaElement>('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 <video> element.
// Calling video.load() while HLS owns the MediaSource puts the element
// in a conflicting state where HLS can't cleanly abort its XHR requests.
mediaElementsRef.current.forEach(videoEl => {
try { videoEl.pause(); } catch (_) { /* ignore */ }
});
containerRef.current?.querySelectorAll<HTMLMediaElement>('video, audio').forEach(el => {
try { el.pause(); } catch (_) { /* ignore */ }
});
// ──── Step 2: Destroy HLS.js (stops all network requests) ────
// Monkey-patch recovery methods so in-flight error callbacks can't
// trigger a recovery loop between stopLoad() and destroy().
if (hlsInstanceRef.current) {
try {
hlsInstanceRef.current.startLoad = () => {};
(hlsInstanceRef.current as any).recoverMediaError = () => {};
} catch (_) { /* ignore */ }
// stopLoad() cancels pending fragment requests.
// destroy() internally calls detachMedia() (removes the MediaSource
// from the <video>) and aborts all remaining XHR — do NOT call
// detachMedia() separately, as that causes destroy() to skip XHR cleanup.
try { hlsInstanceRef.current.stopLoad(); } catch (_) { /* ignore */ }
try { hlsInstanceRef.current.destroy(); } catch (_) { /* ignore */ }
hlsInstanceRef.current = null;
}
hlsErrorHandlerRef.current?.detach();
hlsErrorHandlerRef.current = null;
// ──── Step 3: Clear media sources and destroy ArtPlayer ────
// Now that HLS is gone, scrub any remaining src/srcObject on media
// elements so the browser releases the underlying decode resources.
stopTrackedMedia(true);
if (playerRef.current) {
try { playerRef.current.pause(); } catch (_) { /* ignore */ }
try { playerRef.current.destroy(); } catch (_) { /* ignore */ }
playerRef.current = null;
}
mediaElementsRef.current.clear();
resetArtPlayerContainer();
};
const handleClose = () => {
releasePlayer();
onClose();
};
// Prevent ALL scrolling when video player is open // Prevent ALL scrolling when video player is open
useEffect(() => { useEffect(() => {
@ -125,10 +225,18 @@ export default function ArtPlayerWrapper({
setLocalAvgRating(avgRating); setLocalAvgRating(avgRating);
}, [isBookmarked, bookmarkCount, avgRating]); }, [isBookmarked, bookmarkCount, avgRating]);
// Keep formatOverrideRef in sync (no re-init needed, handled separately)
useEffect(() => {
formatOverrideRef.current = formatOverride;
}, [formatOverride]);
// Initialize ArtPlayer // Initialize ArtPlayer
useEffect(() => { useEffect(() => {
if (!useArtPlayer || !isOpen || !containerRef.current) return; if (!useArtPlayer || !isOpen || !containerRef.current) return;
// Prevent duplicate instances/audio when React dev mode re-runs effects.
releasePlayer();
// Inject custom styles to remove shadows // Inject custom styles to remove shadows
const styleId = 'artplayer-styles'; const styleId = 'artplayer-styles';
if (!document.getElementById(styleId)) { if (!document.getElementById(styleId)) {
@ -142,7 +250,10 @@ export default function ArtPlayerWrapper({
setError(null); setError(null);
try { try {
const detectedFormat = detectVideoFormat(video); // Read from ref so this effect does NOT re-run when formatOverride changes
const detectedFormat = formatOverrideRef.current || detectVideoFormat(video);
const qualityOptions = cloneQualities(detectedFormat.qualities);
const settingsQualityOptions = cloneQualities(detectedFormat.qualities);
setFormat(detectedFormat); setFormat(detectedFormat);
// HLS.js plugin for ArtPlayer // HLS.js plugin for ArtPlayer
@ -184,7 +295,7 @@ export default function ArtPlayerWrapper({
theme: '#3b82f6', // Blue theme theme: '#3b82f6', // Blue theme
// Quality control (for HLS) // Quality control (for HLS)
quality: detectedFormat.qualities || [], quality: qualityOptions,
// Subtitle support // Subtitle support
subtitle: { subtitle: {
@ -202,7 +313,7 @@ export default function ArtPlayerWrapper({
{ {
html: 'Quality', html: 'Quality',
icon: '<span class="artplayer-icon-settings-quality">⚙️</span>', icon: '<span class="artplayer-icon-settings-quality">⚙️</span>',
selector: detectedFormat.qualities || [], selector: settingsQualityOptions,
onSelect: function(item: any) { onSelect: function(item: any) {
console.log('Quality selected:', item); console.log('Quality selected:', item);
if (hlsInstance && item.level !== undefined) { if (hlsInstance && item.level !== undefined) {
@ -218,30 +329,45 @@ export default function ArtPlayerWrapper({
// Custom initialization for HLS // Custom initialization for HLS
customType: { customType: {
m3u8: function(video: HTMLVideoElement, url: string) { m3u8: function(video: HTMLVideoElement, url: string) {
mediaElementsRef.current.add(video);
if (Hls.isSupported()) { if (Hls.isSupported()) {
// Reset shutdown flag — we're starting fresh
hlsShuttingDownRef.current = false;
hlsInstance = new Hls({ hlsInstance = new Hls({
debug: process.env.NODE_ENV === 'development', // Route HLS internal errors through console.warn so they don't
enableWorker: true, // trigger the Next.js dev overlay (which intercepts console.error).
lowLatencyMode: false, // Disable for better buffering debug: process.env.NODE_ENV === 'development' ? {
backBufferLength: 90, trace: () => {},
maxBufferLength: 120, // Increase buffer length debug: () => {},
maxBufferSize: 100 * 1000 * 1000, // 100MB buffer log: () => {},
maxBufferHole: 0.5, // Allow small holes info: () => {},
startLevel: -1, // Auto-select optimal quality warn: (...a: unknown[]) => console.debug('[HLS]', ...a),
error: (...a: unknown[]) => console.warn('[HLS]', ...a),
} : false,
// Disable the web worker so XHR abort is synchronous on the main
// thread — eliminates worker-race requests after hls.destroy().
enableWorker: false,
lowLatencyMode: false,
backBufferLength: 30,
// Smaller buffer = fewer in-flight segments when user closes video.
maxBufferLength: 30,
maxBufferSize: 60 * 1000 * 1000, // 60MB
maxBufferHole: 0.5,
startLevel: -1,
capLevelToPlayerSize: true, capLevelToPlayerSize: true,
autoStartLoad: true, autoStartLoad: true,
maxFragLookUpTolerance: 0.25, maxFragLookUpTolerance: 0.25,
liveSyncDurationCount: 3, liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10, liveMaxLatencyDurationCount: 10,
// Aggressive preloading for better buffering
manifestLoadingTimeOut: 10000, manifestLoadingTimeOut: 10000,
manifestLoadingMaxRetry: 2, // Fewer retries so recovery loops die quickly.
manifestLoadingMaxRetry: 1,
levelLoadingTimeOut: 10000, levelLoadingTimeOut: 10000,
levelLoadingMaxRetry: 2, levelLoadingMaxRetry: 1,
fragLoadingTimeOut: 20000, // Longer timeout for large segments fragLoadingTimeOut: 20000,
fragLoadingMaxRetry: 3, fragLoadingMaxRetry: 1,
// Bandwidth estimation settings abrEwmaDefaultEstimate: 500000,
abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
abrBandWidthFactor: 0.95, abrBandWidthFactor: 0.95,
abrBandWidthUpFactor: 0.7, abrBandWidthUpFactor: 0.7,
}); });
@ -307,7 +433,10 @@ export default function ArtPlayerWrapper({
}); });
hlsInstance.on(Hls.Events.ERROR, (event: string, data: any) => { hlsInstance.on(Hls.Events.ERROR, (event: string, data: any) => {
console.error('HLS error details:', { // If we're shutting down, ignore all errors — no recovery needed.
if (hlsShuttingDownRef.current) return;
console.warn('HLS error details:', {
type: data.type, type: data.type,
details: data.details, details: data.details,
fatal: data.fatal, fatal: data.fatal,
@ -329,7 +458,7 @@ export default function ArtPlayerWrapper({
hlsInstance?.recoverMediaError(); hlsInstance?.recoverMediaError();
break; break;
default: default:
console.error('HLS fatal error, cannot recover'); console.warn('HLS fatal error, cannot recover');
setError('HLS streaming failed. Falling back to direct playback.'); setError('HLS streaming failed. Falling back to direct playback.');
// This will trigger fallback in the parent component // This will trigger fallback in the parent component
break; break;
@ -360,6 +489,10 @@ export default function ArtPlayerWrapper({
} }
}); });
if (player.video) {
mediaElementsRef.current.add(player.video as HTMLVideoElement);
}
// Event listeners // Event listeners
player.on('ready', () => { player.on('ready', () => {
console.log('ArtPlayer ready'); console.log('ArtPlayer ready');
@ -394,6 +527,33 @@ export default function ArtPlayerWrapper({
} }
}); });
player.on('video:seeking', () => {
const activeJobId = getTranscodeJobId(detectedFormat.url);
if (!activeJobId) return;
fetch(`/api/transcode/${activeJobId}/seek`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ position: player.currentTime })
}).then(async response => {
if (response.status === 204) return;
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Seek restart failed');
}
const data = await response.json();
setFormat({
type: 'hls',
supportLevel: 'hls',
url: data.playlistUrl,
qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }]
});
}).catch(error => {
console.warn('[ArtPlayer] Transcode seek handling failed:', error);
});
});
player.on('video:loadedmetadata', () => { player.on('video:loadedmetadata', () => {
setDuration(player.duration); setDuration(player.duration);
}); });
@ -431,28 +591,7 @@ export default function ArtPlayerWrapper({
return () => { return () => {
console.log('[ArtPlayer] Starting cleanup...'); console.log('[ArtPlayer] Starting cleanup...');
releasePlayer();
// Stop HLS loading immediately
if (hlsInstanceRef.current) {
console.log('[ArtPlayer] Stopping HLS loading...');
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
// Destroy player
if (playerRef.current) {
console.log('[ArtPlayer] Destroying player...');
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up HLS error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
console.log('[ArtPlayer] Cleanup completed'); console.log('[ArtPlayer] Cleanup completed');
}; };
} catch (error) { } catch (error) {
@ -460,7 +599,30 @@ export default function ArtPlayerWrapper({
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`); setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
setIsLoading(false); setIsLoading(false);
} }
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, format?.supportLevel]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay]);
useEffect(() => {
const activeJobId = getTranscodeJobId(format?.url);
if (!isOpen || !activeJobId) return;
const ping = () => {
fetch(`/api/transcode/${activeJobId}/ping`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
position: playerRef.current?.currentTime || currentTime,
isPaused: !isPlaying,
}),
}).catch(error => {
console.warn('[ArtPlayer] Failed to ping transcode session:', error);
});
};
ping();
const interval = window.setInterval(ping, 20_000);
return () => window.clearInterval(interval);
}, [isOpen, format?.url, currentTime, isPlaying]);
// Handle bookmark toggle // Handle bookmark toggle
const handleBookmarkToggle = useCallback(async () => { const handleBookmarkToggle = useCallback(async () => {
@ -551,7 +713,7 @@ export default function ArtPlayerWrapper({
switch (e.key) { switch (e.key) {
case 'Escape': case 'Escape':
e.preventDefault(); e.preventDefault();
onClose(); handleClose();
break; break;
case ' ': case ' ':
e.preventDefault(); e.preventDefault();
@ -584,32 +746,13 @@ export default function ArtPlayerWrapper({
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isPlaying]); }, [isOpen, isPlaying]);
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
console.log('[ArtPlayer] Unmount cleanup...'); console.log('[ArtPlayer] Unmount cleanup...');
releasePlayer();
// Stop HLS loading
if (hlsInstanceRef.current) {
console.log('[ArtPlayer] Stopping HLS on unmount...');
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
// Destroy player
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up HLS error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
// Clean up custom styles // Clean up custom styles
const styleElement = document.getElementById('artplayer-styles'); const styleElement = document.getElementById('artplayer-styles');
@ -623,38 +766,9 @@ export default function ArtPlayerWrapper({
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
console.log('[ArtPlayer] Modal closed, stopping HLS...'); console.log('[ArtPlayer] Modal closed, stopping HLS...');
releasePlayer();
// Stop HLS loading immediately when modal closes
if (hlsInstanceRef.current) {
hlsInstanceRef.current.stopLoad();
hlsInstanceRef.current.destroy();
hlsInstanceRef.current = null;
}
// Also destroy player when modal closes
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
// Clean up error handler
if (hlsErrorHandlerRef.current) {
hlsErrorHandlerRef.current.detach();
hlsErrorHandlerRef.current = null;
}
// Release backend session reference
if (format?.type === 'hls') {
fetch(`/api/stream/hls/${video.id}/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'release_reference' })
}).catch(error => {
console.warn('[ArtPlayer] Failed to release session reference:', error);
});
}
} }
}, [isOpen, format?.type, video.id]); }, [isOpen]);
if (!isOpen) return null; if (!isOpen) return null;
@ -663,7 +777,7 @@ export default function ArtPlayerWrapper({
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8"> <div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
{/* Close button */} {/* Close button */}
<button <button
onClick={onClose} onClick={handleClose}
className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors" className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
> >
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -823,3 +937,9 @@ export default function ArtPlayerWrapper({
</div> </div>
); );
} }
function getTranscodeJobId(url?: string): string | null {
if (!url) return null;
const match = url.match(/\/api\/transcode\/([^/]+)\//);
return match?.[1] || null;
}

View File

@ -37,6 +37,7 @@ interface LocalPlayerLauncherProps {
onRate?: (id: number, rating: number) => Promise<void>; onRate?: (id: number, rating: number) => Promise<void>;
showBookmarks?: boolean; showBookmarks?: boolean;
showRatings?: boolean; showRatings?: boolean;
notice?: string;
} }
interface PlayerInfo { interface PlayerInfo {
@ -149,7 +150,8 @@ export default function LocalPlayerLauncher({
onUnbookmark, onUnbookmark,
onRate, onRate,
showBookmarks = true, showBookmarks = true,
showRatings = true showRatings = true,
notice
}: LocalPlayerLauncherProps) { }: LocalPlayerLauncherProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]); const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
@ -549,6 +551,12 @@ export default function LocalPlayerLauncher({
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{notice && (
<Alert>
<AlertDescription>{notice}</AlertDescription>
</Alert>
)}
{/* Video Info with Bookmark & Rating */} {/* Video Info with Bookmark & Rating */}
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border"> <div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector'; import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
import ArtPlayerWrapper from '@/components/artplayer-wrapper'; import ArtPlayerWrapper from '@/components/artplayer-wrapper';
import LocalPlayerLauncher from '@/components/local-player-launcher'; import LocalPlayerLauncher from '@/components/local-player-launcher';
@ -44,6 +44,41 @@ export default function UnifiedVideoPlayer({
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true); const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
const [currentRating, setCurrentRating] = useState(0); const [currentRating, setCurrentRating] = useState(0);
const [ratingCheckLoading, setRatingCheckLoading] = useState(true); const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
const [transcodeLoading, setTranscodeLoading] = useState(false);
const [transcodeError, setTranscodeError] = useState<string | null>(null);
const [activeTranscodeJobId, setActiveTranscodeJobId] = useState<string | null>(null);
const activeTranscodeJobIdRef = useRef<string | null>(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 // Check current bookmark status and rating when video opens
useEffect(() => { 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(() => { useEffect(() => {
if (video) { if (video && isOpen) {
console.log('[UnifiedVideoPlayer] Detecting format for video:', video); 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); console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
setFormat(detectedFormat);
setIsLoading(false); 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 // Handle ArtPlayer errors with recovery
const handleArtPlayerError = useCallback((error: string) => { const handleArtPlayerError = useCallback((error: string) => {
@ -170,7 +268,7 @@ export default function UnifiedVideoPlayer({
<LocalPlayerLauncher <LocalPlayerLauncher
video={video} video={video}
format={format} format={format}
onClose={onClose} onClose={handleClose}
onPlayerSelect={(playerId) => { onPlayerSelect={(playerId) => {
console.log(`Selected player: ${playerId}`); console.log(`Selected player: ${playerId}`);
}} }}
@ -180,6 +278,7 @@ export default function UnifiedVideoPlayer({
onRate={handleRatingUpdate} onRate={handleRatingUpdate}
showBookmarks={showBookmarks} showBookmarks={showBookmarks}
showRatings={showRatings} showRatings={showRatings}
notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined}
/> />
); );
} }
@ -190,7 +289,7 @@ export default function UnifiedVideoPlayer({
<ArtPlayerWrapper <ArtPlayerWrapper
video={video} video={video}
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={handleClose}
onProgress={handleProgressUpdate} onProgress={handleProgressUpdate}
onBookmark={handleBookmarkToggle} onBookmark={handleBookmarkToggle}
onUnbookmark={handleUnbookmark} onUnbookmark={handleUnbookmark}
@ -203,16 +302,17 @@ export default function UnifiedVideoPlayer({
showBookmarks={showBookmarks} showBookmarks={showBookmarks}
showRatings={showRatings} showRatings={showRatings}
autoplay={autoplay} autoplay={autoplay}
formatOverride={format || undefined}
/> />
); );
}; };
if (isLoading || bookmarkCheckLoading || ratingCheckLoading) { if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) {
return ( return (
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"> <div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
<div className="text-white text-center"> <div className="text-white text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
<p>Loading ArtPlayer...</p> <p>{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}</p>
</div> </div>
</div> </div>
); );

View File

@ -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 // 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_bookmarks_media_id ON bookmarks(media_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(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_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_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_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; return db;
} }

View File

@ -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<string, FFmpegProcessInfo>();
/**
* 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);
});
}

View File

@ -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<number, SessionHeartbeat>();
private sessionStats = new Map<number, {
requestCount: number;
firstRequest: Date;
lastRequest: Date;
}>();
constructor() {
super();
this.setupEventHandlers();
}
/**
* Create or get existing session for video
*/
async getOrCreateSession(videoId: number, videoPath: string): Promise<SegmentationSession> {
// 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<number> {
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<void> {
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<void> {
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;

View File

@ -100,20 +100,7 @@ export function useProtectedDuration({
console.log(`[DURATION] API response not ok:`, response.status, response.statusText); console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
} }
// 2. Try transcoding headers if transcoding // 2. Use fallback when database duration is unavailable.
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
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`); console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
setDuration(fallbackDuration); setDuration(fallbackDuration);
@ -157,16 +144,6 @@ export async function getRealDuration(videoId: string): Promise<number> {
} }
} }
// 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; return 0;
} catch (error) { } catch (error) {
console.error('[DURATION] Error getting real duration:', error); console.error('[DURATION] Error getting real duration:', error);

View File

@ -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),
};
}
}

View File

@ -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,
};
}

View File

@ -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 };
}

View File

@ -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<TranscodeProfileName, 'remux-copy'>, 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'),
];
}

View File

@ -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<ProbeInfo> {
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,
};
}

View File

@ -0,0 +1,24 @@
export class HeartbeatManager {
private timers = new Map<string, NodeJS.Timeout>();
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);
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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<string, TranscodeJob>();
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<TranscodeJob> {
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<void> {
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<void> {
// 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<ProbeInfo> {
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<void> {
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<TranscodeProfileName, 'remux-copy'>, 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<boolean> {
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<void> {
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 };

View File

@ -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);
}

View File

@ -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';
}

View File

@ -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;
}
}
}

View File

@ -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<number, SegmentationSession>(); // Map videoId -> session
private activeJobs = 0;
private config: SegmentationConfig;
private cleanupTimer?: NodeJS.Timeout;
constructor(config: Partial<SegmentationConfig> = {}) {
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<void> {
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<boolean> {
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<boolean> {
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<boolean> {
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<any> {
return stat(videoPath);
}
/**
* Get file duration using FFprobe
*/
private async getFileDuration(videoPath: string): Promise<number | null> {
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<SegmentationSession> {
// 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<SegmentationSession | null> {
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<void> {
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<SegmentationSession | null> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<string> {
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<void> {
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<void> {
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<void> {
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;

View File

@ -62,10 +62,10 @@ const HLS_COMPATIBLE_FORMATS = [
// MPEG Transport Stream formats - TEMPORARY: treat as local player required // MPEG Transport Stream formats - TEMPORARY: treat as local player required
// TODO: Fix the .ts resegmentation implementation for HLS streaming // 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 // 'ts', // TEMPORARILY DISABLED - send to local player instead
'm2ts', // Blu-ray Transport Stream // 'm2ts', // Blu-ray Transport Stream
'mts' // AVCHD Transport Stream // 'mts' // AVCHD Transport Stream
]; ];
// Formats with limited support (may need transcoding) // Formats with limited support (may need transcoding)
@ -191,27 +191,28 @@ function createDirectFormat(video: VideoFile, extension: string): VideoFormat {
* Create HLS streaming format configuration * Create HLS streaming format configuration
*/ */
function createHLSFormat(video: VideoFile, extension: string): VideoFormat { function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
const url = `/api/transcode/start?mediaId=${video.id}`;
return { return {
type: 'hls', type: 'hls',
supportLevel: 'hls', supportLevel: 'hls',
url: `/api/stream/hls/${video.id}/playlist.m3u8`, url,
qualities: [ qualities: [
{ {
html: 'Auto', html: 'Auto',
url: `/api/stream/hls/${video.id}/playlist.m3u8`, url,
default: true default: true
}, },
{ {
html: '1080p', html: '1080p',
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=1080` url: `${url}&profile=1080p-h264-aac`
}, },
{ {
html: '720p', html: '720p',
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=720` url: `${url}&profile=720p-h264-aac`
}, },
{ {
html: '480p', 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 * .ts files are already in HLS-compatible format, so we use HLS streaming
*/ */
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat { function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
const url = `/api/transcode/start?mediaId=${video.id}`;
return { return {
type: 'hls', type: 'hls',
supportLevel: 'hls', supportLevel: 'hls',
url: `/api/stream/hls/${video.id}/playlist.m3u8`, url,
qualities: [ qualities: [
{ {
html: 'Auto (HLS)', html: 'Auto (HLS)',
url: `/api/stream/hls/${video.id}/playlist.m3u8`, url,
default: true default: true
}, },
{ {

View File

@ -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);
});