Compare commits
5 Commits
8b12de057f
...
e369168bd7
| Author | SHA1 | Date |
|---|---|---|
|
|
e369168bd7 | |
|
|
c6b15c5791 | |
|
|
1732a16ce2 | |
|
|
e9150ce0d7 | |
|
|
4a164fee21 |
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.
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
# Mobile-Friendly Sidebar & Responsive Layout Plan
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The current NextAV layout uses a **persistent left sidebar** (`w-64` expanded / `w-16` collapsed) alongside the main content area in a `flex h-screen` container. While this works well on desktop, it is **not mobile-friendly** for several reasons:
|
||||||
|
|
||||||
|
1. **The sidebar always occupies screen width** — on a 375px mobile screen, even the collapsed `w-16` (64px) sidebar consumes ~17% of the viewport, leaving only ~311px for content.
|
||||||
|
2. **No mobile navigation paradigm** — there is no hamburger menu, bottom navigation bar, or swipe-to-open drawer pattern. The sidebar is always rendered.
|
||||||
|
3. **The collapse toggle is not a mobile pattern** — the `ChevronLeft`/`ChevronRight` toggle on the sidebar is desktop-oriented; mobile users expect a hamburger icon in a top bar or a bottom tab bar.
|
||||||
|
4. **Content grids are too dense** — while `getColumnCount()` in the grids already supports 2 columns at `<640px`, the available width is further reduced by the always-visible sidebar.
|
||||||
|
5. **No `<meta name="viewport">` consideration** — the layout doesn't adapt its core shell for touch devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Architecture Analysis
|
||||||
|
|
||||||
|
### Root Layout ([layout.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/app/layout.tsx))
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `<Sidebar>` is always rendered as a direct flex child.
|
||||||
|
- No conditional rendering based on screen size.
|
||||||
|
- No shared state mechanism for mobile sidebar open/close.
|
||||||
|
|
||||||
|
### Sidebar ([sidebar.tsx](file:///Users/tigeren/Dev/xorbitlab/nextav/src/components/sidebar.tsx))
|
||||||
|
|
||||||
|
- **282 lines**, manages its own `isCollapsed` state locally.
|
||||||
|
- Contains: nav links (Home, Videos, Photos, Bookmarks, Surprise Me, Settings), collapsible Clusters section, collapsible Libraries section, and a footer.
|
||||||
|
- Width toggles between `w-64` and `w-16` via CSS transition.
|
||||||
|
- **No `md:` or `lg:` responsive breakpoint classes** — purely desktop-oriented.
|
||||||
|
|
||||||
|
### Content Pages
|
||||||
|
|
||||||
|
| Page | Component | Grid Behavior |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| `/videos` | `InfiniteVirtualGrid` | 2-7 columns based on `containerWidth` |
|
||||||
|
| `/photos` | `InfiniteVirtualGrid` | Same as above |
|
||||||
|
| `/bookmarks` | `InfiniteVirtualGrid` | Same as above |
|
||||||
|
| `/folder-viewer` | `VirtualizedFolderGrid` | 2-7 columns based on `containerWidth` |
|
||||||
|
| `/surprise-me` | CSS grid | `grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5` |
|
||||||
|
| `/settings` | Manual layout | `grid-cols-1 lg:grid-cols-3` |
|
||||||
|
| `/` (Home) | Manual layout | `grid-cols-1 md:grid-cols-2 lg:grid-cols-4` |
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The virtualized grids (`InfiniteVirtualGrid`, `VirtualizedFolderGrid`) already calculate column count responsively via `containerWidth`. They would benefit automatically from having more width available once the sidebar is hidden on mobile.
|
||||||
|
|
||||||
|
### Tailwind Configuration ([tailwind.config.ts](file:///Users/tigeren/Dev/xorbitlab/nextav/tailwind.config.ts))
|
||||||
|
|
||||||
|
- TailwindCSS **v3.4.17** (confirmed in `package.json`)
|
||||||
|
- Uses `darkMode: ["class"]` with `.dark` class on `<html>`
|
||||||
|
- Default breakpoints (`sm: 640px`, `md: 768px`, `lg: 1024px`, `xl: 1280px`, `2xl: 1536px`)
|
||||||
|
- No custom mobile breakpoints defined
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The following decisions were confirmed by the project owner:
|
||||||
|
> 1. **YouTube-style bottom tab bar** for primary mobile navigation (not just a hamburger drawer)
|
||||||
|
> 2. **`lg` breakpoint (1024px)** as the mobile/desktop cutoff — tablets in portrait get the mobile experience
|
||||||
|
> 3. **Clusters & Libraries sections collapsed by default** in the mobile drawer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Solution
|
||||||
|
|
||||||
|
### Approach: **Bottom Tab Bar + Hamburger Drawer + Persistent Desktop Sidebar**
|
||||||
|
|
||||||
|
On **desktop** (`lg` and above, ≥1024px): keep the current sidebar as-is (persistent, collapsible).
|
||||||
|
On **mobile/tablet** (`< lg`, <1024px): hide the sidebar, show a **YouTube-style bottom tab bar** for primary navigation, and provide a **hamburger menu in a slim top bar** that opens the full sidebar as a **slide-in drawer overlay** for secondary navigation (Clusters, Libraries, Settings).
|
||||||
|
|
||||||
|
This mirrors the YouTube mobile app pattern:
|
||||||
|
- **Bottom tab bar** = quick access to primary sections (Home, Videos, Photos, Bookmarks)
|
||||||
|
- **Hamburger drawer** = full sidebar with all sections including Clusters, Libraries, Settings
|
||||||
|
- **Top bar** = branding + hamburger trigger
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Shared Sidebar State (Context)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> The sidebar's open/close state currently lives inside `sidebar.tsx` as a local `useState`. To control it from the top bar (hamburger button) and coordinate with the bottom tab bar, we need to lift this state into a React Context.
|
||||||
|
|
||||||
|
#### [NEW] `src/contexts/sidebar-context.tsx`
|
||||||
|
|
||||||
|
Create a `SidebarProvider` context that provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SidebarContextType {
|
||||||
|
isCollapsed: boolean; // Desktop collapse state
|
||||||
|
isMobileOpen: boolean; // Mobile drawer open state
|
||||||
|
toggleCollapse: () => void; // Desktop toggle
|
||||||
|
openMobile: () => void; // Open mobile drawer
|
||||||
|
closeMobile: () => void; // Close mobile drawer
|
||||||
|
isMobile: boolean; // Current screen is mobile (<1024px)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Use `window.matchMedia('(min-width: 1024px)')` to detect mobile vs desktop (using `lg` breakpoint).
|
||||||
|
- Auto-close mobile drawer on route change (listen to `usePathname()`).
|
||||||
|
- Auto-close mobile drawer when screen resizes above `lg` breakpoint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Mobile Top Bar Component
|
||||||
|
|
||||||
|
#### [NEW] `src/components/mobile-top-bar.tsx`
|
||||||
|
|
||||||
|
A slim fixed top bar visible only on `< lg` screens:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ ☰ │ NextAV │ [🔍] │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Left**: Hamburger icon (`Menu` from lucide) → triggers `openMobile()` to open the full drawer
|
||||||
|
- **Center**: "NextAV" branding
|
||||||
|
- **Right**: Optional search icon or current section indicator
|
||||||
|
- CSS: `lg:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-card border-b border-border`
|
||||||
|
- Add `pt-14` to `<main>` on mobile to account for the fixed top bar height
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The top bar provides access to the **drawer** (secondary nav). The **bottom tab bar** (Phase 2B) handles primary navigation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2B: Bottom Tab Bar Component (YouTube-style)
|
||||||
|
|
||||||
|
#### [NEW] `src/components/mobile-bottom-tabs.tsx`
|
||||||
|
|
||||||
|
A fixed bottom tab bar visible only on `< lg` screens, providing quick access to primary sections:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ 🏠 │ 🎬 │ 📷 │ 🔖 │
|
||||||
|
│ Home │ Videos │ Photos │ Bookmarks │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **4 primary tabs**: Home, Videos, Photos, Bookmarks
|
||||||
|
- Active tab highlighted with primary color (filled icon + colored label)
|
||||||
|
- Inactive tabs use muted color
|
||||||
|
- CSS: `lg:hidden fixed bottom-0 left-0 right-0 z-40 h-16 bg-card border-t border-border backdrop-blur-lg`
|
||||||
|
- Uses `safe-area-inset-bottom` padding for devices with home indicators (iPhone notch)
|
||||||
|
- Each tab is a `<Link>` using Next.js router, with active state derived from `usePathname()`
|
||||||
|
- Add `pb-16` to `<main>` on mobile to account for the fixed bottom bar height
|
||||||
|
|
||||||
|
**Why 4 tabs?** YouTube uses 4-5 tabs. The remaining sections (Surprise Me, Settings, Clusters, Libraries) live in the hamburger drawer — they're less frequently accessed.
|
||||||
|
|
||||||
|
**Tab touch targets**: Each tab should be at minimum `48px × 48px` (WCAG 2.5.8), with the full tab area being tappable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Sidebar Refactoring
|
||||||
|
|
||||||
|
#### [MODIFY] `src/components/sidebar.tsx`
|
||||||
|
|
||||||
|
**Desktop behavior** (`lg:` and above):
|
||||||
|
- Keep current behavior: persistent sidebar, `w-64`/`w-16` collapse toggle.
|
||||||
|
- Add `hidden lg:flex` to the sidebar container.
|
||||||
|
|
||||||
|
**Mobile behavior** (`< lg`):
|
||||||
|
- Render as a **fixed overlay drawer** that slides in from the left (opened via hamburger in top bar).
|
||||||
|
- Include a **backdrop** (`bg-black/50`) that closes the drawer on tap.
|
||||||
|
- Use `translate-x` animation for the slide-in/out effect.
|
||||||
|
- The drawer should be `w-72` (slightly wider than desktop's `w-64` for better touch targets).
|
||||||
|
- Add a close button (`X` icon) in the drawer header.
|
||||||
|
- Nav items should have larger touch targets (`min-h-[48px]` per Material Design guidelines).
|
||||||
|
- **Clusters and Libraries sections collapsed by default** — `showClusters` and `showLibraries` should initialize to `false` when in mobile mode.
|
||||||
|
|
||||||
|
**Structural approach** — two render paths in the same component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Desktop: persistent sidebar
|
||||||
|
<aside className="hidden lg:flex flex-col ...">
|
||||||
|
{/* existing sidebar content */}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
// Mobile: overlay drawer
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
|
<div className="absolute inset-0 bg-black/50" onClick={closeMobile} />
|
||||||
|
<aside className="relative w-72 h-full bg-card animate-slide-in ...">
|
||||||
|
{/* same sidebar content with larger touch targets */}
|
||||||
|
{/* Clusters: showClusters defaults to false */}
|
||||||
|
{/* Libraries: showLibraries defaults to false */}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> On mobile, the drawer acts as **secondary navigation** — users who need Clusters, Libraries, Surprise Me, or Settings will open it from the hamburger. The **bottom tab bar** handles the 4 most common destinations without opening the drawer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Layout Adjustments
|
||||||
|
|
||||||
|
#### [MODIFY] `src/app/layout.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SidebarProvider>
|
||||||
|
<div className="flex h-screen">
|
||||||
|
<MobileTopBar /> {/* visible only < lg */}
|
||||||
|
<Sidebar /> {/* persistent on lg+, drawer on mobile */}
|
||||||
|
<main className="flex-1 overflow-y-auto pt-14 lg:pt-0 pb-16 lg:pb-0">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<MobileBottomTabs /> {/* visible only < lg */}
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Both `pt-14` (top bar) and `pb-16` (bottom tab bar) must be applied on mobile only (`lg:pt-0 lg:pb-0`) to avoid wasting space on desktop.
|
||||||
|
|
||||||
|
#### [MODIFY] `src/app/globals.css`
|
||||||
|
|
||||||
|
Add animation keyframes for the drawer slide-in:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes slideInFromLeft {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToLeft {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slideInFromLeft 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-out {
|
||||||
|
animation: slideOutToLeft 0.3s ease-in forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Touch & Mobile UX Enhancements
|
||||||
|
|
||||||
|
#### Touch Targets
|
||||||
|
- Increase nav button heights from `h-10` to `min-h-[48px]` on mobile (WCAG 2.5.8).
|
||||||
|
- Increase icon sizes from `h-4 w-4` to `h-5 w-5` on mobile.
|
||||||
|
- Increase font sizes from `text-sm` to `text-base` on mobile for nav labels.
|
||||||
|
|
||||||
|
#### Swipe Gesture (Optional, Phase 2)
|
||||||
|
- Add a swipe-right gesture on the left edge of the screen to open the drawer.
|
||||||
|
- Add a swipe-left gesture on the open drawer to close it.
|
||||||
|
- Can be implemented with pointer events (`onTouchStart`, `onTouchMove`, `onTouchEnd`).
|
||||||
|
|
||||||
|
#### Content Adjustments
|
||||||
|
- The `InfiniteVirtualGrid` and `VirtualizedFolderGrid` will automatically gain more width since the sidebar no longer takes space on mobile.
|
||||||
|
- Consider reducing padding from `p-6` to `p-3` on mobile for content pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
|
||||||
|
| File | Action | Summary |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `src/contexts/sidebar-context.tsx` | **NEW** | Shared sidebar state context with mobile detection at `lg` breakpoint |
|
||||||
|
| `src/components/mobile-top-bar.tsx` | **NEW** | Mobile-only slim top bar with hamburger + branding |
|
||||||
|
| `src/components/mobile-bottom-tabs.tsx` | **NEW** | YouTube-style bottom tab bar (Home, Videos, Photos, Bookmarks) |
|
||||||
|
| `src/components/sidebar.tsx` | **MODIFY** | Dual-mode: persistent desktop (`lg:flex`) + overlay drawer mobile; Clusters/Libraries collapsed by default on mobile |
|
||||||
|
| `src/app/layout.tsx` | **MODIFY** | Wrap with `SidebarProvider`, add `MobileTopBar` + `MobileBottomTabs`, adjust `<main>` padding (`pt-14 pb-16 lg:pt-0 lg:pb-0`) |
|
||||||
|
| `src/app/globals.css` | **MODIFY** | Add slide-in/out animations, safe-area-inset-bottom support |
|
||||||
|
| `tailwind.config.ts` | **MODIFY** (optional) | Add custom animation keyframes if preferred over raw CSS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual Mockup
|
||||||
|
|
||||||
|
### Desktop (≥1024px) — No Change
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┬────────────────────────────────────────┐
|
||||||
|
│ ▶ NextAV │ │
|
||||||
|
│─────────────│ Main Content Area │
|
||||||
|
│ 🏠 Home │ │
|
||||||
|
│ 🎬 Videos │ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ 📷 Photos │ │ Card │ │ Card │ │ Card │ │
|
||||||
|
│ 🔖 Bookmarks│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ ✨ Surprise │ ┌──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ ⚙ Settings │ │ Card │ │ Card │ │ Card │ │
|
||||||
|
│─────────────│ └──────┘ └──────┘ └──────┘ │
|
||||||
|
│ Clusters │ │
|
||||||
|
│ Libraries │ │
|
||||||
|
│─────────────│ │
|
||||||
|
│ NextAV v1.0│ │
|
||||||
|
└─────────────┴────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile/Tablet (<1024px) — Bottom Tab Bar + Top Bar
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ ☰ │ NextAV │ 🔍 │ ← slim top bar (h-14)
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌────────┐│
|
||||||
|
│ │ Card │ │ Card ││
|
||||||
|
│ └──────────┘ └────────┘│
|
||||||
|
│ ┌──────────┐ ┌────────┐│
|
||||||
|
│ │ Card │ │ Card ││
|
||||||
|
│ └──────────┘ └────────┘│
|
||||||
|
│ ┌──────────┐ ┌────────┐│
|
||||||
|
│ │ Card │ │ Card ││
|
||||||
|
│ └──────────┘ └────────┘│
|
||||||
|
│ │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ 🏠 Home │🎬 Videos│📷 Photos│🔖 Marks│ ← bottom tab bar (h-16)
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile/Tablet — Hamburger Drawer Open
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ ▶ NextAV ✕ │▒▒▒▒▒│
|
||||||
|
│────────────────────│▒▒▒▒▒│
|
||||||
|
│ 🏠 Home │▒▒▒▒▒│
|
||||||
|
│ 🎬 Videos │▒▒▒▒▒│
|
||||||
|
│ 📷 Photos │▒▒▒▒▒│
|
||||||
|
│ 🔖 Bookmarks │▒▒▒▒▒│
|
||||||
|
│ ✨ Surprise Me │▒▒▒▒▒│
|
||||||
|
│ ⚙ Settings │▒▒▒▒▒│
|
||||||
|
│────────────────────│▒▒▒▒▒│
|
||||||
|
│ ▸ Clusters (collapsed) │
|
||||||
|
│ ▸ Libraries (collapsed) │
|
||||||
|
│────────────────────│▒▒▒▒▒│
|
||||||
|
│ NextAV v1.0 │▒▒▒▒▒│
|
||||||
|
└──────────────────────────┘
|
||||||
|
▲ drawer (w-72) ▲ backdrop
|
||||||
|
|
||||||
|
Note: Clusters & Libraries are collapsed
|
||||||
|
by default on mobile. User can expand them.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
| Priority | Task | Effort |
|
||||||
|
|----------|------|--------|
|
||||||
|
| P0 | Create `SidebarProvider` context | Small |
|
||||||
|
| P0 | Create `MobileTopBar` component | Small |
|
||||||
|
| P0 | Create `MobileBottomTabs` component (YouTube-style) | Medium |
|
||||||
|
| P0 | Refactor `sidebar.tsx` for desktop/mobile dual mode | Medium |
|
||||||
|
| P0 | Update `layout.tsx` to integrate all pieces | Small |
|
||||||
|
| P0 | Add CSS animations for drawer + safe-area support | Small |
|
||||||
|
| P1 | Increase touch targets on mobile nav items & drawer | Small |
|
||||||
|
| P1 | Reduce content padding on mobile (`p-6` → `p-3`) | Small |
|
||||||
|
| P2 | Swipe gesture to open/close drawer | Medium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved Decisions
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
|---|----------|----------|
|
||||||
|
| 1 | Bottom Tab Bar vs Hamburger Drawer? | **YouTube-style bottom tab bar** for primary nav (Home, Videos, Photos, Bookmarks). Hamburger drawer retained for secondary nav (Surprise Me, Settings, Clusters, Libraries). |
|
||||||
|
| 2 | Breakpoint Selection | **`lg` (1024px)** — tablets in portrait mode get the mobile experience with bottom tab bar + drawer. |
|
||||||
|
| 3 | Sidebar Sections on Mobile | **Collapsed by default** — Clusters and Libraries sections start collapsed in the mobile drawer to reduce scroll length. Users can expand them manually. |
|
||||||
|
|
@ -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}`,
|
direct: `${baseUrl}/api/stream/direct/${video.id}`,
|
||||||
external: `${baseUrl}/api/external-stream/${video.id}`,
|
external: `${baseUrl}/api/external-stream/${video.id}`,
|
||||||
|
|
||||||
// HLS streaming (if supported)
|
// Live transcoding starts with POST /api/transcode/start and returns the playlist URL.
|
||||||
hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
hls: `${baseUrl}/api/transcode/start`,
|
||||||
|
|
||||||
// Protocol-specific URLs for external players
|
// Protocol-specific URLs for external players
|
||||||
protocols: {
|
protocols: {
|
||||||
|
|
@ -116,7 +116,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||||
// Browser-compatible formats
|
// Browser-compatible formats
|
||||||
browser: {
|
browser: {
|
||||||
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
|
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
|
||||||
transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
transcoded: `${baseUrl}/api/transcode/start`,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,21 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { transcodeOrchestrator } from '@/lib/transcode/orchestrator';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function DELETE(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
|
||||||
|
const { jobId } = await params;
|
||||||
|
// markKilled() synchronously removes the job from the in-memory map so that
|
||||||
|
// segment/playlist routes return 404 immediately. The FFmpeg process is then
|
||||||
|
// terminated in the background so this response returns in <10ms.
|
||||||
|
const proc = transcodeOrchestrator.markKilled(jobId);
|
||||||
|
if (proc) {
|
||||||
|
transcodeOrchestrator.killProcess(proc).catch(() => {});
|
||||||
|
} else {
|
||||||
|
// Job not found via markKilled (may have already been cleaned up) — still
|
||||||
|
// try the normal kill path in case state differs.
|
||||||
|
transcodeOrchestrator.kill(jobId, 'client closed').catch(() => {});
|
||||||
|
}
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
@ -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,46 @@
|
||||||
|
import fs from 'fs';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { transcodeOrchestrator } from '@/lib/transcode/orchestrator';
|
||||||
|
import { segmentPath } from '@/lib/transcode/playlist';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
async function waitForSegment(filePath: string, timeoutMs = 5000): Promise<boolean> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size > 0) return true;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string; name: string }> }) {
|
||||||
|
const { jobId, name } = await params;
|
||||||
|
const job = transcodeOrchestrator.get(jobId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = segmentPath(job.outDir, name);
|
||||||
|
if (!resolvedPath || !(await waitForSegment(resolvedPath))) {
|
||||||
|
return new Response(null, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(resolvedPath);
|
||||||
|
const stream = fs.createReadStream(resolvedPath);
|
||||||
|
return new Response(stream as any, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'video/mp2t',
|
||||||
|
'Content-Length': stat.size.toString(),
|
||||||
|
'Cache-Control': 'private, max-age=3600',
|
||||||
|
'ETag': `"${jobId}-${name}-${stat.size}-${stat.mtimeMs}"`,
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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: {
|
streaming: {
|
||||||
direct_url: `/api/stream/direct/${video.id}`,
|
direct_url: `/api/stream/direct/${video.id}`,
|
||||||
hls_url: format.type === 'hls' ? `/api/stream/hls/${video.id}/playlist.m3u8` : null,
|
hls_url: format.type === 'hls' ? format.url : null,
|
||||||
fallback_url: `/api/stream/${video.id}`,
|
fallback_url: `/api/stream/${video.id}`,
|
||||||
transcoding_url: `/api/stream/${video.id}/transcode`,
|
transcoding_url: `/api/transcode/start`,
|
||||||
supports_range_requests: format.supportLevel === 'native',
|
supports_range_requests: format.supportLevel === 'native',
|
||||||
supports_adaptive_bitrate: format.type === 'hls'
|
supports_adaptive_bitrate: format.type === 'hls'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -206,3 +206,27 @@
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition-duration: 150ms;
|
transition-duration: 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile sidebar drawer animations */
|
||||||
|
@keyframes slideInFromLeft {
|
||||||
|
from { transform: translateX(-100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOutToLeft {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slideInFromLeft 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-out {
|
||||||
|
animation: slideOutToLeft 0.3s ease-in forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area bottom padding for devices with home indicator */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
|
import MobileTopBar from "@/components/mobile-top-bar";
|
||||||
|
import MobileBottomTabs from "@/components/mobile-bottom-tabs";
|
||||||
|
import { SidebarProvider } from "@/contexts/sidebar-context";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
|
@ -23,12 +26,16 @@ export default function RootLayout({
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} antialiased bg-background text-foreground`}
|
className={`${inter.variable} antialiased bg-background text-foreground`}
|
||||||
>
|
>
|
||||||
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
<SidebarProvider>
|
||||||
<Sidebar />
|
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||||
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto">
|
<MobileTopBar />
|
||||||
{children}
|
<Sidebar />
|
||||||
</main>
|
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto pt-14 lg:pt-0 pb-16 lg:pb-0">
|
||||||
</div>
|
{children}
|
||||||
|
</main>
|
||||||
|
<MobileBottomTabs />
|
||||||
|
</div>
|
||||||
|
</SidebarProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface ArtPlayerWrapperProps {
|
||||||
showBookmarks?: boolean;
|
showBookmarks?: boolean;
|
||||||
showRatings?: boolean;
|
showRatings?: boolean;
|
||||||
autoplay?: boolean;
|
autoplay?: boolean;
|
||||||
|
formatOverride?: VideoFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArtPlayerWrapper({
|
export default function ArtPlayerWrapper({
|
||||||
|
|
@ -41,11 +42,14 @@ export default function ArtPlayerWrapper({
|
||||||
avgRating = 0,
|
avgRating = 0,
|
||||||
showBookmarks = false,
|
showBookmarks = false,
|
||||||
showRatings = false,
|
showRatings = false,
|
||||||
autoplay = true
|
autoplay = true,
|
||||||
|
formatOverride
|
||||||
}: ArtPlayerWrapperProps) {
|
}: ArtPlayerWrapperProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const playerRef = useRef<Artplayer | null>(null);
|
const playerRef = useRef<Artplayer | null>(null);
|
||||||
const hlsInstanceRef = useRef<Hls | null>(null); // Store HLS instance for cleanup
|
const hlsInstanceRef = useRef<Hls | null>(null); // Store HLS instance for cleanup
|
||||||
|
const formatOverrideRef = useRef<VideoFormat | undefined>(formatOverride);
|
||||||
|
const mediaElementsRef = useRef<Set<HTMLVideoElement>>(new Set());
|
||||||
const [format, setFormat] = useState<VideoFormat | null>(null);
|
const [format, setFormat] = useState<VideoFormat | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -59,6 +63,102 @@ export default function ArtPlayerWrapper({
|
||||||
const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount);
|
const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount);
|
||||||
const [localAvgRating, setLocalAvgRating] = useState(avgRating);
|
const [localAvgRating, setLocalAvgRating] = useState(avgRating);
|
||||||
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
|
const hlsErrorHandlerRef = useRef<HLSErrorHandler | null>(null);
|
||||||
|
const hlsShuttingDownRef = useRef<boolean>(false); // Blocks HLS error-recovery after teardown
|
||||||
|
|
||||||
|
const cloneQualities = (qualities: VideoFormat['qualities']) => (
|
||||||
|
qualities?.map(quality => ({ ...quality })) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const resetArtPlayerContainer = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const el = containerRef.current as unknown as Record<string, unknown>;
|
||||||
|
for (const prop of ['$control_option', '$video', '$player', '$art']) {
|
||||||
|
try { delete el[prop]; } catch (_) { /* non-configurable */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopTrackedMedia = (clearSource: boolean) => {
|
||||||
|
mediaElementsRef.current.forEach(videoEl => {
|
||||||
|
videoEl.pause();
|
||||||
|
if (clearSource) {
|
||||||
|
videoEl.removeAttribute('src');
|
||||||
|
videoEl.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
containerRef.current?.querySelectorAll<HTMLMediaElement>('video, audio').forEach(mediaEl => {
|
||||||
|
mediaEl.pause();
|
||||||
|
if (clearSource) {
|
||||||
|
mediaEl.removeAttribute('src');
|
||||||
|
mediaEl.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clearSource) {
|
||||||
|
document.querySelectorAll<HTMLMediaElement>('video, audio').forEach(mediaEl => {
|
||||||
|
mediaEl.pause();
|
||||||
|
mediaEl.removeAttribute('src');
|
||||||
|
mediaEl.load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const releasePlayer = () => {
|
||||||
|
// Mark HLS as shutting down BEFORE stopping/destroying so that any
|
||||||
|
// in-flight error callbacks (NETWORK_ERROR → startLoad recovery) are blocked.
|
||||||
|
hlsShuttingDownRef.current = true;
|
||||||
|
|
||||||
|
// ──── Step 1: Immediately pause all media to stop audio output ────
|
||||||
|
// Only pause here — do NOT remove src or call load() yet, because
|
||||||
|
// HLS.js still owns the MediaSource on the <video> element.
|
||||||
|
// Calling video.load() while HLS owns the MediaSource puts the element
|
||||||
|
// in a conflicting state where HLS can't cleanly abort its XHR requests.
|
||||||
|
mediaElementsRef.current.forEach(videoEl => {
|
||||||
|
try { videoEl.pause(); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
containerRef.current?.querySelectorAll<HTMLMediaElement>('video, audio').forEach(el => {
|
||||||
|
try { el.pause(); } catch (_) { /* ignore */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──── Step 2: Destroy HLS.js (stops all network requests) ────
|
||||||
|
// Monkey-patch recovery methods so in-flight error callbacks can't
|
||||||
|
// trigger a recovery loop between stopLoad() and destroy().
|
||||||
|
if (hlsInstanceRef.current) {
|
||||||
|
try {
|
||||||
|
hlsInstanceRef.current.startLoad = () => {};
|
||||||
|
(hlsInstanceRef.current as any).recoverMediaError = () => {};
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
// stopLoad() cancels pending fragment requests.
|
||||||
|
// destroy() internally calls detachMedia() (removes the MediaSource
|
||||||
|
// from the <video>) and aborts all remaining XHR — do NOT call
|
||||||
|
// detachMedia() separately, as that causes destroy() to skip XHR cleanup.
|
||||||
|
try { hlsInstanceRef.current.stopLoad(); } catch (_) { /* ignore */ }
|
||||||
|
try { hlsInstanceRef.current.destroy(); } catch (_) { /* ignore */ }
|
||||||
|
hlsInstanceRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
hlsErrorHandlerRef.current?.detach();
|
||||||
|
hlsErrorHandlerRef.current = null;
|
||||||
|
|
||||||
|
// ──── Step 3: Clear media sources and destroy ArtPlayer ────
|
||||||
|
// Now that HLS is gone, scrub any remaining src/srcObject on media
|
||||||
|
// elements so the browser releases the underlying decode resources.
|
||||||
|
stopTrackedMedia(true);
|
||||||
|
|
||||||
|
if (playerRef.current) {
|
||||||
|
try { playerRef.current.pause(); } catch (_) { /* ignore */ }
|
||||||
|
try { playerRef.current.destroy(); } catch (_) { /* ignore */ }
|
||||||
|
playerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaElementsRef.current.clear();
|
||||||
|
resetArtPlayerContainer();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
releasePlayer();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
// Prevent ALL scrolling when video player is open
|
// Prevent ALL scrolling when video player is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -125,10 +225,18 @@ export default function ArtPlayerWrapper({
|
||||||
setLocalAvgRating(avgRating);
|
setLocalAvgRating(avgRating);
|
||||||
}, [isBookmarked, bookmarkCount, avgRating]);
|
}, [isBookmarked, bookmarkCount, avgRating]);
|
||||||
|
|
||||||
|
// Keep formatOverrideRef in sync (no re-init needed, handled separately)
|
||||||
|
useEffect(() => {
|
||||||
|
formatOverrideRef.current = formatOverride;
|
||||||
|
}, [formatOverride]);
|
||||||
|
|
||||||
// Initialize ArtPlayer
|
// Initialize ArtPlayer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
||||||
|
|
||||||
|
// Prevent duplicate instances/audio when React dev mode re-runs effects.
|
||||||
|
releasePlayer();
|
||||||
|
|
||||||
// Inject custom styles to remove shadows
|
// Inject custom styles to remove shadows
|
||||||
const styleId = 'artplayer-styles';
|
const styleId = 'artplayer-styles';
|
||||||
if (!document.getElementById(styleId)) {
|
if (!document.getElementById(styleId)) {
|
||||||
|
|
@ -142,7 +250,10 @@ export default function ArtPlayerWrapper({
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detectedFormat = detectVideoFormat(video);
|
// Read from ref so this effect does NOT re-run when formatOverride changes
|
||||||
|
const detectedFormat = formatOverrideRef.current || detectVideoFormat(video);
|
||||||
|
const qualityOptions = cloneQualities(detectedFormat.qualities);
|
||||||
|
const settingsQualityOptions = cloneQualities(detectedFormat.qualities);
|
||||||
setFormat(detectedFormat);
|
setFormat(detectedFormat);
|
||||||
|
|
||||||
// HLS.js plugin for ArtPlayer
|
// HLS.js plugin for ArtPlayer
|
||||||
|
|
@ -184,7 +295,7 @@ export default function ArtPlayerWrapper({
|
||||||
theme: '#3b82f6', // Blue theme
|
theme: '#3b82f6', // Blue theme
|
||||||
|
|
||||||
// Quality control (for HLS)
|
// Quality control (for HLS)
|
||||||
quality: detectedFormat.qualities || [],
|
quality: qualityOptions,
|
||||||
|
|
||||||
// Subtitle support
|
// Subtitle support
|
||||||
subtitle: {
|
subtitle: {
|
||||||
|
|
@ -202,7 +313,7 @@ export default function ArtPlayerWrapper({
|
||||||
{
|
{
|
||||||
html: 'Quality',
|
html: 'Quality',
|
||||||
icon: '<span class="artplayer-icon-settings-quality">⚙️</span>',
|
icon: '<span class="artplayer-icon-settings-quality">⚙️</span>',
|
||||||
selector: detectedFormat.qualities || [],
|
selector: settingsQualityOptions,
|
||||||
onSelect: function(item: any) {
|
onSelect: function(item: any) {
|
||||||
console.log('Quality selected:', item);
|
console.log('Quality selected:', item);
|
||||||
if (hlsInstance && item.level !== undefined) {
|
if (hlsInstance && item.level !== undefined) {
|
||||||
|
|
@ -218,30 +329,45 @@ export default function ArtPlayerWrapper({
|
||||||
// Custom initialization for HLS
|
// Custom initialization for HLS
|
||||||
customType: {
|
customType: {
|
||||||
m3u8: function(video: HTMLVideoElement, url: string) {
|
m3u8: function(video: HTMLVideoElement, url: string) {
|
||||||
|
mediaElementsRef.current.add(video);
|
||||||
if (Hls.isSupported()) {
|
if (Hls.isSupported()) {
|
||||||
|
// Reset shutdown flag — we're starting fresh
|
||||||
|
hlsShuttingDownRef.current = false;
|
||||||
|
|
||||||
hlsInstance = new Hls({
|
hlsInstance = new Hls({
|
||||||
debug: process.env.NODE_ENV === 'development',
|
// Route HLS internal errors through console.warn so they don't
|
||||||
enableWorker: true,
|
// trigger the Next.js dev overlay (which intercepts console.error).
|
||||||
lowLatencyMode: false, // Disable for better buffering
|
debug: process.env.NODE_ENV === 'development' ? {
|
||||||
backBufferLength: 90,
|
trace: () => {},
|
||||||
maxBufferLength: 120, // Increase buffer length
|
debug: () => {},
|
||||||
maxBufferSize: 100 * 1000 * 1000, // 100MB buffer
|
log: () => {},
|
||||||
maxBufferHole: 0.5, // Allow small holes
|
info: () => {},
|
||||||
startLevel: -1, // Auto-select optimal quality
|
warn: (...a: unknown[]) => console.debug('[HLS]', ...a),
|
||||||
|
error: (...a: unknown[]) => console.warn('[HLS]', ...a),
|
||||||
|
} : false,
|
||||||
|
// Disable the web worker so XHR abort is synchronous on the main
|
||||||
|
// thread — eliminates worker-race requests after hls.destroy().
|
||||||
|
enableWorker: false,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
backBufferLength: 30,
|
||||||
|
// Smaller buffer = fewer in-flight segments when user closes video.
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxBufferSize: 60 * 1000 * 1000, // 60MB
|
||||||
|
maxBufferHole: 0.5,
|
||||||
|
startLevel: -1,
|
||||||
capLevelToPlayerSize: true,
|
capLevelToPlayerSize: true,
|
||||||
autoStartLoad: true,
|
autoStartLoad: true,
|
||||||
maxFragLookUpTolerance: 0.25,
|
maxFragLookUpTolerance: 0.25,
|
||||||
liveSyncDurationCount: 3,
|
liveSyncDurationCount: 3,
|
||||||
liveMaxLatencyDurationCount: 10,
|
liveMaxLatencyDurationCount: 10,
|
||||||
// Aggressive preloading for better buffering
|
|
||||||
manifestLoadingTimeOut: 10000,
|
manifestLoadingTimeOut: 10000,
|
||||||
manifestLoadingMaxRetry: 2,
|
// Fewer retries so recovery loops die quickly.
|
||||||
|
manifestLoadingMaxRetry: 1,
|
||||||
levelLoadingTimeOut: 10000,
|
levelLoadingTimeOut: 10000,
|
||||||
levelLoadingMaxRetry: 2,
|
levelLoadingMaxRetry: 1,
|
||||||
fragLoadingTimeOut: 20000, // Longer timeout for large segments
|
fragLoadingTimeOut: 20000,
|
||||||
fragLoadingMaxRetry: 3,
|
fragLoadingMaxRetry: 1,
|
||||||
// Bandwidth estimation settings
|
abrEwmaDefaultEstimate: 500000,
|
||||||
abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
|
|
||||||
abrBandWidthFactor: 0.95,
|
abrBandWidthFactor: 0.95,
|
||||||
abrBandWidthUpFactor: 0.7,
|
abrBandWidthUpFactor: 0.7,
|
||||||
});
|
});
|
||||||
|
|
@ -307,7 +433,10 @@ export default function ArtPlayerWrapper({
|
||||||
});
|
});
|
||||||
|
|
||||||
hlsInstance.on(Hls.Events.ERROR, (event: string, data: any) => {
|
hlsInstance.on(Hls.Events.ERROR, (event: string, data: any) => {
|
||||||
console.error('HLS error details:', {
|
// If we're shutting down, ignore all errors — no recovery needed.
|
||||||
|
if (hlsShuttingDownRef.current) return;
|
||||||
|
|
||||||
|
console.warn('HLS error details:', {
|
||||||
type: data.type,
|
type: data.type,
|
||||||
details: data.details,
|
details: data.details,
|
||||||
fatal: data.fatal,
|
fatal: data.fatal,
|
||||||
|
|
@ -329,7 +458,7 @@ export default function ArtPlayerWrapper({
|
||||||
hlsInstance?.recoverMediaError();
|
hlsInstance?.recoverMediaError();
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.error('HLS fatal error, cannot recover');
|
console.warn('HLS fatal error, cannot recover');
|
||||||
setError('HLS streaming failed. Falling back to direct playback.');
|
setError('HLS streaming failed. Falling back to direct playback.');
|
||||||
// This will trigger fallback in the parent component
|
// This will trigger fallback in the parent component
|
||||||
break;
|
break;
|
||||||
|
|
@ -360,6 +489,10 @@ export default function ArtPlayerWrapper({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (player.video) {
|
||||||
|
mediaElementsRef.current.add(player.video as HTMLVideoElement);
|
||||||
|
}
|
||||||
|
|
||||||
// Event listeners
|
// Event listeners
|
||||||
player.on('ready', () => {
|
player.on('ready', () => {
|
||||||
console.log('ArtPlayer ready');
|
console.log('ArtPlayer ready');
|
||||||
|
|
@ -394,6 +527,33 @@ export default function ArtPlayerWrapper({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
player.on('video:seeking', () => {
|
||||||
|
const activeJobId = getTranscodeJobId(detectedFormat.url);
|
||||||
|
if (!activeJobId) return;
|
||||||
|
|
||||||
|
fetch(`/api/transcode/${activeJobId}/seek`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ position: player.currentTime })
|
||||||
|
}).then(async response => {
|
||||||
|
if (response.status === 204) return;
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || 'Seek restart failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setFormat({
|
||||||
|
type: 'hls',
|
||||||
|
supportLevel: 'hls',
|
||||||
|
url: data.playlistUrl,
|
||||||
|
qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }]
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('[ArtPlayer] Transcode seek handling failed:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
player.on('video:loadedmetadata', () => {
|
player.on('video:loadedmetadata', () => {
|
||||||
setDuration(player.duration);
|
setDuration(player.duration);
|
||||||
});
|
});
|
||||||
|
|
@ -431,28 +591,7 @@ export default function ArtPlayerWrapper({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[ArtPlayer] Starting cleanup...');
|
console.log('[ArtPlayer] Starting cleanup...');
|
||||||
|
releasePlayer();
|
||||||
// Stop HLS loading immediately
|
|
||||||
if (hlsInstanceRef.current) {
|
|
||||||
console.log('[ArtPlayer] Stopping HLS loading...');
|
|
||||||
hlsInstanceRef.current.stopLoad();
|
|
||||||
hlsInstanceRef.current.destroy();
|
|
||||||
hlsInstanceRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy player
|
|
||||||
if (playerRef.current) {
|
|
||||||
console.log('[ArtPlayer] Destroying player...');
|
|
||||||
playerRef.current.destroy();
|
|
||||||
playerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up HLS error handler
|
|
||||||
if (hlsErrorHandlerRef.current) {
|
|
||||||
hlsErrorHandlerRef.current.detach();
|
|
||||||
hlsErrorHandlerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[ArtPlayer] Cleanup completed');
|
console.log('[ArtPlayer] Cleanup completed');
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -460,7 +599,30 @@ export default function ArtPlayerWrapper({
|
||||||
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, format?.supportLevel]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeJobId = getTranscodeJobId(format?.url);
|
||||||
|
if (!isOpen || !activeJobId) return;
|
||||||
|
|
||||||
|
const ping = () => {
|
||||||
|
fetch(`/api/transcode/${activeJobId}/ping`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
position: playerRef.current?.currentTime || currentTime,
|
||||||
|
isPaused: !isPlaying,
|
||||||
|
}),
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('[ArtPlayer] Failed to ping transcode session:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ping();
|
||||||
|
const interval = window.setInterval(ping, 20_000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [isOpen, format?.url, currentTime, isPlaying]);
|
||||||
|
|
||||||
// Handle bookmark toggle
|
// Handle bookmark toggle
|
||||||
const handleBookmarkToggle = useCallback(async () => {
|
const handleBookmarkToggle = useCallback(async () => {
|
||||||
|
|
@ -551,7 +713,7 @@ export default function ArtPlayerWrapper({
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onClose();
|
handleClose();
|
||||||
break;
|
break;
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -584,32 +746,13 @@ export default function ArtPlayerWrapper({
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isOpen, onClose, isPlaying]);
|
}, [isOpen, isPlaying]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
console.log('[ArtPlayer] Unmount cleanup...');
|
console.log('[ArtPlayer] Unmount cleanup...');
|
||||||
|
releasePlayer();
|
||||||
// Stop HLS loading
|
|
||||||
if (hlsInstanceRef.current) {
|
|
||||||
console.log('[ArtPlayer] Stopping HLS on unmount...');
|
|
||||||
hlsInstanceRef.current.stopLoad();
|
|
||||||
hlsInstanceRef.current.destroy();
|
|
||||||
hlsInstanceRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy player
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.destroy();
|
|
||||||
playerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up HLS error handler
|
|
||||||
if (hlsErrorHandlerRef.current) {
|
|
||||||
hlsErrorHandlerRef.current.detach();
|
|
||||||
hlsErrorHandlerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up custom styles
|
// Clean up custom styles
|
||||||
const styleElement = document.getElementById('artplayer-styles');
|
const styleElement = document.getElementById('artplayer-styles');
|
||||||
|
|
@ -623,38 +766,9 @@ export default function ArtPlayerWrapper({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
console.log('[ArtPlayer] Modal closed, stopping HLS...');
|
console.log('[ArtPlayer] Modal closed, stopping HLS...');
|
||||||
|
releasePlayer();
|
||||||
// Stop HLS loading immediately when modal closes
|
|
||||||
if (hlsInstanceRef.current) {
|
|
||||||
hlsInstanceRef.current.stopLoad();
|
|
||||||
hlsInstanceRef.current.destroy();
|
|
||||||
hlsInstanceRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also destroy player when modal closes
|
|
||||||
if (playerRef.current) {
|
|
||||||
playerRef.current.destroy();
|
|
||||||
playerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up error handler
|
|
||||||
if (hlsErrorHandlerRef.current) {
|
|
||||||
hlsErrorHandlerRef.current.detach();
|
|
||||||
hlsErrorHandlerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release backend session reference
|
|
||||||
if (format?.type === 'hls') {
|
|
||||||
fetch(`/api/stream/hls/${video.id}/cleanup`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'release_reference' })
|
|
||||||
}).catch(error => {
|
|
||||||
console.warn('[ArtPlayer] Failed to release session reference:', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [isOpen, format?.type, video.id]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
|
@ -663,7 +777,7 @@ export default function ArtPlayerWrapper({
|
||||||
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
<div className="relative w-full h-full max-w-7xl max-h-[90vh] mx-auto my-8">
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={handleClose}
|
||||||
className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
className="absolute top-4 right-4 z-20 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|
@ -823,3 +937,9 @@ export default function ArtPlayerWrapper({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTranscodeJobId(url?: string): string | null {
|
||||||
|
if (!url) return null;
|
||||||
|
const match = url.match(/\/api\/transcode\/([^/]+)\//);
|
||||||
|
return match?.[1] || null;
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ interface LocalPlayerLauncherProps {
|
||||||
onRate?: (id: number, rating: number) => Promise<void>;
|
onRate?: (id: number, rating: number) => Promise<void>;
|
||||||
showBookmarks?: boolean;
|
showBookmarks?: boolean;
|
||||||
showRatings?: boolean;
|
showRatings?: boolean;
|
||||||
|
notice?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlayerInfo {
|
interface PlayerInfo {
|
||||||
|
|
@ -149,7 +150,8 @@ export default function LocalPlayerLauncher({
|
||||||
onUnbookmark,
|
onUnbookmark,
|
||||||
onRate,
|
onRate,
|
||||||
showBookmarks = true,
|
showBookmarks = true,
|
||||||
showRatings = true
|
showRatings = true,
|
||||||
|
notice
|
||||||
}: LocalPlayerLauncherProps) {
|
}: LocalPlayerLauncherProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
||||||
|
|
@ -549,6 +551,12 @@ export default function LocalPlayerLauncher({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
{notice && (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{notice}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Video Info with Bookmark & Rating */}
|
{/* Video Info with Bookmark & Rating */}
|
||||||
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border">
|
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Home, Film, Image as ImageIcon, Bookmark } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ href: "/", label: "Home", icon: Home },
|
||||||
|
{ href: "/videos", label: "Videos", icon: Film },
|
||||||
|
{ href: "/photos", label: "Photos", icon: ImageIcon },
|
||||||
|
{ href: "/bookmarks", label: "Bookmarks", icon: Bookmark },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MobileBottomTabs() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-40 h-16 bg-card border-t border-border backdrop-blur-lg safe-area-bottom">
|
||||||
|
<div className="flex items-stretch h-full">
|
||||||
|
{tabs.map(({ href, label, icon: Icon }) => {
|
||||||
|
const isActive = pathname === href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 flex flex-col items-center justify-center gap-0.5 min-h-[48px] transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn("h-5 w-5", isActive && "fill-current")} />
|
||||||
|
<span className={cn("text-[10px] font-medium leading-none", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Menu, Play } from "lucide-react";
|
||||||
|
import { useSidebar } from "@/contexts/sidebar-context";
|
||||||
|
|
||||||
|
export default function MobileTopBar() {
|
||||||
|
const { openMobile } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="lg:hidden fixed top-0 left-0 right-0 z-40 h-14 bg-card border-b border-border flex items-center px-4 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={openMobile}
|
||||||
|
aria-label="Open menu"
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<div className="w-7 h-7 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
||||||
|
<Play className="h-3.5 w-3.5 text-primary-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-base font-bold text-foreground tracking-tight">NextAV</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
X,
|
||||||
Home,
|
Home,
|
||||||
Settings,
|
Settings,
|
||||||
Video,
|
Video,
|
||||||
|
|
@ -24,13 +25,14 @@ import Link from "next/link";
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import { useSidebar } from "@/contexts/sidebar-context";
|
||||||
|
|
||||||
const SidebarContent = () => {
|
const SidebarContent = () => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const { isCollapsed, isMobileOpen, toggleCollapse, closeMobile, isMobile } = useSidebar();
|
||||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||||
const [showClusters, setShowClusters] = useState(true);
|
const [showClusters, setShowClusters] = useState(!isMobile);
|
||||||
const [showLibraries, setShowLibraries] = useState(true);
|
const [showLibraries, setShowLibraries] = useState(!isMobile);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
|
@ -75,9 +77,13 @@ const SidebarContent = () => {
|
||||||
return icons[iconName] || Folder;
|
return icons[iconName] || Folder;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
// When switching to mobile, collapse sections by default
|
||||||
setIsCollapsed(!isCollapsed);
|
useEffect(() => {
|
||||||
};
|
if (isMobile) {
|
||||||
|
setShowClusters(false);
|
||||||
|
setShowLibraries(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Home", icon: Home },
|
{ href: "/", label: "Home", icon: Home },
|
||||||
|
|
@ -88,16 +94,16 @@ const SidebarContent = () => {
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const sidebarInner = (
|
||||||
<div
|
<div className={cn(
|
||||||
className={cn(
|
"flex flex-col bg-card h-full overflow-hidden",
|
||||||
"flex flex-col bg-card border-r border-border transition-all duration-300 ease-in-out shadow-lg h-full overflow-hidden",
|
// Desktop: width transitions; Mobile: full w-72 in drawer
|
||||||
isCollapsed ? "w-16" : "w-64"
|
!isMobile && (isCollapsed ? "w-16" : "w-64"),
|
||||||
)}
|
isMobile && "w-72"
|
||||||
>
|
)}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
<div className="w-8 h-8 bg-gradient-to-br from-primary to-primary/80 rounded-lg flex items-center justify-center shadow-lg">
|
||||||
<Play className="h-4 w-4 text-primary-foreground" />
|
<Play className="h-4 w-4 text-primary-foreground" />
|
||||||
|
|
@ -105,14 +111,25 @@ const SidebarContent = () => {
|
||||||
<h1 className="text-lg font-bold text-foreground tracking-tight">NextAV</h1>
|
<h1 className="text-lg font-bold text-foreground tracking-tight">NextAV</h1>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
{isMobile ? (
|
||||||
onClick={toggleSidebar}
|
<Button
|
||||||
variant="ghost"
|
onClick={closeMobile}
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
size="icon"
|
||||||
>
|
className="h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors ml-auto"
|
||||||
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
>
|
||||||
</Button>
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={toggleCollapse}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
|
|
@ -123,15 +140,24 @@ const SidebarContent = () => {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-10 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group",
|
||||||
|
isMobile ? "min-h-[48px] h-auto" : "h-10",
|
||||||
pathname === item.href && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
pathname === item.href && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className={cn(
|
<item.icon className={cn(
|
||||||
"h-4 w-4 transition-colors",
|
"transition-colors flex-shrink-0",
|
||||||
|
isMobile ? "h-5 w-5" : "h-4 w-4",
|
||||||
pathname === item.href ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
pathname === item.href ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
|
||||||
)} />
|
)} />
|
||||||
{!isCollapsed && <span className="ml-3 font-medium text-sm">{item.label}</span>}
|
{(!isCollapsed || isMobile) && (
|
||||||
|
<span className={cn(
|
||||||
|
"ml-3 font-medium",
|
||||||
|
isMobile ? "text-base" : "text-sm"
|
||||||
|
)}>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
@ -144,11 +170,11 @@ const SidebarContent = () => {
|
||||||
onClick={() => setShowClusters(!showClusters)}
|
onClick={() => setShowClusters(!showClusters)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||||
isCollapsed && "justify-center"
|
isCollapsed && !isMobile && "justify-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Database className="h-4 w-4 text-muted-foreground" />
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
||||||
Clusters
|
Clusters
|
||||||
|
|
@ -166,31 +192,23 @@ const SidebarContent = () => {
|
||||||
const IconComponent = getIconComponent(cluster.icon);
|
const IconComponent = getIconComponent(cluster.icon);
|
||||||
const isActive = pathname === `/clusters/${cluster.id}`;
|
const isActive = pathname === `/clusters/${cluster.id}`;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link href={`/clusters/${cluster.id}`} key={cluster.id} passHref>
|
||||||
href={`/clusters/${cluster.id}`}
|
|
||||||
key={cluster.id}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-9 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
||||||
|
isMobile ? "min-h-[44px] h-auto" : "h-9",
|
||||||
isActive && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
isActive && "bg-primary/10 text-primary border border-primary/20 shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-3.5 h-3.5 rounded flex items-center justify-center"
|
className="w-3.5 h-3.5 rounded flex items-center justify-center flex-shrink-0"
|
||||||
style={{ backgroundColor: `${cluster.color}30` }}
|
style={{ backgroundColor: `${cluster.color}30` }}
|
||||||
>
|
>
|
||||||
<IconComponent
|
<IconComponent className="h-2.5 w-2.5" style={{ color: cluster.color }} />
|
||||||
className="h-2.5 w-2.5"
|
|
||||||
style={{ color: cluster.color }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<span className="ml-3 truncate text-xs">
|
<span className="ml-3 truncate text-xs">{cluster.name}</span>
|
||||||
{cluster.name}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -208,11 +226,11 @@ const SidebarContent = () => {
|
||||||
onClick={() => setShowLibraries(!showLibraries)}
|
onClick={() => setShowLibraries(!showLibraries)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||||
isCollapsed && "justify-center"
|
isCollapsed && !isMobile && "justify-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
<h2 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex-1 text-left">
|
||||||
Libraries
|
Libraries
|
||||||
|
|
@ -235,14 +253,15 @@ const SidebarContent = () => {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-start h-9 text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
"w-full justify-start text-muted-foreground hover:text-foreground hover:bg-muted/50 rounded-lg transition-all group text-sm",
|
||||||
|
isMobile ? "min-h-[44px] h-auto" : "h-9",
|
||||||
pathname === "/folder-viewer" &&
|
pathname === "/folder-viewer" &&
|
||||||
searchParams.get("path") === lib.path &&
|
searchParams.get("path") === lib.path &&
|
||||||
"bg-accent text-accent-foreground border border-accent/20"
|
"bg-accent text-accent-foreground border border-accent/20"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
|
||||||
{!isCollapsed && (
|
{(!isCollapsed || isMobile) && (
|
||||||
<span className="ml-3 truncate text-xs">
|
<span className="ml-3 truncate text-xs">
|
||||||
{lib.path.split('/').pop() || lib.path}
|
{lib.path.split('/').pop() || lib.path}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -260,19 +279,45 @@ const SidebarContent = () => {
|
||||||
<div className="p-4 border-t border-border">
|
<div className="p-4 border-t border-border">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"text-center text-xs text-muted-foreground",
|
"text-center text-xs text-muted-foreground",
|
||||||
isCollapsed && "text-[10px]"
|
isCollapsed && !isMobile && "text-[10px]"
|
||||||
)}
|
)}>
|
||||||
>
|
{(!isCollapsed || isMobile) ? "NextAV v1.0" : "v1.0"}
|
||||||
{!isCollapsed ? "NextAV v1.0" : "v1.0"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop: persistent sidebar, hidden on mobile */}
|
||||||
|
<aside className={cn(
|
||||||
|
"hidden lg:flex flex-col bg-card border-r border-border shadow-lg h-full transition-all duration-300 ease-in-out overflow-hidden",
|
||||||
|
isCollapsed ? "w-16" : "w-64"
|
||||||
|
)}>
|
||||||
|
{sidebarInner}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile: overlay drawer */}
|
||||||
|
{isMobileOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={closeMobile}
|
||||||
|
/>
|
||||||
|
{/* Drawer */}
|
||||||
|
<aside className="relative w-72 h-full animate-slide-in shadow-2xl">
|
||||||
|
{sidebarInner}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Sidebar = () => {
|
const Sidebar = () => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="w-16 bg-card border-r border-border" />}>
|
<Suspense fallback={<div className="hidden lg:block w-16 bg-card border-r border-border" />}>
|
||||||
<SidebarContent />
|
<SidebarContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||||
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
||||||
import LocalPlayerLauncher from '@/components/local-player-launcher';
|
import LocalPlayerLauncher from '@/components/local-player-launcher';
|
||||||
|
|
@ -44,6 +44,41 @@ export default function UnifiedVideoPlayer({
|
||||||
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
|
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
|
||||||
const [currentRating, setCurrentRating] = useState(0);
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
|
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
|
||||||
|
const [transcodeLoading, setTranscodeLoading] = useState(false);
|
||||||
|
const [transcodeError, setTranscodeError] = useState<string | null>(null);
|
||||||
|
const [activeTranscodeJobId, setActiveTranscodeJobId] = useState<string | null>(null);
|
||||||
|
const activeTranscodeJobIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Keep ref in sync with state
|
||||||
|
useEffect(() => {
|
||||||
|
activeTranscodeJobIdRef.current = activeTranscodeJobId;
|
||||||
|
}, [activeTranscodeJobId]);
|
||||||
|
|
||||||
|
const releaseTranscodeJob = useCallback((jobId: string | null) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
fetch(`/api/transcode/${jobId}`, { method: 'DELETE', keepalive: true }).catch(error => {
|
||||||
|
console.warn('[UnifiedVideoPlayer] Failed to release transcode job:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetDetectedFormat = useCallback(() => {
|
||||||
|
const detectedFormat = detectVideoFormat(video);
|
||||||
|
setFormat(detectedFormat);
|
||||||
|
setTranscodeError(null);
|
||||||
|
setTranscodeLoading(false);
|
||||||
|
return detectedFormat;
|
||||||
|
}, [video]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
// Read from ref to get the latest value, not a stale closure
|
||||||
|
const jobId = activeTranscodeJobIdRef.current;
|
||||||
|
if (jobId) {
|
||||||
|
releaseTranscodeJob(jobId);
|
||||||
|
setActiveTranscodeJobId(null);
|
||||||
|
}
|
||||||
|
resetDetectedFormat();
|
||||||
|
onClose();
|
||||||
|
}, [onClose, releaseTranscodeJob, resetDetectedFormat]);
|
||||||
|
|
||||||
// Check current bookmark status and rating when video opens
|
// Check current bookmark status and rating when video opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -95,16 +130,79 @@ export default function UnifiedVideoPlayer({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Detect format on mount
|
// Detect/reset format whenever a video is opened. This avoids reusing a killed HLS job URL
|
||||||
|
// after closing and reopening the same video.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (video) {
|
if (video && isOpen) {
|
||||||
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
|
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
|
||||||
const detectedFormat = detectVideoFormat(video);
|
// Read from ref to get latest jobId without needing it in deps
|
||||||
|
const prevJobId = activeTranscodeJobIdRef.current;
|
||||||
|
if (prevJobId) {
|
||||||
|
releaseTranscodeJob(prevJobId);
|
||||||
|
setActiveTranscodeJobId(null);
|
||||||
|
}
|
||||||
|
const detectedFormat = resetDetectedFormat();
|
||||||
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
|
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
|
||||||
setFormat(detectedFormat);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [video]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [video, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || format?.type !== 'local-player' || process.env.NEXT_PUBLIC_ENABLE_LIVE_TRANSCODE !== 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setTranscodeLoading(true);
|
||||||
|
setTranscodeError(null);
|
||||||
|
|
||||||
|
fetch('/api/transcode/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mediaId: video.id }),
|
||||||
|
})
|
||||||
|
.then(async response => {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to start live transcode');
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setActiveTranscodeJobId(data.jobId || null);
|
||||||
|
setFormat({
|
||||||
|
type: 'hls',
|
||||||
|
supportLevel: 'hls',
|
||||||
|
url: data.playlistUrl,
|
||||||
|
warning: `Live transcoding (${data.profile})`,
|
||||||
|
qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn('[UnifiedVideoPlayer] Live transcode unavailable, falling back to local player:', error);
|
||||||
|
setTranscodeError(error instanceof Error ? error.message : 'Live transcode unavailable');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setTranscodeLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen, format?.type, video.id]);
|
||||||
|
|
||||||
|
// When the modal closes while a transcode job is active, kill it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) return;
|
||||||
|
const jobId = activeTranscodeJobIdRef.current;
|
||||||
|
if (!jobId) return;
|
||||||
|
releaseTranscodeJob(jobId);
|
||||||
|
setActiveTranscodeJobId(null);
|
||||||
|
resetDetectedFormat();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
// Handle ArtPlayer errors with recovery
|
// Handle ArtPlayer errors with recovery
|
||||||
const handleArtPlayerError = useCallback((error: string) => {
|
const handleArtPlayerError = useCallback((error: string) => {
|
||||||
|
|
@ -170,7 +268,7 @@ export default function UnifiedVideoPlayer({
|
||||||
<LocalPlayerLauncher
|
<LocalPlayerLauncher
|
||||||
video={video}
|
video={video}
|
||||||
format={format}
|
format={format}
|
||||||
onClose={onClose}
|
onClose={handleClose}
|
||||||
onPlayerSelect={(playerId) => {
|
onPlayerSelect={(playerId) => {
|
||||||
console.log(`Selected player: ${playerId}`);
|
console.log(`Selected player: ${playerId}`);
|
||||||
}}
|
}}
|
||||||
|
|
@ -180,6 +278,7 @@ export default function UnifiedVideoPlayer({
|
||||||
onRate={handleRatingUpdate}
|
onRate={handleRatingUpdate}
|
||||||
showBookmarks={showBookmarks}
|
showBookmarks={showBookmarks}
|
||||||
showRatings={showRatings}
|
showRatings={showRatings}
|
||||||
|
notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +289,7 @@ export default function UnifiedVideoPlayer({
|
||||||
<ArtPlayerWrapper
|
<ArtPlayerWrapper
|
||||||
video={video}
|
video={video}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={handleClose}
|
||||||
onProgress={handleProgressUpdate}
|
onProgress={handleProgressUpdate}
|
||||||
onBookmark={handleBookmarkToggle}
|
onBookmark={handleBookmarkToggle}
|
||||||
onUnbookmark={handleUnbookmark}
|
onUnbookmark={handleUnbookmark}
|
||||||
|
|
@ -203,16 +302,17 @@ export default function UnifiedVideoPlayer({
|
||||||
showBookmarks={showBookmarks}
|
showBookmarks={showBookmarks}
|
||||||
showRatings={showRatings}
|
showRatings={showRatings}
|
||||||
autoplay={autoplay}
|
autoplay={autoplay}
|
||||||
|
formatOverride={format || undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading || bookmarkCheckLoading || ratingCheckLoading) {
|
if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||||
<div className="text-white text-center">
|
<div className="text-white text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||||
<p>Loading ArtPlayer...</p>
|
<p>{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface SidebarContextType {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isMobileOpen: boolean;
|
||||||
|
toggleCollapse: () => void;
|
||||||
|
openMobile: () => void;
|
||||||
|
closeMobile: () => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(max-width: 1023px)");
|
||||||
|
const handler = (e: MediaQueryListEvent | MediaQueryList) => {
|
||||||
|
setIsMobile(e.matches);
|
||||||
|
if (!e.matches) setIsMobileOpen(false);
|
||||||
|
};
|
||||||
|
handler(mq);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-close mobile drawer on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMobileOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback(() => setIsCollapsed((v) => !v), []);
|
||||||
|
const openMobile = useCallback(() => setIsMobileOpen(true), []);
|
||||||
|
const closeMobile = useCallback(() => setIsMobileOpen(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider
|
||||||
|
value={{ isCollapsed, isMobileOpen, toggleCollapse, openMobile, closeMobile, isMobile }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebar() {
|
||||||
|
const ctx = useContext(SidebarContext);
|
||||||
|
if (!ctx) throw new Error("useSidebar must be used within SidebarProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
@ -140,6 +140,24 @@ function initializeDatabase() {
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Operational state for live transcoding jobs. Jobs are short-lived, but
|
||||||
|
// persisting them lets the server recover cleanly after a restart.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS transcode_jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
media_id INTEGER NOT NULL,
|
||||||
|
profile TEXT NOT NULL,
|
||||||
|
out_dir TEXT NOT NULL,
|
||||||
|
start_ts REAL NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('pending', 'encoding', 'ready', 'paused', 'failed', 'killed')),
|
||||||
|
pid INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_ping_at DATETIME,
|
||||||
|
error TEXT,
|
||||||
|
FOREIGN KEY (media_id) REFERENCES media(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// Create indexes for performance
|
// Create indexes for performance
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
||||||
|
|
@ -165,6 +183,8 @@ function initializeDatabase() {
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`);
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`);
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status);`);
|
||||||
|
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try transcoding headers if transcoding
|
// 2. Use fallback when database duration is unavailable.
|
||||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
|
||||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
|
||||||
if (headerDuration) {
|
|
||||||
const durationValue = parseFloat(headerDuration);
|
|
||||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
|
||||||
setDuration(durationValue);
|
|
||||||
hasRealDuration.current = true;
|
|
||||||
console.log(`[DURATION] Using header duration: ${durationValue}s`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Use fallback
|
|
||||||
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
||||||
setDuration(fallbackDuration);
|
setDuration(fallbackDuration);
|
||||||
|
|
||||||
|
|
@ -157,16 +144,6 @@ export async function getRealDuration(videoId: string): Promise<number> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Try transcoding headers
|
|
||||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
|
||||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
|
||||||
if (headerDuration) {
|
|
||||||
const durationValue = parseFloat(headerDuration);
|
|
||||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
|
||||||
return durationValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[DURATION] Error getting real duration:', error);
|
console.error('[DURATION] Error getting real duration:', error);
|
||||||
|
|
|
||||||
|
|
@ -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,388 @@
|
||||||
|
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getDatabase } from '@/db';
|
||||||
|
import { getTranscodeConfig } from './config';
|
||||||
|
import { ConcurrencyGate, ConcurrencyLimitError } from './concurrency';
|
||||||
|
import { decideTranscode, TranscodeDecision } from './decision';
|
||||||
|
import { buildRemuxArgs, buildTranscodeArgs } from './ffmpeg-args';
|
||||||
|
import { parseStoredProbe, probeMedia, ProbeInfo } from './ffprobe';
|
||||||
|
import { HeartbeatManager } from './heartbeat';
|
||||||
|
import { SegmentJanitor } from './janitor';
|
||||||
|
import { listSegmentFiles } from './playlist';
|
||||||
|
import { getDefaultProfileName, TranscodeProfileName } from './profiles';
|
||||||
|
import { TranscodeThrottler } from './throttler';
|
||||||
|
|
||||||
|
export type TranscodeJobStatus = 'pending' | 'encoding' | 'ready' | 'paused' | 'failed' | 'killed';
|
||||||
|
|
||||||
|
export interface TranscodeJob {
|
||||||
|
id: string;
|
||||||
|
mediaId: number;
|
||||||
|
mediaPath: string;
|
||||||
|
profile: TranscodeProfileName;
|
||||||
|
outDir: string;
|
||||||
|
startTime: number;
|
||||||
|
status: TranscodeJobStatus;
|
||||||
|
process?: ChildProcessWithoutNullStreams;
|
||||||
|
createdAt: number;
|
||||||
|
lastPingAt: number;
|
||||||
|
lastClientPosition: number;
|
||||||
|
knownDuration: number;
|
||||||
|
error?: string;
|
||||||
|
stderrRing: string[];
|
||||||
|
decision: TranscodeDecision;
|
||||||
|
throttler?: TranscodeThrottler;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MediaRow {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
codec_info?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TranscodeDisabledError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Live transcoding is disabled');
|
||||||
|
this.name = 'TranscodeDisabledError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TranscodeOrchestrator {
|
||||||
|
private readonly jobs = new Map<string, TranscodeJob>();
|
||||||
|
private readonly gate = new ConcurrencyGate(getTranscodeConfig().maxConcurrent);
|
||||||
|
private readonly heartbeat = new HeartbeatManager();
|
||||||
|
private readonly janitor = new SegmentJanitor();
|
||||||
|
private recovered = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const config = getTranscodeConfig();
|
||||||
|
fs.mkdirSync(config.tmpDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
recover(): void {
|
||||||
|
if (this.recovered) return;
|
||||||
|
this.recovered = true;
|
||||||
|
const db = getDatabase();
|
||||||
|
db.prepare("UPDATE transcode_jobs SET status = 'killed', error = COALESCE(error, 'server restarted') WHERE status IN ('pending', 'encoding', 'paused')").run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(mediaId: number, requestedProfile: string | undefined, startTime: number): Promise<TranscodeJob> {
|
||||||
|
const config = getTranscodeConfig();
|
||||||
|
if (!config.enabled) {
|
||||||
|
throw new TranscodeDisabledError();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recover();
|
||||||
|
this.janitor.enforceBudget();
|
||||||
|
|
||||||
|
const profile = getDefaultProfileName(requestedProfile || config.defaultProfile);
|
||||||
|
const existing = this.findReusableJob(mediaId, profile, startTime);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const media = this.getMedia(mediaId);
|
||||||
|
const probe = await this.getProbe(media);
|
||||||
|
const decision = decideTranscode(media.path, probe, profile);
|
||||||
|
|
||||||
|
if (decision.kind === 'direct') {
|
||||||
|
throw new Error('Media is directly playable and does not need live transcoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gate.acquire();
|
||||||
|
|
||||||
|
const jobId = crypto.randomUUID();
|
||||||
|
const outDir = path.join(config.tmpDir, String(mediaId), `${decision.profile}-${jobId}`);
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const job: TranscodeJob = {
|
||||||
|
id: jobId,
|
||||||
|
mediaId,
|
||||||
|
mediaPath: media.path,
|
||||||
|
profile: decision.profile,
|
||||||
|
outDir,
|
||||||
|
startTime,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastPingAt: Date.now(),
|
||||||
|
lastClientPosition: startTime,
|
||||||
|
knownDuration: probe.duration,
|
||||||
|
stderrRing: [],
|
||||||
|
decision,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.jobs.set(jobId, job);
|
||||||
|
this.persistJob(job);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.spawnJob(job);
|
||||||
|
return job;
|
||||||
|
} catch (error) {
|
||||||
|
await this.kill(jobId, error instanceof Error ? error.message : 'startup failed', 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(jobId: string): TranscodeJob | undefined {
|
||||||
|
return this.jobs.get(jobId);
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
enabled: getTranscodeConfig().enabled,
|
||||||
|
concurrency: this.gate.snapshot(),
|
||||||
|
jobs: Array.from(this.jobs.values()).map(job => ({
|
||||||
|
id: job.id,
|
||||||
|
mediaId: job.mediaId,
|
||||||
|
profile: job.profile,
|
||||||
|
status: job.status,
|
||||||
|
pid: job.process?.pid,
|
||||||
|
startTime: job.startTime,
|
||||||
|
lastPingAt: job.lastPingAt,
|
||||||
|
lastClientPosition: job.lastClientPosition,
|
||||||
|
segmentCount: listSegmentFiles(job.outDir).length,
|
||||||
|
decision: job.decision.kind,
|
||||||
|
error: job.error,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ping(jobId: string, position: number, isPaused: boolean): boolean {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job) return false;
|
||||||
|
const config = getTranscodeConfig();
|
||||||
|
job.lastPingAt = Date.now();
|
||||||
|
job.lastClientPosition = position;
|
||||||
|
job.throttler?.updateClient(position, isPaused);
|
||||||
|
this.heartbeat.arm(jobId, config.heartbeatTimeoutMs, () => {
|
||||||
|
this.kill(jobId, 'heartbeat timeout').catch(error => console.error('[Transcode] heartbeat kill failed', error));
|
||||||
|
});
|
||||||
|
getDatabase().prepare('UPDATE transcode_jobs SET last_ping_at = CURRENT_TIMESTAMP WHERE job_id = ?').run(jobId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(jobId: string, position: number): Promise<{ reused: true } | { reused: false; job: TranscodeJob }> {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job) throw new Error('Transcode job not found');
|
||||||
|
|
||||||
|
const encodedSeconds = listSegmentFiles(job.outDir).length * getTranscodeConfig().segmentDuration;
|
||||||
|
if (position >= job.startTime && position <= job.startTime + Math.max(0, encodedSeconds - 8)) {
|
||||||
|
return { reused: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.kill(jobId, 'seek restart', getTranscodeConfig().cacheGraceMs);
|
||||||
|
const newJob = await this.start(job.mediaId, job.profile, position);
|
||||||
|
return { reused: false, job: newJob };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously remove the job from the in-memory map so that segment/playlist
|
||||||
|
* routes start returning 404 immediately. Returns the job's FFmpeg process (if
|
||||||
|
* still running) so the caller can kill it asynchronously in the background.
|
||||||
|
*/
|
||||||
|
markKilled(jobId: string): ChildProcessWithoutNullStreams | null {
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job || job.status === 'killed') return null;
|
||||||
|
job.status = 'killed';
|
||||||
|
job.error = 'client closed';
|
||||||
|
this.heartbeat.clear(jobId);
|
||||||
|
job.throttler?.stop();
|
||||||
|
this.gate.release();
|
||||||
|
this.jobs.delete(jobId);
|
||||||
|
// Persist status to DB so recovery on restart doesn't see stale jobs.
|
||||||
|
try {
|
||||||
|
getDatabase().prepare('UPDATE transcode_jobs SET status = ?, error = ? WHERE job_id = ?').run('killed', 'client closed', jobId);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
// Schedule cleanup with the normal grace period.
|
||||||
|
this.janitor.scheduleCleanup(job.outDir, getTranscodeConfig().cacheGraceMs);
|
||||||
|
// Return the live process so the caller can terminate it asynchronously.
|
||||||
|
return (job.process && job.process.exitCode === null) ? job.process : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminate a raw FFmpeg process (graceful → SIGTERM → SIGKILL). */
|
||||||
|
async killProcess(proc: ChildProcessWithoutNullStreams): Promise<void> {
|
||||||
|
if (proc.exitCode !== null) return;
|
||||||
|
try { proc.stdin.write('q\n'); } catch { /* already gone */ }
|
||||||
|
const exited = await waitForExit(proc, 3000);
|
||||||
|
if (!exited && proc.exitCode === null) {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
await waitForExit(proc, 2000);
|
||||||
|
}
|
||||||
|
if (proc.exitCode === null) {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async kill(jobId: string, reason = 'client closed', cleanupDelay = getTranscodeConfig().cacheGraceMs): Promise<void> {
|
||||||
|
// If markKilled() already ran, the job is gone from the map but we might
|
||||||
|
// still need to wait for FFmpeg to exit. Re-check via a local reference.
|
||||||
|
const job = this.jobs.get(jobId);
|
||||||
|
if (!job) {
|
||||||
|
// Job was already removed by markKilled(); just return.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.status = reason === 'completed' ? 'ready' : 'killed';
|
||||||
|
job.error = reason === 'completed' ? undefined : reason;
|
||||||
|
this.heartbeat.clear(jobId);
|
||||||
|
job.throttler?.stop();
|
||||||
|
|
||||||
|
const proc = job.process;
|
||||||
|
if (proc && proc.exitCode === null) {
|
||||||
|
try {
|
||||||
|
proc.stdin.write('q\n');
|
||||||
|
} catch {
|
||||||
|
// Process may already be gone.
|
||||||
|
}
|
||||||
|
const exited = await waitForExit(proc, 3000);
|
||||||
|
if (!exited && proc.exitCode === null) {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
await waitForExit(proc, 2000);
|
||||||
|
}
|
||||||
|
if (proc.exitCode === null) {
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDatabase().prepare('UPDATE transcode_jobs SET status = ?, error = ? WHERE job_id = ?').run(job.status, job.error || null, jobId);
|
||||||
|
this.gate.release();
|
||||||
|
this.jobs.delete(jobId);
|
||||||
|
this.janitor.scheduleCleanup(job.outDir, cleanupDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findReusableJob(mediaId: number, profile: TranscodeProfileName, startTime: number): TranscodeJob | undefined {
|
||||||
|
const now = Date.now();
|
||||||
|
return Array.from(this.jobs.values()).find(job =>
|
||||||
|
job.mediaId === mediaId &&
|
||||||
|
job.profile === profile &&
|
||||||
|
Math.abs(job.startTime - startTime) < 1 &&
|
||||||
|
(job.status === 'encoding' || job.status === 'ready') &&
|
||||||
|
now - job.lastPingAt < 30_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMedia(mediaId: number): MediaRow {
|
||||||
|
const media = getDatabase().prepare("SELECT id, path, codec_info FROM media WHERE id = ? AND type = 'video'").get(mediaId) as MediaRow | undefined;
|
||||||
|
if (!media) throw new Error('Video not found');
|
||||||
|
if (!fs.existsSync(media.path)) throw new Error('Video file not found on disk');
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProbe(media: MediaRow): Promise<ProbeInfo> {
|
||||||
|
const stored = parseStoredProbe(media.codec_info);
|
||||||
|
if (stored) return stored;
|
||||||
|
const probed = await probeMedia(media.path);
|
||||||
|
getDatabase().prepare('UPDATE media SET codec_info = ? WHERE id = ?').run(JSON.stringify(probed), media.id);
|
||||||
|
return probed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistJob(job: TranscodeJob): void {
|
||||||
|
getDatabase().prepare(`
|
||||||
|
INSERT INTO transcode_jobs (job_id, media_id, profile, out_dir, start_ts, status, pid, last_ping_at, error)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)
|
||||||
|
`).run(job.id, job.mediaId, job.profile, job.outDir, job.startTime, job.status, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnJob(job: TranscodeJob): Promise<void> {
|
||||||
|
const config = getTranscodeConfig();
|
||||||
|
const args = job.decision.kind === 'hls-remux'
|
||||||
|
? buildRemuxArgs(job.mediaPath, job.outDir, job.startTime)
|
||||||
|
: buildTranscodeArgs(job.mediaPath, job.outDir, job.profile as Exclude<TranscodeProfileName, 'remux-copy'>, job.startTime);
|
||||||
|
|
||||||
|
const proc = spawn(config.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
job.process = proc;
|
||||||
|
job.status = 'encoding';
|
||||||
|
job.throttler = new TranscodeThrottler(proc);
|
||||||
|
job.throttler.start();
|
||||||
|
|
||||||
|
getDatabase().prepare("UPDATE transcode_jobs SET status = 'encoding', pid = ? WHERE job_id = ?").run(proc.pid || null, job.id);
|
||||||
|
this.heartbeat.arm(job.id, config.heartbeatTimeoutMs, () => {
|
||||||
|
this.kill(job.id, 'heartbeat timeout').catch(error => console.error('[Transcode] heartbeat kill failed', error));
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stderr.on('data', (chunk: string) => {
|
||||||
|
const lines = chunk.split(/\r?\n/).filter(Boolean);
|
||||||
|
for (const line of lines) {
|
||||||
|
job.stderrRing.push(line);
|
||||||
|
if (job.stderrRing.length > 50) job.stderrRing.shift();
|
||||||
|
job.throttler?.updateProgress(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('exit', code => {
|
||||||
|
job.throttler?.stop();
|
||||||
|
if (code === 0 && job.status !== 'killed') {
|
||||||
|
job.status = 'ready';
|
||||||
|
getDatabase().prepare("UPDATE transcode_jobs SET status = 'ready' WHERE job_id = ?").run(job.id);
|
||||||
|
} else if (job.status !== 'killed') {
|
||||||
|
job.status = 'failed';
|
||||||
|
job.error = job.stderrRing.slice(-10).join('\n') || `ffmpeg exited with code ${code}`;
|
||||||
|
getDatabase().prepare("UPDATE transcode_jobs SET status = 'failed', error = ? WHERE job_id = ?").run(job.error, job.id);
|
||||||
|
this.gate.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForFirstSegment(job.outDir, config.startupTimeoutMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(proc: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<boolean> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
if (proc.exitCode !== null) {
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve(false);
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref();
|
||||||
|
|
||||||
|
const onExit = () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
proc.off('exit', onExit);
|
||||||
|
};
|
||||||
|
|
||||||
|
proc.once('exit', onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForFirstSegment(outDir: string, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (listSegmentFiles(outDir).length > 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
watcher.close();
|
||||||
|
reject(new Error('TRANSCODE_STARTUP_TIMEOUT'));
|
||||||
|
}, timeoutMs);
|
||||||
|
timeout.unref();
|
||||||
|
|
||||||
|
const watcher = fs.watch(outDir, () => {
|
||||||
|
if (listSegmentFiles(outDir).length > 0) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
watcher.close();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForTranscode = globalThis as typeof globalThis & {
|
||||||
|
nextavTranscodeOrchestrator?: TranscodeOrchestrator;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transcodeOrchestrator =
|
||||||
|
globalForTranscode.nextavTranscodeOrchestrator ?? new TranscodeOrchestrator();
|
||||||
|
|
||||||
|
globalForTranscode.nextavTranscodeOrchestrator = transcodeOrchestrator;
|
||||||
|
|
||||||
|
export { ConcurrencyLimitError };
|
||||||
|
|
@ -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
|
// MPEG Transport Stream formats - TEMPORARY: treat as local player required
|
||||||
// TODO: Fix the .ts resegmentation implementation for HLS streaming
|
// TODO: Fix the .ts resegmentation implementation for HLS streaming
|
||||||
const TS_STREAM_FORMATS = [
|
const TS_STREAM_FORMATS: string[] = [
|
||||||
// 'ts', // TEMPORARILY DISABLED - send to local player instead
|
// 'ts', // TEMPORARILY DISABLED - send to local player instead
|
||||||
'm2ts', // Blu-ray Transport Stream
|
// 'm2ts', // Blu-ray Transport Stream
|
||||||
'mts' // AVCHD Transport Stream
|
// 'mts' // AVCHD Transport Stream
|
||||||
];
|
];
|
||||||
|
|
||||||
// Formats with limited support (may need transcoding)
|
// Formats with limited support (may need transcoding)
|
||||||
|
|
@ -191,27 +191,28 @@ function createDirectFormat(video: VideoFile, extension: string): VideoFormat {
|
||||||
* Create HLS streaming format configuration
|
* Create HLS streaming format configuration
|
||||||
*/
|
*/
|
||||||
function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||||
|
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||||
return {
|
return {
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
supportLevel: 'hls',
|
supportLevel: 'hls',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
url,
|
||||||
qualities: [
|
qualities: [
|
||||||
{
|
{
|
||||||
html: 'Auto',
|
html: 'Auto',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
url,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
html: '1080p',
|
html: '1080p',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=1080`
|
url: `${url}&profile=1080p-h264-aac`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
html: '720p',
|
html: '720p',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=720`
|
url: `${url}&profile=720p-h264-aac`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
html: '480p',
|
html: '480p',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=480`
|
url: `${url}&profile=480p-h264-aac`
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -222,14 +223,15 @@ function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||||
* .ts files are already in HLS-compatible format, so we use HLS streaming
|
* .ts files are already in HLS-compatible format, so we use HLS streaming
|
||||||
*/
|
*/
|
||||||
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||||
|
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||||
return {
|
return {
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
supportLevel: 'hls',
|
supportLevel: 'hls',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
url,
|
||||||
qualities: [
|
qualities: [
|
||||||
{
|
{
|
||||||
html: 'Auto (HLS)',
|
html: 'Auto (HLS)',
|
||||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
url,
|
||||||
default: true
|
default: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end test for the live transcoding lifecycle (v2 rewrite).
|
||||||
|
*
|
||||||
|
* Covers the staged changes:
|
||||||
|
* - POST /api/transcode/start -> job + playlist URL
|
||||||
|
* - master.m3u8 serves a VOD playlist immediately
|
||||||
|
* - seg/ waits up to 5s for a segment to exist (waitForSegment)
|
||||||
|
* - ping keeps the job alive; status reports it
|
||||||
|
* - seek within the encoded window is reused (204)
|
||||||
|
* - DELETE is fast (markKilled) and segment/playlist 404 immediately after
|
||||||
|
* - the FFmpeg process actually exits afterwards
|
||||||
|
* - repeated DELETE is idempotent
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node tests/streaming/test-transcode-lifecycle.mjs [mediaId]
|
||||||
|
*
|
||||||
|
* Requires the dev server running with ENABLE_LIVE_TRANSCODE=true and a media
|
||||||
|
* row whose format decision is hls-remux or hls-transcode (e.g. a .mov).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||||
|
const MEDIA_ID = Number(process.argv[2] || process.env.TEST_MEDIA_ID || 127);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
function ok(condition, label, extra = '') {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ✅ ${label}${extra ? ` (${extra})` : ''}`);
|
||||||
|
} else {
|
||||||
|
failures += 1;
|
||||||
|
console.log(` ❌ ${label}${extra ? ` (${extra})` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body, timeoutMs = 15_000 } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
body: text,
|
||||||
|
json: (() => { try { return JSON.parse(text); } catch { return null; } })(),
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🧪 Transcode lifecycle e2e — media #${MEDIA_ID} @ ${BASE_URL}`);
|
||||||
|
console.log('======================================================');
|
||||||
|
|
||||||
|
// ── 1. Start a transcode job ──────────────────────────────────────
|
||||||
|
console.log('\n1. Start transcode job');
|
||||||
|
const start = await request('/api/transcode/start', { method: 'POST', body: { mediaId: MEDIA_ID, startTime: 0 } });
|
||||||
|
ok(start.status === 200, 'POST /api/transcode/start -> 200', `elapsed ${start.elapsedMs}ms`);
|
||||||
|
if (start.status !== 200) {
|
||||||
|
console.log(` response: ${start.body.slice(0, 300)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const jobId = start.json.jobId;
|
||||||
|
const playlistUrl = start.json.playlistUrl;
|
||||||
|
ok(typeof jobId === 'string' && jobId.length > 0, 'jobId returned');
|
||||||
|
ok(typeof playlistUrl === 'string' && playlistUrl.includes(jobId), 'playlistUrl returned');
|
||||||
|
ok(start.json.decision === 'hls-remux' || start.json.decision === 'hls-transcode', `decision ${start.json.decision}`);
|
||||||
|
|
||||||
|
// ── 2. Playlist serves a VOD playlist ─────────────────────────────
|
||||||
|
console.log('\n2. Master playlist');
|
||||||
|
const playlist = await request(playlistUrl);
|
||||||
|
ok(playlist.status === 200, `GET ${playlistUrl} -> 200`, `elapsed ${playlist.elapsedMs}ms`);
|
||||||
|
ok(playlist.body.includes('#EXTM3U'), 'contains #EXTM3U');
|
||||||
|
ok(playlist.body.includes('#EXT-X-PLAYLIST-TYPE:VOD'), 'playlist type VOD');
|
||||||
|
const segmentMatch = playlist.body.match(/seg\/seg_\d{5}\.ts/);
|
||||||
|
ok(!!segmentMatch, 'references a segment file');
|
||||||
|
|
||||||
|
// ── 3. Segment delivery (includes waitForSegment path) ────────────
|
||||||
|
console.log('\n3. Segment delivery');
|
||||||
|
const firstSegmentName = segmentMatch[0].replace('seg/', '');
|
||||||
|
const firstSeg = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
|
||||||
|
ok(firstSeg.status === 200, `GET ${firstSegmentName} -> 200`, `elapsed ${firstSeg.elapsedMs}ms`);
|
||||||
|
ok(firstSeg.headers.get('content-type') === 'video/mp2t', 'content-type video/mp2t');
|
||||||
|
ok(Number(firstSeg.headers.get('content-length') || 0) > 0, 'segment non-empty');
|
||||||
|
|
||||||
|
// A segment a bit further ahead: waitForSegment polls until ffmpeg writes it.
|
||||||
|
const aheadIndex = String(30).padStart(5, '0');
|
||||||
|
const aheadName = `seg_${aheadIndex}.ts`;
|
||||||
|
const ahead = await request(`/api/transcode/${jobId}/seg/${aheadName}`, { timeoutMs: 10_000 });
|
||||||
|
ok(ahead.status === 200, `GET ${aheadName} (waitForSegment) -> 200`, `elapsed ${ahead.elapsedMs}ms`);
|
||||||
|
|
||||||
|
// A segment that will never exist: should poll for ~5s then 404.
|
||||||
|
const neverSeg = await request(`/api/transcode/${jobId}/seg/seg_99999.ts`, { timeoutMs: 10_000 });
|
||||||
|
ok(neverSeg.status === 404, 'GET seg_99999.ts -> 404', `elapsed ${neverSeg.elapsedMs}ms (expected ~5000)`);
|
||||||
|
ok(neverSeg.elapsedMs >= 4500, 'waited for segment before 404ing (poll path exercised)');
|
||||||
|
|
||||||
|
// ── 4. Ping keeps the job alive ───────────────────────────────────
|
||||||
|
console.log('\n4. Heartbeat ping');
|
||||||
|
const ping = await request(`/api/transcode/${jobId}/ping`, { method: 'POST', body: { position: 0, isPaused: false } });
|
||||||
|
ok(ping.status === 200 && ping.json?.success === true, 'POST ping -> 200');
|
||||||
|
|
||||||
|
const statusBefore = await request('/api/transcode/status');
|
||||||
|
const listed = statusBefore.json?.jobs?.find(j => j.id === jobId);
|
||||||
|
ok(statusBefore.status === 200 && !!listed, 'job appears in /api/transcode/status', listed ? `status=${listed.status}` : '');
|
||||||
|
|
||||||
|
// ── 5. Seek within the encoded window is reused ───────────────────
|
||||||
|
console.log('\n5. Seek within encoded window');
|
||||||
|
const seek = await request(`/api/transcode/${jobId}/seek`, { method: 'POST', body: { position: 0 } });
|
||||||
|
ok(seek.status === 204, 'POST seek {position:0} -> 204 (job reused)');
|
||||||
|
|
||||||
|
// ── 6. DELETE fast-path + immediate 404s ─────────────────────────
|
||||||
|
console.log('\n6. DELETE (markKilled fast path)');
|
||||||
|
const del = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
|
||||||
|
ok(del.status === 200 && del.json?.success === true, 'DELETE -> 200 success', `elapsed ${del.elapsedMs}ms`);
|
||||||
|
ok(del.elapsedMs < 1000, 'DELETE responds in <1s (markKilled is synchronous)', `${del.elapsedMs}ms`);
|
||||||
|
|
||||||
|
const playlistAfter = await request(playlistUrl);
|
||||||
|
ok(playlistAfter.status === 404, 'playlist 404 immediately after DELETE');
|
||||||
|
const segAfter = await request(`/api/transcode/${jobId}/seg/${firstSegmentName}`);
|
||||||
|
ok(segAfter.status === 404, 'segment 404 immediately after DELETE');
|
||||||
|
|
||||||
|
const statusAfter = await request('/api/transcode/status');
|
||||||
|
ok(!statusAfter.json?.jobs?.some(j => j.id === jobId), 'job removed from status');
|
||||||
|
|
||||||
|
// ── 7. FFmpeg process actually exits ──────────────────────────────
|
||||||
|
console.log('\n7. FFmpeg process termination');
|
||||||
|
await sleep(5000);
|
||||||
|
const { execSync } = await import('node:child_process');
|
||||||
|
let ffmpegStillRunning = false;
|
||||||
|
try {
|
||||||
|
const out = execSync(`pgrep -fl "${jobId}" || true`, { encoding: 'utf8' }).trim();
|
||||||
|
ffmpegStillRunning = out.length > 0;
|
||||||
|
if (ffmpegStillRunning) console.log(` lingering: ${out}`);
|
||||||
|
} catch {
|
||||||
|
// pgrep unavailable — treat as pass-through, best-effort check
|
||||||
|
}
|
||||||
|
ok(!ffmpegStillRunning, 'no FFmpeg process remains for the job after grace period');
|
||||||
|
|
||||||
|
// ── 8. Idempotent DELETE ──────────────────────────────────────────
|
||||||
|
console.log('\n8. Idempotent DELETE');
|
||||||
|
const delAgain = await request(`/api/transcode/${jobId}`, { method: 'DELETE' });
|
||||||
|
ok(delAgain.status === 200, 'second DELETE -> 200 (no crash)');
|
||||||
|
|
||||||
|
// ── 9. Unknown job 404s ───────────────────────────────────────────
|
||||||
|
console.log('\n9. Unknown job handling');
|
||||||
|
const missing = await request('/api/transcode/00000000-0000-0000-0000-000000000000/master.m3u8');
|
||||||
|
ok(missing.status === 404, 'playlist for unknown job -> 404');
|
||||||
|
|
||||||
|
console.log('\n======================================================');
|
||||||
|
if (failures === 0) {
|
||||||
|
console.log(`🎉 ALL CHECKS PASSED (job ${jobId})`);
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log(`💥 ${failures} check(s) FAILED (job ${jobId})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error('❌ Test runner crashed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue