feat: 重写live transcoding
This commit is contained in:
parent
4a164fee21
commit
e9150ce0d7
BIN
data/media.db
BIN
data/media.db
Binary file not shown.
|
|
@ -0,0 +1,579 @@
|
|||
# Live Transcoding Reintroduction — Comprehensive Implementation Proposal
|
||||
|
||||
> Status: **Backlog / Design Proposal**
|
||||
> Author: Engineering
|
||||
> Target Branch: `feature/live-transcode-v2`
|
||||
> Related Docs:
|
||||
> - `docs/active/media-streaming/TRANSCODING_REMOVAL_DESIGN.md`
|
||||
> - `docs/active/media-streaming/TRANSCODING_REMOVAL_SUMMARY.md`
|
||||
> - `docs/archive/transcoding-legacy/JELLYFIN_TRANSCODING_ARCHITECTURE.md`
|
||||
> - `docs/archive/transcoding-legacy/STASH-ANALYSIS-AND-SOLUTION-PLAN.md`
|
||||
> - `docs/archive/transcoding-legacy/03-process-management-transcoding.md`
|
||||
> - `docs/archive/transcoding-legacy/ANTI-JITTER-IMPLEMENTATION.md`
|
||||
> - `docs/archive/transcoding-legacy/VIDEO-PLAYER-REPLACEMENT-PLAN.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Bring Live Transcoding Back?
|
||||
|
||||
NextAV currently routes any non‑natively‑supported format (MKV/AVI/WMV/FLV/MOV/TS/etc.) to a **Local Player Launcher** (VLC/IINA/PotPlayer). This eliminated server CPU usage but came at three real costs:
|
||||
|
||||
| Loss | User Impact |
|
||||
|------|-------------|
|
||||
| Cross‑device playback (phone, tablet, TV browser) | "I can't watch my MKVs on iPad" |
|
||||
| Single‑click in‑browser playback for all formats | Friction: install app, allow protocol, retry |
|
||||
| Adaptive bitrate over slow networks | Large files unplayable over LAN/WAN |
|
||||
|
||||
The goal of v2 is to bring back **YouTube/Jellyfin‑class live transcoding** that does **not** repeat the historical pain points (process leaks, jitter, broken seek, duration corruption, runaway CPU).
|
||||
|
||||
---
|
||||
|
||||
## 2. Honest Postmortem of v1 (Why It Was Removed)
|
||||
|
||||
From the legacy docs and `archive/transcoding-legacy/*`, the v1 transcoder failed at six things at once:
|
||||
|
||||
### 2.1 Process Lifecycle
|
||||
- FFmpeg child processes orphaned on browser close / route change.
|
||||
- No central registry → could not enumerate or kill cleanly.
|
||||
- No client heartbeat → processes ran forever when client died silently.
|
||||
- `kill('SIGKILL')` raced with the HTTP stream close, leading to half-shut pipes and `EPIPE`/`ECONNRESET`.
|
||||
|
||||
### 2.2 Seeking
|
||||
- Old design transcoded **from t=0** on every seek. A seek to 1h00m wasted 1h of CPU before producing one byte.
|
||||
- The Stash analysis (`STASH-ANALYSIS-AND-SOLUTION-PLAN.md`) correctly identified that the fix is `-ss <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 9‑min video) and jumped backwards as new fragments arrived.
|
||||
- `ANTI-JITTER-IMPLEMENTATION.md` patched the symptom in the UI, not the cause (lack of a known duration / HLS playlist with `#EXT-X-ENDLIST`).
|
||||
|
||||
### 2.4 Format Detection
|
||||
- Decision was made per request based on a JSON blob in `media.codec_info`. That blob was sometimes missing, sometimes wrong (the scanner only ran ffprobe once and never re‑probed), so the same file would sometimes stream direct, sometimes transcode.
|
||||
|
||||
### 2.5 Resource Contention
|
||||
- No concurrency cap. Three simultaneous 1080p `libx264 -preset veryfast` jobs would saturate any consumer CPU and starve thumbnail generation and the Next.js event loop.
|
||||
- No throttling: FFmpeg encoded as fast as possible and ran 60+ seconds ahead of the player, wasting CPU if the user closed the tab.
|
||||
|
||||
### 2.6 HLS Half-Implementation
|
||||
- `src/app/api/stream/hls/...` exists today and generates a **fake playlist** that points at byte‑range slices of the source file (`ts-segmentation-service.ts`). For real `.ts` Transport Streams this can work, but for arbitrary inputs (MKV/AVI) the resulting "segments" are not valid TS and players fail with `bufferAppendError`.
|
||||
- The `hls-session-manager.ts` heartbeat exists but nothing in the player calls it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design Principles for v2
|
||||
|
||||
1. **HLS-first, MP4-fragment second.** All live transcoding produces an HLS playlist + MPEG-TS (or fMP4) segments on disk. The browser sees a finite playlist with a real `#EXT-X-PLAYLIST-TYPE:VOD` and `#EXT-X-ENDLIST`, so `duration` is correct from frame 1 and seeking works without restarts inside the encoded window.
|
||||
2. **Stash-style seek = restart.** Seeks outside the already-encoded window kill the FFmpeg job and restart with `-ss <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 (single‑variant playlist only). Quality switching = new job.
|
||||
|
||||
---
|
||||
|
||||
## 6. Format Decision Engine
|
||||
|
||||
```ts
|
||||
// src/lib/transcode/decision.ts
|
||||
type Decision =
|
||||
| { kind: 'direct' } // browser can play the file as-is
|
||||
| { kind: 'hls-remux'; profile: 'remux-copy' } // codecs OK, container not
|
||||
| { kind: 'hls-transcode'; profile: '720p-h264-aac' | ... };
|
||||
```
|
||||
|
||||
Rules, evaluated in order:
|
||||
|
||||
1. If `extension ∈ {mp4,m4v,webm,ogg,ogv}` **and** `videoCodec ∈ {h264,vp8,vp9,av1}` **and** `audioCodec ∈ {aac,opus,vorbis}` → `direct`.
|
||||
2. If `videoCodec ∈ {h264}` **and** `audioCodec ∈ {aac}` but container is anything else (mkv, ts, mov) → `hls-remux` (`-c copy`, basically free CPU).
|
||||
3. If `videoCodec ∈ {hevc,vp9,av1}` and the requesting User-Agent indicates support (Safari/Chrome with hardware decode) → `direct` with a probe header (HTTP 200 + `X-Probe: 1`); on player error within 5s, client posts `/api/transcode/start` to fall back.
|
||||
4. Otherwise → `hls-transcode` with the user's preferred profile (default 720p).
|
||||
|
||||
The decision is computed **fresh** on every `/api/transcode/start` using a server-side ffprobe of the file (cached in `media.codec_info` with a TTL of 7 days and a `codec_info_version` integer so we can invalidate the whole cache atomically).
|
||||
|
||||
---
|
||||
|
||||
## 7. FFmpeg Command Templates
|
||||
|
||||
### 7.1 Transcode (the hot path)
|
||||
|
||||
```bash
|
||||
ffmpeg \
|
||||
-hide_banner -loglevel error -nostats \
|
||||
-ss <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 Annex‑B for MPEG‑TS).
|
||||
|
||||
### 7.3 Hardware Acceleration
|
||||
|
||||
Probed once at boot by running `ffmpeg -hide_banner -encoders | grep h264_`. Selection priority:
|
||||
|
||||
1. **macOS dev**: `h264_videotoolbox` (zero CPU, ships with macOS)
|
||||
2. **Linux + NVIDIA**: `h264_nvenc`
|
||||
3. **Linux + Intel iGPU**: `h264_qsv` (requires `libmfx`)
|
||||
4. **Fallback everywhere**: `libx264 -preset veryfast`
|
||||
|
||||
The choice is recorded in `/api/transcode/status` and surfaced in Settings → "Transcoding Backend: h264_videotoolbox (auto)".
|
||||
|
||||
---
|
||||
|
||||
## 8. Process Lifecycle (the part v1 got wrong)
|
||||
|
||||
```
|
||||
client click play
|
||||
│
|
||||
▼
|
||||
POST /api/transcode/start
|
||||
│ ┌─────────────────────────────────────────────────────────┐
|
||||
│ │ ConcurrencyGate.acquire() ── may 503 with Retry-After │
|
||||
│ └─────────────────────────────────────────────────────────┘
|
||||
▼
|
||||
orchestrator.startJob(mediaId, profile, startTs)
|
||||
│
|
||||
├─ pick existing job? (same key, alive, ping <30s) → return it
|
||||
├─ ffprobe file (cached) → decide remux vs transcode
|
||||
├─ mkdir <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 1–4; track as a follow-up.
|
||||
- **Q3**: Multi-audio tracks. Same story as subtitles — out of scope for v2, but the profile key already supports `+audioTrack` for future use.
|
||||
- **R1**: Process management is **the** historical failure point. Phase 1 must be ruthlessly tested with `lsof`, `ps`, and crash injection before any encoding work begins. If Phase 1 cannot demonstrate zero leaks under chaos testing, **abort the whole effort** and keep the local-player approach.
|
||||
- **R2**: Browser HLS behavior varies. Safari handles HLS natively; Chrome/Firefox need hls.js. The chosen `MEDIA-SEQUENCE` / `DISCONTINUITY` strategy must be validated on all three. Reserve 1 week of Phase 4 just for cross-browser bug triage.
|
||||
|
||||
---
|
||||
|
||||
## 19. Decision Required Before Coding
|
||||
|
||||
1. Approve the **HLS-on-disk** approach (vs. the v1 piped-MP4 approach). *Strongly recommended; everything else in this doc depends on it.*
|
||||
2. Approve **single-variant playlists** for v2 (no ABR).
|
||||
3. Confirm the **delete-first** stance on legacy code (§13). If we keep v1 stubs around, the new code paths will be ambiguous and the old bugs will resurface.
|
||||
4. Confirm `MAX_CONCURRENT_TRANSCODES=2` as the default for a personal/home server. Adjust upward only when the target deployment is a multi-user server.
|
||||
|
||||
Once these four are signed off, work can start with Phase 0 cleanup.
|
||||
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
@ -101,8 +101,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
direct: `${baseUrl}/api/stream/direct/${video.id}`,
|
||||
external: `${baseUrl}/api/external-stream/${video.id}`,
|
||||
|
||||
// HLS streaming (if supported)
|
||||
hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
// Live transcoding starts with POST /api/transcode/start and returns the playlist URL.
|
||||
hls: `${baseUrl}/api/transcode/start`,
|
||||
|
||||
// Protocol-specific URLs for external players
|
||||
protocols: {
|
||||
|
|
@ -116,7 +116,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
// Browser-compatible formats
|
||||
browser: {
|
||||
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
|
||||
transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
transcoded: `${baseUrl}/api/transcode/start`,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
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;
|
||||
await transcodeOrchestrator.kill(jobId, 'client closed');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
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';
|
||||
|
||||
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 || !fs.existsSync(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': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
@ -74,9 +74,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ id:
|
|||
},
|
||||
streaming: {
|
||||
direct_url: `/api/stream/direct/${video.id}`,
|
||||
hls_url: format.type === 'hls' ? `/api/stream/hls/${video.id}/playlist.m3u8` : null,
|
||||
hls_url: format.type === 'hls' ? format.url : null,
|
||||
fallback_url: `/api/stream/${video.id}`,
|
||||
transcoding_url: `/api/stream/${video.id}/transcode`,
|
||||
transcoding_url: `/api/transcode/start`,
|
||||
supports_range_requests: format.supportLevel === 'native',
|
||||
supports_adaptive_bitrate: format.type === 'hls'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ interface ArtPlayerWrapperProps {
|
|||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
autoplay?: boolean;
|
||||
formatOverride?: VideoFormat;
|
||||
}
|
||||
|
||||
export default function ArtPlayerWrapper({
|
||||
|
|
@ -41,7 +42,8 @@ export default function ArtPlayerWrapper({
|
|||
avgRating = 0,
|
||||
showBookmarks = false,
|
||||
showRatings = false,
|
||||
autoplay = true
|
||||
autoplay = true,
|
||||
formatOverride
|
||||
}: ArtPlayerWrapperProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Artplayer | null>(null);
|
||||
|
|
@ -142,7 +144,7 @@ export default function ArtPlayerWrapper({
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const detectedFormat = detectVideoFormat(video);
|
||||
const detectedFormat = formatOverride || detectVideoFormat(video);
|
||||
setFormat(detectedFormat);
|
||||
|
||||
// HLS.js plugin for ArtPlayer
|
||||
|
|
@ -394,6 +396,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', () => {
|
||||
setDuration(player.duration);
|
||||
});
|
||||
|
|
@ -460,7 +489,29 @@ export default function ArtPlayerWrapper({
|
|||
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, format?.supportLevel]);
|
||||
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, formatOverride]);
|
||||
|
||||
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
|
||||
const handleBookmarkToggle = useCallback(async () => {
|
||||
|
|
@ -643,14 +694,10 @@ export default function ArtPlayerWrapper({
|
|||
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);
|
||||
const activeJobId = getTranscodeJobId(format?.url);
|
||||
if (activeJobId) {
|
||||
fetch(`/api/transcode/${activeJobId}`, { method: 'DELETE' }).catch(error => {
|
||||
console.warn('[ArtPlayer] Failed to release transcode session:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -823,3 +870,9 @@ export default function ArtPlayerWrapper({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranscodeJobId(url?: string): string | null {
|
||||
if (!url) return null;
|
||||
const match = url.match(/\/api\/transcode\/([^/]+)\//);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ interface LocalPlayerLauncherProps {
|
|||
onRate?: (id: number, rating: number) => Promise<void>;
|
||||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
|
|
@ -149,7 +150,8 @@ export default function LocalPlayerLauncher({
|
|||
onUnbookmark,
|
||||
onRate,
|
||||
showBookmarks = true,
|
||||
showRatings = true
|
||||
showRatings = true,
|
||||
notice
|
||||
}: LocalPlayerLauncherProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
||||
|
|
@ -549,6 +551,12 @@ export default function LocalPlayerLauncher({
|
|||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{notice && (
|
||||
<Alert>
|
||||
<AlertDescription>{notice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 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="flex items-start justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export default function UnifiedVideoPlayer({
|
|||
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
|
||||
const [currentRating, setCurrentRating] = useState(0);
|
||||
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
|
||||
const [transcodeLoading, setTranscodeLoading] = useState(false);
|
||||
const [transcodeError, setTranscodeError] = useState<string | null>(null);
|
||||
|
||||
// Check current bookmark status and rating when video opens
|
||||
useEffect(() => {
|
||||
|
|
@ -106,6 +108,50 @@ export default function UnifiedVideoPlayer({
|
|||
}
|
||||
}, [video]);
|
||||
|
||||
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) {
|
||||
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]);
|
||||
|
||||
// Handle ArtPlayer errors with recovery
|
||||
const handleArtPlayerError = useCallback((error: string) => {
|
||||
console.log('ArtPlayer encountered error:', error);
|
||||
|
|
@ -180,6 +226,7 @@ export default function UnifiedVideoPlayer({
|
|||
onRate={handleRatingUpdate}
|
||||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -203,16 +250,17 @@ export default function UnifiedVideoPlayer({
|
|||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
autoplay={autoplay}
|
||||
formatOverride={format || undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || bookmarkCheckLoading || ratingCheckLoading) {
|
||||
if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-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>
|
||||
<p>Loading ArtPlayer...</p>
|
||||
<p>{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -140,6 +140,24 @@ function initializeDatabase() {
|
|||
);
|
||||
`);
|
||||
|
||||
// Operational state for live transcoding jobs. Jobs are short-lived, but
|
||||
// persisting them lets the server recover cleanly after a restart.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS transcode_jobs (
|
||||
job_id TEXT PRIMARY KEY,
|
||||
media_id INTEGER NOT NULL,
|
||||
profile TEXT NOT NULL,
|
||||
out_dir TEXT NOT NULL,
|
||||
start_ts REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'encoding', 'ready', 'paused', 'failed', 'killed')),
|
||||
pid INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_ping_at DATETIME,
|
||||
error TEXT,
|
||||
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
|
||||
// Create indexes for performance
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
||||
|
|
@ -165,6 +183,8 @@ function initializeDatabase() {
|
|||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status);`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -100,20 +100,7 @@ export function useProtectedDuration({
|
|||
console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers if transcoding
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
setDuration(durationValue);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using header duration: ${durationValue}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use fallback
|
||||
// 2. Use fallback when database duration is unavailable.
|
||||
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
|
|
@ -157,16 +144,6 @@ export async function getRealDuration(videoId: string): Promise<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;
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error getting real duration:', error);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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'),
|
||||
];
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
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 };
|
||||
}
|
||||
|
||||
async kill(jobId: string, reason = 'client closed', cleanupDelay = getTranscodeConfig().cacheGraceMs): Promise<void> {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (!job) 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 };
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -62,10 +62,10 @@ const HLS_COMPATIBLE_FORMATS = [
|
|||
|
||||
// MPEG Transport Stream formats - TEMPORARY: treat as local player required
|
||||
// TODO: Fix the .ts resegmentation implementation for HLS streaming
|
||||
const TS_STREAM_FORMATS = [
|
||||
const TS_STREAM_FORMATS: string[] = [
|
||||
// 'ts', // TEMPORARILY DISABLED - send to local player instead
|
||||
'm2ts', // Blu-ray Transport Stream
|
||||
'mts' // AVCHD Transport Stream
|
||||
// 'm2ts', // Blu-ray Transport Stream
|
||||
// 'mts' // AVCHD Transport Stream
|
||||
];
|
||||
|
||||
// Formats with limited support (may need transcoding)
|
||||
|
|
@ -191,27 +191,28 @@ function createDirectFormat(video: VideoFile, extension: string): VideoFormat {
|
|||
* Create HLS streaming format configuration
|
||||
*/
|
||||
function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||
return {
|
||||
type: 'hls',
|
||||
supportLevel: 'hls',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
qualities: [
|
||||
{
|
||||
html: 'Auto',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
html: '1080p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=1080`
|
||||
url: `${url}&profile=1080p-h264-aac`
|
||||
},
|
||||
{
|
||||
html: '720p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=720`
|
||||
url: `${url}&profile=720p-h264-aac`
|
||||
},
|
||||
{
|
||||
html: '480p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=480`
|
||||
url: `${url}&profile=480p-h264-aac`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -222,14 +223,15 @@ function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
|||
* .ts files are already in HLS-compatible format, so we use HLS streaming
|
||||
*/
|
||||
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||
return {
|
||||
type: 'hls',
|
||||
supportLevel: 'hls',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
qualities: [
|
||||
{
|
||||
html: 'Auto (HLS)',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue