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}`,
|
||||
external: `${baseUrl}/api/external-stream/${video.id}`,
|
||||
|
||||
// HLS streaming (if supported)
|
||||
hls: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
// Live transcoding starts with POST /api/transcode/start and returns the playlist URL.
|
||||
hls: `${baseUrl}/api/transcode/start`,
|
||||
|
||||
// Protocol-specific URLs for external players
|
||||
protocols: {
|
||||
|
|
@ -116,7 +116,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||
// Browser-compatible formats
|
||||
browser: {
|
||||
native: isNativeSupported(fileExtension) ? `${baseUrl}/api/stream/direct/${video.id}` : null,
|
||||
transcoded: `${baseUrl}/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
transcoded: `${baseUrl}/api/transcode/start`,
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
// TRANSCODING DISABLED: These imports are no longer needed
|
||||
// import { getDatabase } from '@/db';
|
||||
// import fs from 'fs';
|
||||
// import { spawn } from 'child_process';
|
||||
// import { Readable } from 'stream';
|
||||
// import { ffmpegRegistry } from '@/lib/ffmpeg/process-registry';
|
||||
|
||||
// TRANSCODING DISABLED: Request tracking no longer needed
|
||||
// const activeRequests = new Map<string, Promise<Response>>();
|
||||
|
||||
export async function HEAD(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// TRANSCODING DISABLED: Return 410 Gone with local player guidance
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Transcoding is disabled. This format requires a local video player.',
|
||||
suggestedPlayers: ['VLC Media Player', 'Elmedia Player', 'PotPlayer'],
|
||||
directStreamUrl: `/api/stream/direct/${id}`,
|
||||
helpUrl: '/help/local-players',
|
||||
status: 'transcoding-disabled'
|
||||
}, { status: 410 }); // 410 Gone
|
||||
} catch (error) {
|
||||
console.error('HEAD request error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// TRANSCODING DISABLED: Return 410 Gone for OPTIONS as well
|
||||
return new Response(null, {
|
||||
status: 410, // Gone
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Range',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
'X-Status': 'transcoding-disabled',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// TRANSCODING DISABLED: Return 410 Gone with comprehensive local player guidance
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Transcoding is disabled. This format requires a local video player.',
|
||||
message: 'This video format is not supported for direct browser playback. Please use a local video player application.',
|
||||
suggestedPlayers: [
|
||||
{ name: 'VLC Media Player', id: 'vlc', platforms: ['Windows', 'macOS', 'Linux'], url: 'https://www.videolan.org/vlc/' },
|
||||
{ name: 'IINA', id: 'iina', platforms: ['macOS'], url: 'https://iina.io/' },
|
||||
{ name: 'Elmedia Player', id: 'elmedia', platforms: ['macOS'], url: 'https://www.elmedia-video-player.com/' },
|
||||
{ name: 'PotPlayer', id: 'potplayer', platforms: ['Windows'], url: 'https://potplayer.daum.net/' }
|
||||
],
|
||||
directStreamUrl: `/api/stream/direct/${id}`,
|
||||
streamInfo: {
|
||||
supportsRangeRequests: true,
|
||||
contentType: 'video/*',
|
||||
authentication: 'none'
|
||||
},
|
||||
action: 'use-local-player',
|
||||
helpUrl: '/help/local-players',
|
||||
status: 'transcoding-disabled',
|
||||
alternative: 'direct-stream'
|
||||
}, { status: 410 }); // 410 Gone
|
||||
} catch (error) {
|
||||
console.error('Transcoding API error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// TRANSCODING DISABLED: Comment out transcoding functionality
|
||||
// This function is no longer used but kept for reference during transition
|
||||
/*
|
||||
async function createTranscodeStream(
|
||||
id: string,
|
||||
filePath: string,
|
||||
seekTime: number,
|
||||
quality: string,
|
||||
duration: number,
|
||||
settings: { width: number, height: number, bitrate: string }
|
||||
): Promise<Response> {
|
||||
// Original transcoding logic disabled
|
||||
// See git history for implementation details
|
||||
throw new Error('Transcoding is disabled. Use local player instead.');
|
||||
}
|
||||
*/
|
||||
|
||||
// TRANSCODING DISABLED: Cleanup function no longer needed
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// TRANSCODING DISABLED: Return 410 Gone - no processes to clean up
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Transcoding cleanup is disabled. No processes to terminate.',
|
||||
status: 'transcoding-disabled',
|
||||
message: 'Transcoding functionality has been removed. Use local video players instead.'
|
||||
}, { status: 410 }); // 410 Gone
|
||||
} catch (error) {
|
||||
console.error('Cleanup API error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Manual cleanup endpoint for HLS segmentation sessions
|
||||
* POST /api/stream/hls/[id]/cleanup - Force cleanup specific session
|
||||
* DELETE /api/stream/hls/[id]/cleanup - Same as POST
|
||||
*/
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if this is a reference release or force cleanup
|
||||
const body = await request.json().catch(() => ({ action: 'force_cleanup' }));
|
||||
const { action = 'force_cleanup' } = body;
|
||||
|
||||
if (action === 'release_reference') {
|
||||
// Release a reference to the session
|
||||
await hlsSessionManager.releaseSession(videoId);
|
||||
console.log(`[HLS-Cleanup] Reference released for video ${videoId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Reference released for video ${videoId}`,
|
||||
videoId
|
||||
});
|
||||
|
||||
} else {
|
||||
// Force cleanup the session (default behavior)
|
||||
await hlsSessionManager.forceCleanupSession(videoId);
|
||||
console.log(`[HLS-Cleanup] Manual cleanup completed for video ${videoId}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Session for video ${videoId} cleaned up successfully`
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Cleanup] Error during cleanup:", error);
|
||||
return NextResponse.json({
|
||||
error: "Cleanup failed",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
// Delegate to POST handler
|
||||
return POST(request, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session status and statistics
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get session information
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
const heartbeat = hlsSessionManager.getHeartbeat(videoId);
|
||||
const sessionStats = hlsSessionManager.getSessionStats(videoId);
|
||||
const isActive = hlsSessionManager.isSessionActive(videoId);
|
||||
|
||||
// Validate session health
|
||||
const healthCheck = await hlsSessionManager.validateSessionHealth(videoId);
|
||||
|
||||
const response = {
|
||||
videoId,
|
||||
session: session ? {
|
||||
videoId: session.videoId,
|
||||
videoPath: session.videoPath,
|
||||
status: session.status,
|
||||
segmentCount: session.segmentCount,
|
||||
totalDuration: session.totalDuration,
|
||||
referenceCount: session.referenceCount,
|
||||
createdAt: session.createdAt,
|
||||
lastAccessed: session.lastAccessed,
|
||||
tempDir: session.tempDir,
|
||||
playlistPath: session.playlistPath,
|
||||
} : null,
|
||||
heartbeat,
|
||||
sessionStats,
|
||||
isActive,
|
||||
healthCheck,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Cleanup] Error getting session status:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to get session status",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for a video file - .m3u8 extension handler
|
||||
* Enhanced to handle .ts files properly as single-segment streams
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as {
|
||||
path: string,
|
||||
codec_info: string,
|
||||
duration: number,
|
||||
title: string
|
||||
} | undefined;
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Video not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const videoPath = video.path;
|
||||
|
||||
if (!fs.existsSync(videoPath)) {
|
||||
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if this is a .ts file (enhanced HLS handling)
|
||||
const fileExtension = path.extname(videoPath).toLowerCase();
|
||||
|
||||
if (fileExtension === '.ts') {
|
||||
// Check if .ts file needs re-segmentation
|
||||
const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath);
|
||||
|
||||
if (needsSegmentation) {
|
||||
return generateSegmentedTSPlaylist(video, videoId, videoPath, request);
|
||||
} else {
|
||||
// Use existing virtual segmentation for proper .ts files
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
} else {
|
||||
// Other formats use generic playlist generation
|
||||
return generateGenericPlaylist(video, videoId, request);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error generating HLS playlist:", error);
|
||||
return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for merged .ts files that need FFmpeg re-segmentation
|
||||
*/
|
||||
async function generateSegmentedTSPlaylist(video: any, videoId: number, videoPath: string, request: NextRequest): Promise<Response> {
|
||||
try {
|
||||
console.log(`[HLS-Playlist] Creating segmented playlist for merged .ts file: ${videoPath}`);
|
||||
|
||||
// Get or create segmentation session
|
||||
const session = await hlsSessionManager.getOrCreateSession(videoId, videoPath);
|
||||
|
||||
if (session.status === 'processing') {
|
||||
// Return a simple response indicating processing
|
||||
return new Response('Processing...', {
|
||||
status: 202,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Retry-After': '5',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (session.status === 'error') {
|
||||
console.error(`[HLS-Playlist] Segmentation failed for video ${videoId}: ${session.error}`);
|
||||
// Fall back to virtual segmentation
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
|
||||
if (session.status === 'ready') {
|
||||
// Serve the generated playlist
|
||||
if (fs.existsSync(session.playlistPath)) {
|
||||
const playlistContent = fs.readFileSync(session.playlistPath, 'utf8');
|
||||
|
||||
// Update heartbeat for session management
|
||||
hlsSessionManager.updateHeartbeat(videoId);
|
||||
|
||||
console.log(`[HLS-Playlist] Serving generated playlist for video ${videoId} (${session.segmentCount} segments)`);
|
||||
|
||||
return new Response(playlistContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=30', // Short cache for generated playlists
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error(`[HLS-Playlist] Playlist file not found: ${session.playlistPath}`);
|
||||
// Fall back to virtual segmentation
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback case
|
||||
console.warn(`[HLS-Playlist] Unexpected session status ${session.status}, falling back to virtual segmentation`);
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[HLS-Playlist] Error creating segmented playlist for video ${videoId}:`, error);
|
||||
// Fall back to virtual segmentation on any error
|
||||
return generateTSFilePlaylist(video, videoId, request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for .ts files (virtual segmentation approach)
|
||||
*/
|
||||
function generateTSFilePlaylist(video: any, videoId: number, request: NextRequest): Response {
|
||||
// Parse codec info to get duration
|
||||
let duration = 0;
|
||||
try {
|
||||
const codecInfo = JSON.parse(video.codec_info || '{}');
|
||||
duration = codecInfo.duration || 0;
|
||||
} catch {
|
||||
// Fallback: use file size estimation
|
||||
const stat = fs.statSync(video.path);
|
||||
duration = Math.max(60, Math.floor(stat.size / (1024 * 1024)) * 30); // Rough estimate
|
||||
}
|
||||
|
||||
// Calculate virtual segments (matching segment route implementation)
|
||||
const stat = fs.statSync(video.path);
|
||||
const fileSize = stat.size;
|
||||
const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (same as segment route)
|
||||
const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE);
|
||||
const segmentDuration = duration / totalSegments; // Distribute duration across segments
|
||||
|
||||
// Generate absolute segment URL with proper video ID
|
||||
const host = request.headers.get('host') || request.nextUrl.host;
|
||||
const protocol = request.nextUrl.protocol;
|
||||
|
||||
console.log(`[HLS-Playlist] Virtual TS segmentation: ${totalSegments} segments, ${segmentDuration.toFixed(2)}s each`);
|
||||
|
||||
// Create multi-segment playlist for virtual .ts segmentation
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
`#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}`,
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
...Array.from({ length: totalSegments }, (_, i) => [
|
||||
`#EXTINF:${segmentDuration.toFixed(3)},`,
|
||||
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
|
||||
]).flat(),
|
||||
'#EXT-X-ENDLIST'
|
||||
].join('\n');
|
||||
|
||||
return new Response(playlist, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for other formats (multi-segment approach)
|
||||
*/
|
||||
function generateGenericPlaylist(video: any, videoId: number, request: NextRequest): Response {
|
||||
// Parse codec info to get duration
|
||||
let duration = 0;
|
||||
try {
|
||||
const codecInfo = JSON.parse(video.codec_info || '{}');
|
||||
duration = codecInfo.duration || 0;
|
||||
} catch {
|
||||
// Fallback: estimate duration from file size (rough approximation)
|
||||
const stat = fs.statSync(video.path);
|
||||
// Assume ~1MB per minute for standard video (very rough)
|
||||
duration = Math.floor(stat.size / (1024 * 1024)) * 60;
|
||||
}
|
||||
|
||||
// If we still don't have duration, use a default
|
||||
if (duration <= 0) {
|
||||
duration = 3600; // 1 hour default
|
||||
}
|
||||
|
||||
// Generate HLS playlist
|
||||
// For now, create a simple playlist with 10-second segments
|
||||
const segmentDuration = 10;
|
||||
const numSegments = Math.ceil(duration / segmentDuration);
|
||||
|
||||
// Generate absolute URLs with proper video ID
|
||||
const host = request.headers.get('host') || request.nextUrl.host;
|
||||
const protocol = request.nextUrl.protocol;
|
||||
|
||||
// Create playlist content with absolute segment URLs
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:10',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
...Array.from({ length: numSegments }, (_, i) => [
|
||||
`#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`,
|
||||
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
|
||||
]).flat(),
|
||||
'#EXT-X-ENDLIST'
|
||||
].join('\n');
|
||||
|
||||
return new Response(playlist, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Generate HLS playlist for a video file
|
||||
* This creates a simple single-bitrate playlist for direct file streaming
|
||||
* For multi-bitrate streaming, this would need to be enhanced with FFmpeg
|
||||
*
|
||||
* Supports both /playlist and /playlist.m3u8 URL patterns for compatibility
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as {
|
||||
path: string,
|
||||
codec_info: string,
|
||||
duration: number,
|
||||
title: string
|
||||
} | undefined;
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: "Video not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const videoPath = video.path;
|
||||
|
||||
if (!fs.existsSync(videoPath)) {
|
||||
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse codec info to get duration
|
||||
let duration = 0;
|
||||
try {
|
||||
const codecInfo = JSON.parse(video.codec_info || '{}');
|
||||
duration = codecInfo.duration || 0;
|
||||
} catch {
|
||||
// Fallback: estimate duration from file size (rough approximation)
|
||||
const stat = fs.statSync(videoPath);
|
||||
// Assume ~1MB per minute for standard video (very rough)
|
||||
duration = Math.floor(stat.size / (1024 * 1024)) * 60;
|
||||
}
|
||||
|
||||
// If we still don't have duration, use a default
|
||||
if (duration <= 0) {
|
||||
duration = 3600; // 1 hour default
|
||||
}
|
||||
|
||||
// Generate HLS playlist
|
||||
// For now, create a simple playlist with 10-second segments
|
||||
const segmentDuration = 10;
|
||||
const numSegments = Math.ceil(duration / segmentDuration);
|
||||
|
||||
// Generate absolute URLs with proper video ID
|
||||
const host = request.headers.get('host') || request.nextUrl.host;
|
||||
const protocol = request.nextUrl.protocol;
|
||||
|
||||
// Create playlist content with absolute segment URLs
|
||||
const playlist = [
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:10',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
...Array.from({ length: numSegments }, (_, i) => [
|
||||
`#EXTINF:${Math.min(segmentDuration, duration - i * segmentDuration).toFixed(3)},`,
|
||||
`${protocol}//${host}/api/stream/hls/${videoId}/segment/${i}.ts`
|
||||
]).flat(),
|
||||
'#EXT-X-ENDLIST'
|
||||
].join('\n');
|
||||
|
||||
return new Response(playlist, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating HLS playlist:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,227 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/db";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Serve a segment from FFmpeg-generated segmentation
|
||||
*/
|
||||
async function serveGeneratedSegment(
|
||||
session: any,
|
||||
segmentIndex: number,
|
||||
request: NextRequest
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Update heartbeat
|
||||
hlsSessionManager.updateHeartbeat(session.videoId, segmentIndex);
|
||||
|
||||
// Get segment file path
|
||||
const segmentPath = await tsSegmentationService.getSegmentPath(session.videoId, segmentIndex);
|
||||
|
||||
if (!segmentPath || !fs.existsSync(segmentPath)) {
|
||||
console.log(`[HLS-Segment] Generated segment ${segmentIndex} not found for video ${session.videoId}`);
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(segmentPath);
|
||||
const segmentSize = stat.size;
|
||||
|
||||
console.log(`[HLS-Segment] Serving generated segment ${segmentIndex} for video ${session.videoId} (${segmentSize} bytes)`);
|
||||
|
||||
// Create read stream for the segment
|
||||
const stream = fs.createReadStream(segmentPath);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Content-Length': segmentSize.toString(),
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=3600', // Cache generated segments for 1 hour
|
||||
'ETag': `"generated-${session.videoId}-${segmentIndex}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[HLS-Segment] Error serving generated segment ${segmentIndex}:`, error);
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a virtual segment from a large .ts file using byte ranges
|
||||
* This mimics how streaming sites serve small .ts segments
|
||||
*/
|
||||
async function serveTSSegment(videoPath: string, segmentIndex: number, request: NextRequest): Promise<Response> {
|
||||
const stat = fs.statSync(videoPath);
|
||||
const fileSize = stat.size;
|
||||
|
||||
// Configuration for virtual segmentation
|
||||
const SEGMENT_SIZE = 2 * 1024 * 1024; // 2MB per segment (like streaming sites)
|
||||
const totalSegments = Math.ceil(fileSize / SEGMENT_SIZE);
|
||||
|
||||
console.log(`[TS-Segment] Virtual segmentation: segment ${segmentIndex}/${totalSegments - 1}, file size: ${fileSize}`);
|
||||
|
||||
// Check if segment is valid
|
||||
if (segmentIndex >= totalSegments) {
|
||||
console.log(`[TS-Segment] Segment ${segmentIndex} out of range (max: ${totalSegments - 1})`);
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Calculate byte range for this segment
|
||||
const start = segmentIndex * SEGMENT_SIZE;
|
||||
const end = Math.min(start + SEGMENT_SIZE - 1, fileSize - 1);
|
||||
const segmentLength = end - start + 1;
|
||||
|
||||
console.log(`[TS-Segment] Serving bytes ${start}-${end} (${segmentLength} bytes)`);
|
||||
|
||||
// Check for client disconnect to cancel streaming
|
||||
const controller = new AbortController();
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
console.log(`[TS-Segment] Client disconnected, cancelling segment ${segmentIndex}`);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
try {
|
||||
// Create a read stream for the specific byte range
|
||||
const stream = fs.createReadStream(videoPath, {
|
||||
start,
|
||||
end
|
||||
});
|
||||
|
||||
// Handle stream errors
|
||||
stream.on('error', (error) => {
|
||||
console.error(`[TS-Segment] Stream error for segment ${segmentIndex}:`, error);
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'video/mp2t',
|
||||
'Content-Length': segmentLength.toString(),
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Cache-Control': 'public, max-age=31536000', // Cache segments aggressively
|
||||
'ETag': `"${segmentIndex}-${start}-${end}"`, // Unique identifier for caching
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ABORT_ERR') {
|
||||
console.log(`[TS-Segment] Segment ${segmentIndex} cancelled by client`);
|
||||
return new NextResponse(null, { status: 499 }); // Client closed connection
|
||||
}
|
||||
|
||||
console.error(`[TS-Segment] Error serving segment ${segmentIndex}:`, error);
|
||||
return new NextResponse(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve HLS segments for video streaming
|
||||
* For .ts files: serve virtual segments using byte ranges (like streaming sites)
|
||||
* For other formats: return error and suggest alternatives
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; segment: string }> }
|
||||
) {
|
||||
const { id, segment } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
console.log(`[HLS-Segment] Request for video ${id}, segment ${segment}`);
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
const segmentIndex = parseInt(segment.replace('.ts', ''));
|
||||
|
||||
console.log(`[HLS-Segment] Parsed videoId: ${videoId}, segmentIndex: ${segmentIndex} (original: ${segment})`);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
console.log(`[HLS-Segment] Invalid video ID: ${id}`);
|
||||
return NextResponse.json({ error: "Invalid video ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isNaN(segmentIndex) || segmentIndex < 0) {
|
||||
console.log(`[HLS-Segment] Invalid segment index: ${segment}`);
|
||||
return NextResponse.json({ error: "Invalid segment index" }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = db.prepare("SELECT * FROM media WHERE id = ? AND type = 'video'").get(videoId) as {
|
||||
path: string,
|
||||
codec_info: string
|
||||
} | undefined;
|
||||
|
||||
if (!video) {
|
||||
console.log(`[HLS-Segment] Video not found in database: ${videoId}`);
|
||||
return NextResponse.json({ error: "Video not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const videoPath = video.path;
|
||||
console.log(`[HLS-Segment] Video path: ${videoPath}`);
|
||||
|
||||
if (!fs.existsSync(videoPath)) {
|
||||
console.log(`[HLS-Segment] Video file not found on disk: ${videoPath}`);
|
||||
return NextResponse.json({ error: "Video file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if the file is already a .ts file (MPEG-TS)
|
||||
const fileExtension = path.extname(videoPath).toLowerCase();
|
||||
console.log(`[HLS-Segment] File extension: ${fileExtension}`);
|
||||
|
||||
if (fileExtension === '.ts') {
|
||||
// Check if we have a segmentation session for this video
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (session && session.status === 'ready') {
|
||||
// Serve from generated segments
|
||||
return serveGeneratedSegment(session, segmentIndex, request);
|
||||
} else {
|
||||
// Fall back to virtual segmentation for regular .ts files
|
||||
return serveTSSegment(videoPath, segmentIndex, request);
|
||||
}
|
||||
} else {
|
||||
// For non-.ts files, we need to either:
|
||||
// 1. Convert to .ts segments on-the-fly (resource intensive)
|
||||
// 2. Return an error indicating HLS is not supported for this format
|
||||
// 3. Fall back to direct streaming
|
||||
|
||||
console.log(`[HLS] Non-TS file requested for HLS streaming: ${videoPath}`);
|
||||
|
||||
// Return a comprehensive error with alternatives
|
||||
return NextResponse.json({
|
||||
error: "HLS streaming not implemented for this format",
|
||||
format: fileExtension,
|
||||
message: "This video format requires container conversion for HLS streaming",
|
||||
alternatives: {
|
||||
direct_stream: `/api/stream/direct/${videoId}`,
|
||||
external_player: `/api/external-stream/${videoId}`,
|
||||
container_conversion: "Consider converting to .mp4 format for browser compatibility"
|
||||
},
|
||||
suggested_action: "Use direct streaming or external player for this format"
|
||||
}, { status: 422 }); // Unprocessable Entity
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Segment] Error serving HLS segment:", error);
|
||||
return NextResponse.json({ error: "Internal server error", details: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { hlsSessionManager } from "@/lib/hls-session-manager";
|
||||
import { tsSegmentationService } from "@/lib/ts-segmentation-service";
|
||||
|
||||
/**
|
||||
* Global HLS segmentation status and management endpoint
|
||||
* GET /api/stream/hls/status - Get overall statistics
|
||||
* POST /api/stream/hls/status - Trigger cleanup of expired sessions
|
||||
*/
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Get overall statistics
|
||||
const overallStats = hlsSessionManager.getOverallStats();
|
||||
const segmentationStats = tsSegmentationService.getStats();
|
||||
const debugInfo = hlsSessionManager.getDebugInfo();
|
||||
|
||||
// Get active and expired sessions
|
||||
const activeSessions = hlsSessionManager.getActiveSessions();
|
||||
const expiredSessions = hlsSessionManager.getExpiredSessions();
|
||||
|
||||
const response = {
|
||||
timestamp: new Date().toISOString(),
|
||||
overallStats,
|
||||
segmentationStats,
|
||||
activeSessions: activeSessions.length,
|
||||
expiredSessions: expiredSessions.length,
|
||||
activeSessionIds: activeSessions,
|
||||
expiredSessionIds: expiredSessions,
|
||||
debug: debugInfo,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Status] Error getting status:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to get HLS status",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger manual cleanup of expired sessions
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const maxIdleTime = body.maxIdleTime || 30 * 60 * 1000; // Default 30 minutes
|
||||
|
||||
console.log(`[HLS-Status] Triggering manual cleanup with maxIdleTime: ${maxIdleTime}ms`);
|
||||
|
||||
// Perform cleanup
|
||||
const cleanedUpCount = await hlsSessionManager.cleanupExpiredSessions(maxIdleTime);
|
||||
|
||||
// Get updated statistics
|
||||
const overallStats = hlsSessionManager.getOverallStats();
|
||||
const segmentationStats = tsSegmentationService.getStats();
|
||||
|
||||
const response = {
|
||||
timestamp: new Date().toISOString(),
|
||||
cleanupPerformed: true,
|
||||
cleanedUpCount,
|
||||
maxIdleTime,
|
||||
overallStats,
|
||||
segmentationStats,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[HLS-Status] Error during manual cleanup:", error);
|
||||
return NextResponse.json({
|
||||
error: "Manual cleanup failed",
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { transcodeOrchestrator } from '@/lib/transcode/orchestrator';
|
||||
import { listSegmentFiles } from '@/lib/transcode/playlist';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
|
||||
const { jobId } = await params;
|
||||
const job = transcodeOrchestrator.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const send = () => {
|
||||
const currentJob = transcodeOrchestrator.get(jobId);
|
||||
if (!currentJob) {
|
||||
controller.enqueue(encoder.encode('event: killed\ndata: {}\n\n'));
|
||||
controller.close();
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(`event: status\ndata: ${JSON.stringify({
|
||||
status: currentJob.status,
|
||||
segmentCount: listSegmentFiles(currentJob.outDir).length,
|
||||
position: currentJob.lastClientPosition,
|
||||
error: currentJob.error,
|
||||
})}\n\n`));
|
||||
};
|
||||
|
||||
const interval = setInterval(send, 2000);
|
||||
send();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { transcodeOrchestrator } from '@/lib/transcode/orchestrator';
|
||||
import { buildPlaylist } from '@/lib/transcode/playlist';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
|
||||
const { jobId } = await params;
|
||||
const job = transcodeOrchestrator.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(buildPlaylist(job), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'X-Content-Duration': job.knownDuration.toString(),
|
||||
'X-Transcode-Status': job.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { transcodeOrchestrator } from '@/lib/transcode/orchestrator';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ jobId: string }> }) {
|
||||
const { jobId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const position = Number(body.position || 0);
|
||||
const isPaused = Boolean(body.isPaused);
|
||||
|
||||
if (!Number.isFinite(position) || position < 0) {
|
||||
return NextResponse.json({ error: 'Invalid position' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ok = transcodeOrchestrator.ping(jobId, position, isPaused);
|
||||
if (!ok) {
|
||||
return NextResponse.json({ error: 'Transcode job not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
@ -0,0 +1,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: {
|
||||
direct_url: `/api/stream/direct/${video.id}`,
|
||||
hls_url: format.type === 'hls' ? `/api/stream/hls/${video.id}/playlist.m3u8` : null,
|
||||
hls_url: format.type === 'hls' ? format.url : null,
|
||||
fallback_url: `/api/stream/${video.id}`,
|
||||
transcoding_url: `/api/stream/${video.id}/transcode`,
|
||||
transcoding_url: `/api/transcode/start`,
|
||||
supports_range_requests: format.supportLevel === 'native',
|
||||
supports_adaptive_bitrate: format.type === 'hls'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,215 +0,0 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getDatabase } from '@/db';
|
||||
import { convertTSToMP4, analyzeTSFile } from '@/lib/ts-converter';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* API endpoint for .ts file container conversion
|
||||
* Converts .ts files to .mp4 containers without re-encoding for better browser compatibility
|
||||
*/
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get video information
|
||||
const video = db.prepare(`
|
||||
SELECT m.*, l.path as library_path
|
||||
FROM media m
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
WHERE m.id = ? AND m.type = 'video'
|
||||
`).get(videoId) as {
|
||||
id: number;
|
||||
path: string;
|
||||
title: string;
|
||||
codec_info?: string;
|
||||
} | undefined;
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const inputPath = video.path;
|
||||
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
return NextResponse.json({ error: 'Video file not found on disk' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if it's a .ts file
|
||||
const extension = path.extname(inputPath).toLowerCase();
|
||||
if (extension !== '.ts') {
|
||||
return NextResponse.json({
|
||||
error: 'Not a .ts file',
|
||||
message: 'This endpoint only converts .ts (MPEG Transport Stream) files',
|
||||
current_format: extension
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse request options
|
||||
const requestBody = await request.json().catch(() => ({}));
|
||||
const options = {
|
||||
fastStart: requestBody.fastStart !== false, // Default to true
|
||||
deleteOriginal: requestBody.deleteOriginal === true, // Default to false
|
||||
};
|
||||
|
||||
// Generate output path in the same directory
|
||||
const outputPath = inputPath.replace(/\.ts$/i, '_web.mp4');
|
||||
|
||||
// Check if converted file already exists
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'File already converted',
|
||||
original_path: inputPath,
|
||||
converted_path: outputPath,
|
||||
converted_size: fs.statSync(outputPath).size,
|
||||
conversion_time: 0
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[TSConvert] Starting conversion for video ${videoId}: ${inputPath}`);
|
||||
|
||||
// Analyze the file first
|
||||
const analysis = await analyzeTSFile(inputPath);
|
||||
|
||||
if (!analysis.isConvertible) {
|
||||
return NextResponse.json({
|
||||
error: 'File not suitable for conversion',
|
||||
reason: analysis.reason,
|
||||
video_codec: analysis.videoCodec,
|
||||
audio_codec: analysis.audioCodec
|
||||
}, { status: 422 });
|
||||
}
|
||||
|
||||
// Perform the conversion
|
||||
const result = await convertTSToMP4(inputPath, {
|
||||
outputPath,
|
||||
...options
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Update database to reference the new file
|
||||
// Note: We keep the original record but could add a reference to the converted file
|
||||
console.log(`[TSConvert] Successfully converted video ${videoId} in ${result.duration.toFixed(2)}s`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'File converted successfully',
|
||||
original_path: inputPath,
|
||||
converted_path: result.outputPath,
|
||||
conversion_time: result.duration,
|
||||
original_size: result.originalSize,
|
||||
converted_size: result.convertedSize,
|
||||
size_change_percent: result.convertedSize ?
|
||||
((result.convertedSize - result.originalSize) / result.originalSize * 100).toFixed(1) : 0,
|
||||
video_codec: analysis.videoCodec,
|
||||
audio_codec: analysis.audioCodec,
|
||||
web_compatible: true,
|
||||
recommended_action: 'Use the converted file for better browser compatibility'
|
||||
});
|
||||
} else {
|
||||
console.error(`[TSConvert] Failed to convert video ${videoId}:`, result.error);
|
||||
|
||||
return NextResponse.json({
|
||||
error: 'Conversion failed',
|
||||
details: result.error,
|
||||
conversion_time: result.duration
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[TSConvert] API error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Internal server error',
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversion status and file information
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const db = getDatabase();
|
||||
|
||||
try {
|
||||
const videoId = parseInt(id);
|
||||
|
||||
if (isNaN(videoId)) {
|
||||
return NextResponse.json({ error: 'Invalid video ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const video = db.prepare(`
|
||||
SELECT m.*, l.path as library_path
|
||||
FROM media m
|
||||
JOIN libraries l ON m.library_id = l.id
|
||||
WHERE m.id = ? AND m.type = 'video'
|
||||
`).get(videoId) as {
|
||||
path: string;
|
||||
title: string;
|
||||
} | undefined;
|
||||
|
||||
if (!video) {
|
||||
return NextResponse.json({ error: 'Video not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const inputPath = video.path;
|
||||
const extension = path.extname(inputPath).toLowerCase();
|
||||
|
||||
if (extension !== '.ts') {
|
||||
return NextResponse.json({
|
||||
convertible: false,
|
||||
reason: 'Not a .ts file',
|
||||
current_format: extension
|
||||
});
|
||||
}
|
||||
|
||||
// Check if converted file exists
|
||||
const convertedPath = inputPath.replace(/\.ts$/i, '_web.mp4');
|
||||
const hasConverted = fs.existsSync(convertedPath);
|
||||
|
||||
// Analyze the original file
|
||||
const analysis = await analyzeTSFile(inputPath);
|
||||
|
||||
return NextResponse.json({
|
||||
convertible: analysis.isConvertible,
|
||||
original_path: inputPath,
|
||||
converted_path: convertedPath,
|
||||
has_converted_file: hasConverted,
|
||||
converted_file_size: hasConverted ? fs.statSync(convertedPath).size : null,
|
||||
analysis: {
|
||||
video_codec: analysis.videoCodec,
|
||||
audio_codec: analysis.audioCodec,
|
||||
duration: analysis.duration,
|
||||
reason: analysis.reason
|
||||
},
|
||||
recommendations: {
|
||||
use_hls: 'Stream via HLS for best compatibility',
|
||||
convert_container: analysis.isConvertible ? 'Convert to MP4 container for direct browser playback' : null,
|
||||
external_player: 'Use VLC or similar player for guaranteed playback'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[TSConvert] Status check error:', error);
|
||||
return NextResponse.json({
|
||||
error: 'Internal server error',
|
||||
details: error.message
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -206,3 +206,27 @@
|
|||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* Mobile sidebar drawer animations */
|
||||
@keyframes slideInFromLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slideOutToLeft {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-100%); }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideInFromLeft 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-slide-out {
|
||||
animation: slideOutToLeft 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* Safe area bottom padding for devices with home indicator */
|
||||
.safe-area-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ import type { Metadata } from "next";
|
|||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import MobileTopBar from "@/components/mobile-top-bar";
|
||||
import MobileBottomTabs from "@/components/mobile-bottom-tabs";
|
||||
import { SidebarProvider } from "@/contexts/sidebar-context";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
|
|
@ -23,12 +26,16 @@ export default function RootLayout({
|
|||
<body
|
||||
className={`${inter.variable} antialiased bg-background text-foreground`}
|
||||
>
|
||||
<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>
|
||||
<SidebarProvider>
|
||||
<div className="flex h-screen bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<MobileTopBar />
|
||||
<Sidebar />
|
||||
<main className="flex-1 bg-background/50 backdrop-blur-sm overflow-y-auto pt-14 lg:pt-0 pb-16 lg:pb-0">
|
||||
{children}
|
||||
</main>
|
||||
<MobileBottomTabs />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ interface ArtPlayerWrapperProps {
|
|||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
autoplay?: boolean;
|
||||
formatOverride?: VideoFormat;
|
||||
}
|
||||
|
||||
export default function ArtPlayerWrapper({
|
||||
|
|
@ -41,11 +42,14 @@ export default function ArtPlayerWrapper({
|
|||
avgRating = 0,
|
||||
showBookmarks = false,
|
||||
showRatings = false,
|
||||
autoplay = true
|
||||
autoplay = true,
|
||||
formatOverride
|
||||
}: ArtPlayerWrapperProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const playerRef = useRef<Artplayer | null>(null);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -59,6 +63,102 @@ export default function ArtPlayerWrapper({
|
|||
const [localBookmarkCount, setLocalBookmarkCount] = useState(bookmarkCount);
|
||||
const [localAvgRating, setLocalAvgRating] = useState(avgRating);
|
||||
const hlsErrorHandlerRef = useRef<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
|
||||
useEffect(() => {
|
||||
|
|
@ -125,10 +225,18 @@ export default function ArtPlayerWrapper({
|
|||
setLocalAvgRating(avgRating);
|
||||
}, [isBookmarked, bookmarkCount, avgRating]);
|
||||
|
||||
// Keep formatOverrideRef in sync (no re-init needed, handled separately)
|
||||
useEffect(() => {
|
||||
formatOverrideRef.current = formatOverride;
|
||||
}, [formatOverride]);
|
||||
|
||||
// Initialize ArtPlayer
|
||||
useEffect(() => {
|
||||
if (!useArtPlayer || !isOpen || !containerRef.current) return;
|
||||
|
||||
// Prevent duplicate instances/audio when React dev mode re-runs effects.
|
||||
releasePlayer();
|
||||
|
||||
// Inject custom styles to remove shadows
|
||||
const styleId = 'artplayer-styles';
|
||||
if (!document.getElementById(styleId)) {
|
||||
|
|
@ -142,7 +250,10 @@ export default function ArtPlayerWrapper({
|
|||
setError(null);
|
||||
|
||||
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);
|
||||
|
||||
// HLS.js plugin for ArtPlayer
|
||||
|
|
@ -184,7 +295,7 @@ export default function ArtPlayerWrapper({
|
|||
theme: '#3b82f6', // Blue theme
|
||||
|
||||
// Quality control (for HLS)
|
||||
quality: detectedFormat.qualities || [],
|
||||
quality: qualityOptions,
|
||||
|
||||
// Subtitle support
|
||||
subtitle: {
|
||||
|
|
@ -202,7 +313,7 @@ export default function ArtPlayerWrapper({
|
|||
{
|
||||
html: 'Quality',
|
||||
icon: '<span class="artplayer-icon-settings-quality">⚙️</span>',
|
||||
selector: detectedFormat.qualities || [],
|
||||
selector: settingsQualityOptions,
|
||||
onSelect: function(item: any) {
|
||||
console.log('Quality selected:', item);
|
||||
if (hlsInstance && item.level !== undefined) {
|
||||
|
|
@ -218,30 +329,45 @@ export default function ArtPlayerWrapper({
|
|||
// Custom initialization for HLS
|
||||
customType: {
|
||||
m3u8: function(video: HTMLVideoElement, url: string) {
|
||||
mediaElementsRef.current.add(video);
|
||||
if (Hls.isSupported()) {
|
||||
// Reset shutdown flag — we're starting fresh
|
||||
hlsShuttingDownRef.current = false;
|
||||
|
||||
hlsInstance = new Hls({
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false, // Disable for better buffering
|
||||
backBufferLength: 90,
|
||||
maxBufferLength: 120, // Increase buffer length
|
||||
maxBufferSize: 100 * 1000 * 1000, // 100MB buffer
|
||||
maxBufferHole: 0.5, // Allow small holes
|
||||
startLevel: -1, // Auto-select optimal quality
|
||||
// Route HLS internal errors through console.warn so they don't
|
||||
// trigger the Next.js dev overlay (which intercepts console.error).
|
||||
debug: process.env.NODE_ENV === 'development' ? {
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
log: () => {},
|
||||
info: () => {},
|
||||
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,
|
||||
autoStartLoad: true,
|
||||
maxFragLookUpTolerance: 0.25,
|
||||
liveSyncDurationCount: 3,
|
||||
liveMaxLatencyDurationCount: 10,
|
||||
// Aggressive preloading for better buffering
|
||||
manifestLoadingTimeOut: 10000,
|
||||
manifestLoadingMaxRetry: 2,
|
||||
// Fewer retries so recovery loops die quickly.
|
||||
manifestLoadingMaxRetry: 1,
|
||||
levelLoadingTimeOut: 10000,
|
||||
levelLoadingMaxRetry: 2,
|
||||
fragLoadingTimeOut: 20000, // Longer timeout for large segments
|
||||
fragLoadingMaxRetry: 3,
|
||||
// Bandwidth estimation settings
|
||||
abrEwmaDefaultEstimate: 500000, // 500kbps initial estimate
|
||||
levelLoadingMaxRetry: 1,
|
||||
fragLoadingTimeOut: 20000,
|
||||
fragLoadingMaxRetry: 1,
|
||||
abrEwmaDefaultEstimate: 500000,
|
||||
abrBandWidthFactor: 0.95,
|
||||
abrBandWidthUpFactor: 0.7,
|
||||
});
|
||||
|
|
@ -307,7 +433,10 @@ export default function ArtPlayerWrapper({
|
|||
});
|
||||
|
||||
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,
|
||||
details: data.details,
|
||||
fatal: data.fatal,
|
||||
|
|
@ -329,7 +458,7 @@ export default function ArtPlayerWrapper({
|
|||
hlsInstance?.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
console.error('HLS fatal error, cannot recover');
|
||||
console.warn('HLS fatal error, cannot recover');
|
||||
setError('HLS streaming failed. Falling back to direct playback.');
|
||||
// This will trigger fallback in the parent component
|
||||
break;
|
||||
|
|
@ -360,6 +489,10 @@ export default function ArtPlayerWrapper({
|
|||
}
|
||||
});
|
||||
|
||||
if (player.video) {
|
||||
mediaElementsRef.current.add(player.video as HTMLVideoElement);
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
player.on('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', () => {
|
||||
setDuration(player.duration);
|
||||
});
|
||||
|
|
@ -431,28 +591,7 @@ export default function ArtPlayerWrapper({
|
|||
|
||||
return () => {
|
||||
console.log('[ArtPlayer] Starting cleanup...');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
releasePlayer();
|
||||
console.log('[ArtPlayer] Cleanup completed');
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -460,7 +599,30 @@ export default function ArtPlayerWrapper({
|
|||
setError(`Failed to initialize player: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [useArtPlayer, isOpen, video, onProgress, volume, autoplay, format?.supportLevel]);
|
||||
// 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
|
||||
const handleBookmarkToggle = useCallback(async () => {
|
||||
|
|
@ -551,7 +713,7 @@ export default function ArtPlayerWrapper({
|
|||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
handleClose();
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
|
|
@ -584,32 +746,13 @@ export default function ArtPlayerWrapper({
|
|||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isPlaying]);
|
||||
}, [isOpen, isPlaying]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('[ArtPlayer] Unmount cleanup...');
|
||||
|
||||
// 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;
|
||||
}
|
||||
releasePlayer();
|
||||
|
||||
// Clean up custom styles
|
||||
const styleElement = document.getElementById('artplayer-styles');
|
||||
|
|
@ -623,38 +766,9 @@ export default function ArtPlayerWrapper({
|
|||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
console.log('[ArtPlayer] Modal closed, stopping HLS...');
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
releasePlayer();
|
||||
}
|
||||
}, [isOpen, format?.type, video.id]);
|
||||
}, [isOpen]);
|
||||
|
||||
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">
|
||||
{/* Close 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"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -822,4 +936,10 @@ 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>;
|
||||
showBookmarks?: boolean;
|
||||
showRatings?: boolean;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
interface PlayerInfo {
|
||||
|
|
@ -149,7 +150,8 @@ export default function LocalPlayerLauncher({
|
|||
onUnbookmark,
|
||||
onRate,
|
||||
showBookmarks = true,
|
||||
showRatings = true
|
||||
showRatings = true,
|
||||
notice
|
||||
}: LocalPlayerLauncherProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [detectedPlayers, setDetectedPlayers] = useState<string[]>([]);
|
||||
|
|
@ -549,6 +551,12 @@ export default function LocalPlayerLauncher({
|
|||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{notice && (
|
||||
<Alert>
|
||||
<AlertDescription>{notice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Video Info with Bookmark & Rating */}
|
||||
<div className="bg-gradient-to-br from-muted/50 to-muted rounded-lg p-4 border border-border">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Home,
|
||||
Settings,
|
||||
Video,
|
||||
|
|
@ -24,13 +25,14 @@ import Link from "next/link";
|
|||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Suspense } from "react";
|
||||
import { useSidebar } from "@/contexts/sidebar-context";
|
||||
|
||||
const SidebarContent = () => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const { isCollapsed, isMobileOpen, toggleCollapse, closeMobile, isMobile } = useSidebar();
|
||||
const [libraries, setLibraries] = useState<{ id: number; path: string }[]>([]);
|
||||
const [clusters, setClusters] = useState<Cluster[]>([]);
|
||||
const [showClusters, setShowClusters] = useState(true);
|
||||
const [showLibraries, setShowLibraries] = useState(true);
|
||||
const [showClusters, setShowClusters] = useState(!isMobile);
|
||||
const [showLibraries, setShowLibraries] = useState(!isMobile);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
|
|
@ -75,9 +77,13 @@ const SidebarContent = () => {
|
|||
return icons[iconName] || Folder;
|
||||
};
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
// When switching to mobile, collapse sections by default
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
setShowClusters(false);
|
||||
setShowLibraries(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "Home", icon: Home },
|
||||
|
|
@ -88,16 +94,16 @@ const SidebarContent = () => {
|
|||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col bg-card border-r border-border transition-all duration-300 ease-in-out shadow-lg h-full overflow-hidden",
|
||||
isCollapsed ? "w-16" : "w-64"
|
||||
)}
|
||||
>
|
||||
const sidebarInner = (
|
||||
<div className={cn(
|
||||
"flex flex-col bg-card h-full overflow-hidden",
|
||||
// Desktop: width transitions; Mobile: full w-72 in drawer
|
||||
!isMobile && (isCollapsed ? "w-16" : "w-64"),
|
||||
isMobile && "w-72"
|
||||
)}>
|
||||
{/* Header */}
|
||||
<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="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" />
|
||||
|
|
@ -105,14 +111,25 @@ const SidebarContent = () => {
|
|||
<h1 className="text-lg font-bold text-foreground tracking-tight">NextAV</h1>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleSidebar}
|
||||
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>
|
||||
{isMobile ? (
|
||||
<Button
|
||||
onClick={closeMobile}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Navigation */}
|
||||
|
|
@ -123,15 +140,24 @@ const SidebarContent = () => {
|
|||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
)} />
|
||||
{!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>
|
||||
</Link>
|
||||
))}
|
||||
|
|
@ -144,11 +170,11 @@ const SidebarContent = () => {
|
|||
onClick={() => setShowClusters(!showClusters)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||
isCollapsed && "justify-center"
|
||||
isCollapsed && !isMobile && "justify-center"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
Clusters
|
||||
|
|
@ -166,31 +192,23 @@ const SidebarContent = () => {
|
|||
const IconComponent = getIconComponent(cluster.icon);
|
||||
const isActive = pathname === `/clusters/${cluster.id}`;
|
||||
return (
|
||||
<Link
|
||||
href={`/clusters/${cluster.id}`}
|
||||
key={cluster.id}
|
||||
passHref
|
||||
>
|
||||
<Link href={`/clusters/${cluster.id}`} key={cluster.id} passHref>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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` }}
|
||||
>
|
||||
<IconComponent
|
||||
className="h-2.5 w-2.5"
|
||||
style={{ color: cluster.color }}
|
||||
/>
|
||||
<IconComponent className="h-2.5 w-2.5" style={{ color: cluster.color }} />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className="ml-3 truncate text-xs">
|
||||
{cluster.name}
|
||||
</span>
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<span className="ml-3 truncate text-xs">{cluster.name}</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
|
|
@ -208,11 +226,11 @@ const SidebarContent = () => {
|
|||
onClick={() => setShowLibraries(!showLibraries)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 mb-3 w-full hover:text-foreground transition-colors",
|
||||
isCollapsed && "justify-center"
|
||||
isCollapsed && !isMobile && "justify-center"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
Libraries
|
||||
|
|
@ -235,14 +253,15 @@ const SidebarContent = () => {
|
|||
<Button
|
||||
variant="ghost"
|
||||
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" &&
|
||||
searchParams.get("path") === lib.path &&
|
||||
"bg-accent text-accent-foreground border border-accent/20"
|
||||
)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground" />
|
||||
{!isCollapsed && (
|
||||
<Folder className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground flex-shrink-0" />
|
||||
{(!isCollapsed || isMobile) && (
|
||||
<span className="ml-3 truncate text-xs">
|
||||
{lib.path.split('/').pop() || lib.path}
|
||||
</span>
|
||||
|
|
@ -260,19 +279,45 @@ const SidebarContent = () => {
|
|||
<div className="p-4 border-t border-border">
|
||||
<div className={cn(
|
||||
"text-center text-xs text-muted-foreground",
|
||||
isCollapsed && "text-[10px]"
|
||||
)}
|
||||
>
|
||||
{!isCollapsed ? "NextAV v1.0" : "v1.0"}
|
||||
isCollapsed && !isMobile && "text-[10px]"
|
||||
)}>
|
||||
{(!isCollapsed || isMobile) ? "NextAV v1.0" : "v1.0"}
|
||||
</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 = () => {
|
||||
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 />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { detectVideoFormat, VideoFile } from '@/lib/video-format-detector';
|
||||
import ArtPlayerWrapper from '@/components/artplayer-wrapper';
|
||||
import LocalPlayerLauncher from '@/components/local-player-launcher';
|
||||
|
|
@ -44,6 +44,41 @@ export default function UnifiedVideoPlayer({
|
|||
const [bookmarkCheckLoading, setBookmarkCheckLoading] = useState(true);
|
||||
const [currentRating, setCurrentRating] = useState(0);
|
||||
const [ratingCheckLoading, setRatingCheckLoading] = useState(true);
|
||||
const [transcodeLoading, setTranscodeLoading] = useState(false);
|
||||
const [transcodeError, setTranscodeError] = useState<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
|
||||
useEffect(() => {
|
||||
|
|
@ -95,16 +130,79 @@ export default function UnifiedVideoPlayer({
|
|||
}
|
||||
};
|
||||
|
||||
// Detect format on mount
|
||||
// Detect/reset format whenever a video is opened. This avoids reusing a killed HLS job URL
|
||||
// after closing and reopening the same video.
|
||||
useEffect(() => {
|
||||
if (video) {
|
||||
if (video && isOpen) {
|
||||
console.log('[UnifiedVideoPlayer] Detecting format for video:', video);
|
||||
const detectedFormat = detectVideoFormat(video);
|
||||
// Read from ref to get latest jobId without needing it in deps
|
||||
const prevJobId = activeTranscodeJobIdRef.current;
|
||||
if (prevJobId) {
|
||||
releaseTranscodeJob(prevJobId);
|
||||
setActiveTranscodeJobId(null);
|
||||
}
|
||||
const detectedFormat = resetDetectedFormat();
|
||||
console.log('[UnifiedVideoPlayer] Detected format:', detectedFormat);
|
||||
setFormat(detectedFormat);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [video]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [video, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || format?.type !== 'local-player' || process.env.NEXT_PUBLIC_ENABLE_LIVE_TRANSCODE !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setTranscodeLoading(true);
|
||||
setTranscodeError(null);
|
||||
|
||||
fetch('/api/transcode/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mediaId: video.id }),
|
||||
})
|
||||
.then(async response => {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to start live transcode');
|
||||
}
|
||||
if (!cancelled) {
|
||||
setActiveTranscodeJobId(data.jobId || null);
|
||||
setFormat({
|
||||
type: 'hls',
|
||||
supportLevel: 'hls',
|
||||
url: data.playlistUrl,
|
||||
warning: `Live transcoding (${data.profile})`,
|
||||
qualities: [{ html: data.profile || 'Transcoded', url: data.playlistUrl, default: true }],
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (!cancelled) {
|
||||
console.warn('[UnifiedVideoPlayer] Live transcode unavailable, falling back to local player:', error);
|
||||
setTranscodeError(error instanceof Error ? error.message : 'Live transcode unavailable');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTranscodeLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, format?.type, video.id]);
|
||||
|
||||
// When the modal closes while a transcode job is active, kill it.
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
const jobId = activeTranscodeJobIdRef.current;
|
||||
if (!jobId) return;
|
||||
releaseTranscodeJob(jobId);
|
||||
setActiveTranscodeJobId(null);
|
||||
resetDetectedFormat();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle ArtPlayer errors with recovery
|
||||
const handleArtPlayerError = useCallback((error: string) => {
|
||||
|
|
@ -170,7 +268,7 @@ export default function UnifiedVideoPlayer({
|
|||
<LocalPlayerLauncher
|
||||
video={video}
|
||||
format={format}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
onPlayerSelect={(playerId) => {
|
||||
console.log(`Selected player: ${playerId}`);
|
||||
}}
|
||||
|
|
@ -180,6 +278,7 @@ export default function UnifiedVideoPlayer({
|
|||
onRate={handleRatingUpdate}
|
||||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
notice={transcodeError ? `Live transcode unavailable: ${transcodeError}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -190,7 +289,7 @@ export default function UnifiedVideoPlayer({
|
|||
<ArtPlayerWrapper
|
||||
video={video}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
onProgress={handleProgressUpdate}
|
||||
onBookmark={handleBookmarkToggle}
|
||||
onUnbookmark={handleUnbookmark}
|
||||
|
|
@ -203,16 +302,17 @@ export default function UnifiedVideoPlayer({
|
|||
showBookmarks={showBookmarks}
|
||||
showRatings={showRatings}
|
||||
autoplay={autoplay}
|
||||
formatOverride={format || undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading || bookmarkCheckLoading || ratingCheckLoading) {
|
||||
if (isLoading || bookmarkCheckLoading || ratingCheckLoading || transcodeLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto mb-4"></div>
|
||||
<p>Loading ArtPlayer...</p>
|
||||
<p>{transcodeLoading ? 'Preparing live transcode...' : 'Loading ArtPlayer...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_bookmarks_media_id ON bookmarks(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_stars_media_id ON stars(media_id);`);
|
||||
|
|
@ -165,6 +183,8 @@ function initializeDatabase() {
|
|||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_media_id ON media_access(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_created_at ON media_access(created_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_media_access_type_created_at ON media_access(access_type, created_at);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_media ON transcode_jobs(media_id);`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_transcode_jobs_status ON transcode_jobs(status);`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,237 +0,0 @@
|
|||
import { ChildProcess } from 'child_process';
|
||||
|
||||
interface FFmpegProcessInfo {
|
||||
process: ChildProcess;
|
||||
startTime: Date;
|
||||
seekTime: number;
|
||||
videoId: string;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced FFmpeg Process Registry for seek-optimized transcoding
|
||||
* Inspired by Stash's approach: kill old processes, start new ones with -ss parameter
|
||||
*/
|
||||
export class FFmpegProcessRegistry {
|
||||
private processes = new Map<string, FFmpegProcessInfo>();
|
||||
|
||||
/**
|
||||
* Register a new FFmpeg process
|
||||
* @param videoId The video ID
|
||||
* @param seekTime The seek time in seconds
|
||||
* @param process The FFmpeg child process
|
||||
* @param command The FFmpeg command arguments
|
||||
* @param quality Optional quality setting
|
||||
*/
|
||||
register(
|
||||
videoId: string,
|
||||
seekTime: number,
|
||||
process: ChildProcess,
|
||||
command: string[],
|
||||
quality?: string
|
||||
): string {
|
||||
const key = `${videoId}_${seekTime}_${quality || 'default'}`;
|
||||
|
||||
// Kill existing process for this video if different seek time
|
||||
this.killExisting(videoId, seekTime, quality);
|
||||
|
||||
this.processes.set(key, {
|
||||
process,
|
||||
startTime: new Date(),
|
||||
seekTime,
|
||||
videoId,
|
||||
command,
|
||||
quality
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Registered process: ${key} (seek: ${seekTime}s)`);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill existing processes for a video that have different seek times
|
||||
*/
|
||||
private killExisting(videoId: string, newSeekTime: number, quality?: string): void {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
const [entryVideoId, entrySeekTime, entryQuality] = key.split('_');
|
||||
|
||||
if (entryVideoId === videoId &&
|
||||
(entrySeekTime !== newSeekTime.toString() ||
|
||||
(entryQuality && entryQuality !== (quality || 'default')))) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
processesToKill.forEach(key => this.killProcess(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a specific process
|
||||
*/
|
||||
killProcess(key: string): boolean {
|
||||
const entry = this.processes.get(key);
|
||||
if (entry && !entry.process.killed) {
|
||||
try {
|
||||
console.log(`[FFMPEG_REGISTRY] Killing process: ${key}`);
|
||||
entry.process.kill('SIGKILL');
|
||||
this.processes.delete(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[FFMPEG_REGISTRY] Error killing process ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill all processes for a specific video
|
||||
*/
|
||||
killAllForVideo(videoId: string): number {
|
||||
const processesToKill: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (entry.videoId === videoId) {
|
||||
processesToKill.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let killedCount = 0;
|
||||
processesToKill.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
killedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (killedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Killed ${killedCount} processes for video: ${videoId}`);
|
||||
}
|
||||
|
||||
return killedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes for a video
|
||||
*/
|
||||
getProcessesForVideo(videoId: string): Array<{
|
||||
key: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}> {
|
||||
return Array.from(this.processes.entries())
|
||||
.filter(([_, entry]) => entry.videoId === videoId)
|
||||
.map(([key, entry]) => ({
|
||||
key,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active processes
|
||||
*/
|
||||
getAllProcesses(): Array<{
|
||||
key: string;
|
||||
videoId: string;
|
||||
seekTime: number;
|
||||
uptime: number;
|
||||
command: string[];
|
||||
quality?: string;
|
||||
}> {
|
||||
return Array.from(this.processes.entries()).map(([key, entry]) => ({
|
||||
key,
|
||||
videoId: entry.videoId,
|
||||
seekTime: entry.seekTime,
|
||||
uptime: Date.now() - entry.startTime.getTime(),
|
||||
command: entry.command,
|
||||
quality: entry.quality
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get process count for a video
|
||||
*/
|
||||
getProcessCountForVideo(videoId: string): number {
|
||||
return Array.from(this.processes.values())
|
||||
.filter(entry => entry.videoId === videoId)
|
||||
.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total process count
|
||||
*/
|
||||
getTotalProcessCount(): number {
|
||||
return this.processes.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale processes (older than maxAge milliseconds)
|
||||
*/
|
||||
cleanupStaleProcesses(maxAge: number = 10 * 60 * 1000): number {
|
||||
const now = Date.now();
|
||||
const staleProcesses: string[] = [];
|
||||
|
||||
for (const [key, entry] of this.processes.entries()) {
|
||||
if (now - entry.startTime.getTime() > maxAge) {
|
||||
staleProcesses.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
let cleanedCount = 0;
|
||||
staleProcesses.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up ${cleanedCount} stale processes`);
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all processes
|
||||
*/
|
||||
cleanupAll(): number {
|
||||
const processKeys = Array.from(this.processes.keys());
|
||||
let cleanedCount = 0;
|
||||
|
||||
processKeys.forEach(key => {
|
||||
if (this.killProcess(key)) {
|
||||
cleanedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[FFMPEG_REGISTRY] Cleaned up all ${cleanedCount} processes`);
|
||||
return cleanedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ffmpegRegistry = new FFmpegProcessRegistry();
|
||||
|
||||
// Cleanup on process exit
|
||||
if (typeof process !== 'undefined') {
|
||||
process.on('exit', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
ffmpegRegistry.cleanupAll();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
/**
|
||||
* HLS Session Manager
|
||||
* Manages lifecycle of HLS segmentation sessions with heartbeat and TTL
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { tsSegmentationService, SegmentationSession } from './ts-segmentation-service';
|
||||
|
||||
export interface SessionHeartbeat {
|
||||
videoId: number;
|
||||
timestamp: Date;
|
||||
segmentIndex?: number;
|
||||
clientInfo?: {
|
||||
userAgent?: string;
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionStats {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
expiredSessions: number;
|
||||
totalSegmentRequests: number;
|
||||
averageSessionDuration: number;
|
||||
}
|
||||
|
||||
class HLSSessionManager extends EventEmitter {
|
||||
private heartbeats = new Map<number, SessionHeartbeat>();
|
||||
private sessionStats = new Map<number, {
|
||||
requestCount: number;
|
||||
firstRequest: Date;
|
||||
lastRequest: Date;
|
||||
}>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get existing session for video
|
||||
*/
|
||||
async getOrCreateSession(videoId: number, videoPath: string): Promise<SegmentationSession> {
|
||||
// Check if video needs segmentation
|
||||
const needsSegmentation = await tsSegmentationService.needsSegmentation(videoPath);
|
||||
|
||||
if (!needsSegmentation) {
|
||||
throw new Error('Video does not need segmentation');
|
||||
}
|
||||
|
||||
let session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (session) {
|
||||
// Add reference to existing session
|
||||
const updatedSession = await tsSegmentationService.addReference(videoId);
|
||||
if (updatedSession) {
|
||||
session = updatedSession;
|
||||
console.log(`[HLSSessionManager] Using existing session for video ${videoId} (refs: ${session.referenceCount})`);
|
||||
}
|
||||
} else {
|
||||
// Create new session
|
||||
console.log(`[HLSSessionManager] Creating new segmentation session for video ${videoId}`);
|
||||
session = await tsSegmentationService.createSegmentationSession(videoId, videoPath);
|
||||
this.emit('sessionCreated', { videoId, session });
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new Error('Failed to create or get session');
|
||||
}
|
||||
|
||||
// Update heartbeat
|
||||
this.updateHeartbeat(videoId);
|
||||
|
||||
// Update stats
|
||||
this.updateSessionStats(videoId);
|
||||
|
||||
return session!
|
||||
}
|
||||
|
||||
/**
|
||||
* Update heartbeat for a session
|
||||
*/
|
||||
updateHeartbeat(videoId: number, segmentIndex?: number, clientInfo?: SessionHeartbeat['clientInfo']): void {
|
||||
const heartbeat: SessionHeartbeat = {
|
||||
videoId,
|
||||
timestamp: new Date(),
|
||||
segmentIndex,
|
||||
clientInfo,
|
||||
};
|
||||
|
||||
this.heartbeats.set(videoId, heartbeat);
|
||||
this.emit('heartbeat', heartbeat);
|
||||
|
||||
console.log(`[HLSSessionManager] Heartbeat updated for video ${videoId} ${segmentIndex !== undefined ? `(segment ${segmentIndex})` : ''}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session heartbeat
|
||||
*/
|
||||
getHeartbeat(videoId: number): SessionHeartbeat | null {
|
||||
return this.heartbeats.get(videoId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is active (recent heartbeat)
|
||||
*/
|
||||
isSessionActive(videoId: number, maxIdleTime: number = 30 * 60 * 1000): boolean {
|
||||
const heartbeat = this.heartbeats.get(videoId);
|
||||
if (!heartbeat) return false;
|
||||
|
||||
const timeSinceLastHeartbeat = Date.now() - heartbeat.timestamp.getTime();
|
||||
return timeSinceLastHeartbeat < maxIdleTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions
|
||||
*/
|
||||
getActiveSessions(maxIdleTime: number = 30 * 60 * 1000): number[] {
|
||||
const activeSessions: number[] = [];
|
||||
|
||||
for (const videoId of this.heartbeats.keys()) {
|
||||
if (this.isSessionActive(videoId, maxIdleTime)) {
|
||||
activeSessions.push(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
return activeSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expired sessions
|
||||
*/
|
||||
getExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): number[] {
|
||||
const expiredSessions: number[] = [];
|
||||
|
||||
for (const videoId of this.heartbeats.keys()) {
|
||||
if (!this.isSessionActive(videoId, maxIdleTime)) {
|
||||
expiredSessions.push(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
return expiredSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired sessions
|
||||
*/
|
||||
async cleanupExpiredSessions(maxIdleTime: number = 30 * 60 * 1000): Promise<number> {
|
||||
const expiredSessions = this.getExpiredSessions(maxIdleTime);
|
||||
let cleanedUpCount = 0;
|
||||
|
||||
for (const videoId of expiredSessions) {
|
||||
try {
|
||||
await this.forceCleanupSession(videoId);
|
||||
cleanedUpCount++;
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error cleaning up session ${videoId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedUpCount > 0) {
|
||||
console.log(`[HLSSessionManager] Cleaned up ${cleanedUpCount} expired sessions`);
|
||||
this.emit('sessionsCleanedUp', { count: cleanedUpCount, videoIds: expiredSessions });
|
||||
}
|
||||
|
||||
return cleanedUpCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup a specific session
|
||||
*/
|
||||
async forceCleanupSession(videoId: number): Promise<void> {
|
||||
try {
|
||||
// Remove reference from segmentation service
|
||||
await tsSegmentationService.removeReference(videoId);
|
||||
|
||||
// Remove heartbeat
|
||||
this.heartbeats.delete(videoId);
|
||||
|
||||
// Remove stats
|
||||
this.sessionStats.delete(videoId);
|
||||
|
||||
console.log(`[HLSSessionManager] Force cleaned up session for video ${videoId}`);
|
||||
this.emit('sessionCleanedUp', { videoId });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error force cleaning up session ${videoId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release reference to a session
|
||||
*/
|
||||
async releaseSession(videoId: number): Promise<void> {
|
||||
try {
|
||||
await tsSegmentationService.removeReference(videoId);
|
||||
console.log(`[HLSSessionManager] Released reference for video ${videoId}`);
|
||||
} catch (error) {
|
||||
console.error(`[HLSSessionManager] Error releasing session ${videoId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session statistics
|
||||
*/
|
||||
getSessionStats(videoId: number): { requestCount: number; firstRequest: Date; lastRequest: Date; } | null {
|
||||
return this.sessionStats.get(videoId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overall statistics
|
||||
*/
|
||||
getOverallStats(): SessionStats {
|
||||
const totalSessions = this.sessionStats.size;
|
||||
const activeSessions = this.getActiveSessions().length;
|
||||
const expiredSessions = totalSessions - activeSessions;
|
||||
|
||||
let totalSegmentRequests = 0;
|
||||
let totalSessionDuration = 0;
|
||||
let validSessions = 0;
|
||||
|
||||
for (const stats of this.sessionStats.values()) {
|
||||
totalSegmentRequests += stats.requestCount;
|
||||
|
||||
const duration = stats.lastRequest.getTime() - stats.firstRequest.getTime();
|
||||
if (duration > 0) {
|
||||
totalSessionDuration += duration;
|
||||
validSessions++;
|
||||
}
|
||||
}
|
||||
|
||||
const averageSessionDuration = validSessions > 0 ? totalSessionDuration / validSessions : 0;
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
activeSessions,
|
||||
expiredSessions,
|
||||
totalSegmentRequests,
|
||||
averageSessionDuration: Math.round(averageSessionDuration / 1000), // Convert to seconds
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session statistics
|
||||
*/
|
||||
private updateSessionStats(videoId: number): void {
|
||||
const now = new Date();
|
||||
const stats = this.sessionStats.get(videoId);
|
||||
|
||||
if (stats) {
|
||||
stats.requestCount++;
|
||||
stats.lastRequest = now;
|
||||
} else {
|
||||
this.sessionStats.set(videoId, {
|
||||
requestCount: 1,
|
||||
firstRequest: now,
|
||||
lastRequest: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers
|
||||
*/
|
||||
private setupEventHandlers(): void {
|
||||
// Listen to segmentation service events
|
||||
this.on('sessionCreated', ({ videoId }) => {
|
||||
console.log(`[HLSSessionManager] Session created for video ${videoId}`);
|
||||
});
|
||||
|
||||
this.on('heartbeat', ({ videoId, segmentIndex }) => {
|
||||
// Optional: Log heartbeats in debug mode
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug(`[HLSSessionManager] Heartbeat: video ${videoId}, segment ${segmentIndex}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.on('sessionCleanedUp', ({ videoId }) => {
|
||||
console.log(`[HLSSessionManager] Session cleaned up for video ${videoId}`);
|
||||
});
|
||||
|
||||
this.on('sessionsCleanedUp', ({ count }) => {
|
||||
console.log(`[HLSSessionManager] Batch cleanup completed: ${count} sessions`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule automatic cleanup
|
||||
*/
|
||||
startAutoCleanup(intervalMs: number = 5 * 60 * 1000, maxIdleTimeMs: number = 30 * 60 * 1000): NodeJS.Timeout {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await this.cleanupExpiredSessions(maxIdleTimeMs);
|
||||
} catch (error) {
|
||||
console.error('[HLSSessionManager] Auto cleanup error:', error);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`[HLSSessionManager] Auto cleanup started (interval: ${intervalMs}ms, maxIdle: ${maxIdleTimeMs}ms)`);
|
||||
return interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information
|
||||
*/
|
||||
getDebugInfo(): {
|
||||
heartbeats: SessionHeartbeat[];
|
||||
stats: SessionStats;
|
||||
segmentationStats: any;
|
||||
} {
|
||||
return {
|
||||
heartbeats: Array.from(this.heartbeats.values()),
|
||||
stats: this.getOverallStats(),
|
||||
segmentationStats: tsSegmentationService.getStats(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate session health
|
||||
*/
|
||||
async validateSessionHealth(videoId: number): Promise<{
|
||||
isValid: boolean;
|
||||
issues: string[];
|
||||
session?: SegmentationSession;
|
||||
}> {
|
||||
const issues: string[] = [];
|
||||
|
||||
try {
|
||||
const session = await tsSegmentationService.getSession(videoId);
|
||||
|
||||
if (!session) {
|
||||
issues.push('Session not found');
|
||||
return { isValid: false, issues };
|
||||
}
|
||||
|
||||
if (session.status === 'error') {
|
||||
issues.push(`Session in error state: ${session.error}`);
|
||||
}
|
||||
|
||||
if (session.status === 'processing') {
|
||||
const age = Date.now() - session.createdAt.getTime();
|
||||
if (age > 5 * 60 * 1000) { // 5 minutes
|
||||
issues.push('Session stuck in processing state');
|
||||
}
|
||||
}
|
||||
|
||||
if (session.status === 'ready') {
|
||||
// Check if playlist file exists
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(session.playlistPath)) {
|
||||
issues.push('Playlist file missing');
|
||||
}
|
||||
}
|
||||
|
||||
const isActive = this.isSessionActive(videoId);
|
||||
if (!isActive) {
|
||||
issues.push('Session inactive (no recent heartbeat)');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
issues,
|
||||
session,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
issues.push(`Validation error: ${error}`);
|
||||
return { isValid: false, issues };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const hlsSessionManager = new HLSSessionManager();
|
||||
export default hlsSessionManager;
|
||||
|
|
@ -100,20 +100,7 @@ export function useProtectedDuration({
|
|||
console.log(`[DURATION] API response not ok:`, response.status, response.statusText);
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers if transcoding
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
setDuration(durationValue);
|
||||
hasRealDuration.current = true;
|
||||
console.log(`[DURATION] Using header duration: ${durationValue}s`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Use fallback
|
||||
// 2. Use fallback when database duration is unavailable.
|
||||
console.log(`[DURATION] Using fallback duration: ${fallbackDuration}s`);
|
||||
setDuration(fallbackDuration);
|
||||
|
||||
|
|
@ -157,16 +144,6 @@ export async function getRealDuration(videoId: string): Promise<number> {
|
|||
}
|
||||
}
|
||||
|
||||
// 2. Try transcoding headers
|
||||
const transcodingResponse = await fetch(`/api/stream/${videoId}/transcode`);
|
||||
const headerDuration = transcodingResponse.headers.get('X-Content-Duration');
|
||||
if (headerDuration) {
|
||||
const durationValue = parseFloat(headerDuration);
|
||||
if (durationValue > 0 && !isNaN(durationValue)) {
|
||||
return durationValue;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error('[DURATION] Error getting real duration:', error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
export class ConcurrencyLimitError extends Error {
|
||||
constructor(public retryAfterSeconds: number) {
|
||||
super('Transcoding capacity is full');
|
||||
this.name = 'ConcurrencyLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConcurrencyGate {
|
||||
private active = 0;
|
||||
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
acquire(): void {
|
||||
if (this.active >= this.max) {
|
||||
throw new ConcurrencyLimitError(30);
|
||||
}
|
||||
this.active += 1;
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.active = Math.max(0, this.active - 1);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
active: this.active,
|
||||
max: this.max,
|
||||
available: Math.max(0, this.max - this.active),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
export interface TranscodeConfig {
|
||||
enabled: boolean;
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
tmpDir: string;
|
||||
maxConcurrent: number;
|
||||
diskBudgetMb: number;
|
||||
heartbeatTimeoutMs: number;
|
||||
startupTimeoutMs: number;
|
||||
hwAccel: 'auto' | 'videotoolbox' | 'nvenc' | 'qsv' | 'none';
|
||||
defaultProfile: string;
|
||||
segmentDuration: number;
|
||||
cacheGraceMs: number;
|
||||
}
|
||||
|
||||
function parseInteger(value: string | undefined, fallback: number): number {
|
||||
if (!value) return fallback;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function getTranscodeConfig(): TranscodeConfig {
|
||||
return {
|
||||
enabled: process.env.ENABLE_LIVE_TRANSCODE === 'true',
|
||||
ffmpegPath: process.env.FFMPEG_PATH || 'ffmpeg',
|
||||
ffprobePath: process.env.FFPROBE_PATH || 'ffprobe',
|
||||
tmpDir: process.env.TRANSCODE_TMP_DIR || '/tmp/nextav-hls',
|
||||
maxConcurrent: parseInteger(process.env.MAX_CONCURRENT_TRANSCODES, 2),
|
||||
diskBudgetMb: parseInteger(process.env.TRANSCODE_DISK_BUDGET_MB, 5000),
|
||||
heartbeatTimeoutMs: parseInteger(process.env.TRANSCODE_HEARTBEAT_TIMEOUT_MS, 60_000),
|
||||
startupTimeoutMs: parseInteger(process.env.TRANSCODE_STARTUP_TIMEOUT_MS, 15_000),
|
||||
hwAccel: (process.env.TRANSCODE_HWACCEL as TranscodeConfig['hwAccel']) || 'auto',
|
||||
defaultProfile: process.env.TRANSCODE_DEFAULT_PROFILE || '720p-h264-aac',
|
||||
segmentDuration: 4,
|
||||
cacheGraceMs: 5 * 60_000,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import path from 'path';
|
||||
import { TranscodeProfileName } from './profiles';
|
||||
import { ProbeInfo } from './ffprobe';
|
||||
|
||||
export type TranscodeDecision =
|
||||
| { kind: 'direct' }
|
||||
| { kind: 'hls-remux'; profile: 'remux-copy' }
|
||||
| { kind: 'hls-transcode'; profile: TranscodeProfileName };
|
||||
|
||||
const DIRECT_EXTENSIONS = new Set(['.mp4', '.m4v', '.webm', '.ogg', '.ogv']);
|
||||
const DIRECT_VIDEO_CODECS = new Set(['h264', 'avc1', 'avc', 'vp8', 'vp9', 'av1']);
|
||||
const DIRECT_AUDIO_CODECS = new Set(['aac', 'mp4a', 'opus', 'vorbis']);
|
||||
const H264_CODECS = new Set(['h264', 'avc1', 'avc']);
|
||||
const AAC_CODECS = new Set(['aac', 'mp4a']);
|
||||
|
||||
export function decideTranscode(filePath: string, probe: ProbeInfo, preferredProfile: TranscodeProfileName): TranscodeDecision {
|
||||
const extension = path.extname(filePath).toLowerCase();
|
||||
const videoCodec = probe.videoCodec.toLowerCase();
|
||||
const audioCodec = probe.audioCodec?.toLowerCase();
|
||||
|
||||
if (
|
||||
DIRECT_EXTENSIONS.has(extension) &&
|
||||
DIRECT_VIDEO_CODECS.has(videoCodec) &&
|
||||
(!audioCodec || DIRECT_AUDIO_CODECS.has(audioCodec))
|
||||
) {
|
||||
return { kind: 'direct' };
|
||||
}
|
||||
|
||||
if (H264_CODECS.has(videoCodec) && (!audioCodec || AAC_CODECS.has(audioCodec))) {
|
||||
return { kind: 'hls-remux', profile: 'remux-copy' };
|
||||
}
|
||||
|
||||
return { kind: 'hls-transcode', profile: preferredProfile };
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import path from 'path';
|
||||
import { getTranscodeConfig } from './config';
|
||||
import { probeHardwareEncoder } from './hw-probe';
|
||||
import { PROFILES, TranscodeProfileName } from './profiles';
|
||||
|
||||
function bitrateToNumber(bitrate: string): number {
|
||||
return Number.parseInt(bitrate.replace(/k$/i, ''), 10);
|
||||
}
|
||||
|
||||
export function buildRemuxArgs(inputPath: string, outDir: string, startTime: number): string[] {
|
||||
const config = getTranscodeConfig();
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-nostats',
|
||||
...(startTime > 0 ? ['-ss', startTime.toString()] : []),
|
||||
'-i',
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-c:v',
|
||||
'copy',
|
||||
'-bsf:v',
|
||||
'h264_mp4toannexb',
|
||||
'-c:a',
|
||||
'copy',
|
||||
'-f',
|
||||
'hls',
|
||||
'-hls_time',
|
||||
config.segmentDuration.toString(),
|
||||
'-hls_list_size',
|
||||
'0',
|
||||
'-hls_segment_type',
|
||||
'mpegts',
|
||||
'-hls_flags',
|
||||
'independent_segments+temp_file',
|
||||
'-hls_segment_filename',
|
||||
path.join(outDir, 'seg_%05d.ts'),
|
||||
'-start_number',
|
||||
'0',
|
||||
path.join(outDir, 'index.m3u8'),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildTranscodeArgs(inputPath: string, outDir: string, profileName: Exclude<TranscodeProfileName, 'remux-copy'>, startTime: number): string[] {
|
||||
const config = getTranscodeConfig();
|
||||
const profile = PROFILES[profileName];
|
||||
const encoder = probeHardwareEncoder();
|
||||
const bitrate = bitrateToNumber(profile.vBitrate);
|
||||
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
'error',
|
||||
'-nostats',
|
||||
...(startTime > 0 ? ['-ss', startTime.toString()] : []),
|
||||
'-i',
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-c:v',
|
||||
encoder.codec,
|
||||
...encoder.presetArgs,
|
||||
'-profile:v',
|
||||
'main',
|
||||
'-level',
|
||||
'4.1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-vf',
|
||||
`scale=-2:min(${profile.maxHeight}\\,ih):flags=lanczos`,
|
||||
'-b:v',
|
||||
profile.vBitrate,
|
||||
'-maxrate',
|
||||
profile.vBitrate,
|
||||
'-bufsize',
|
||||
`${bitrate * 2}k`,
|
||||
'-force_key_frames',
|
||||
`expr:gte(t,n_forced*${config.segmentDuration})`,
|
||||
'-c:a',
|
||||
profile.aCodec,
|
||||
'-b:a',
|
||||
profile.aBitrate,
|
||||
'-ac',
|
||||
'2',
|
||||
'-f',
|
||||
'hls',
|
||||
'-hls_time',
|
||||
config.segmentDuration.toString(),
|
||||
'-hls_list_size',
|
||||
'0',
|
||||
'-hls_segment_type',
|
||||
'mpegts',
|
||||
'-hls_flags',
|
||||
'independent_segments+temp_file',
|
||||
'-hls_segment_filename',
|
||||
path.join(outDir, 'seg_%05d.ts'),
|
||||
'-start_number',
|
||||
'0',
|
||||
path.join(outDir, 'index.m3u8'),
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getTranscodeConfig } from './config';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface ProbeInfo {
|
||||
duration: number;
|
||||
container: string;
|
||||
videoCodec: string;
|
||||
audioCodec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface FFProbeStream {
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface FFProbeOutput {
|
||||
format?: {
|
||||
duration?: string;
|
||||
format_name?: string;
|
||||
};
|
||||
streams?: FFProbeStream[];
|
||||
}
|
||||
|
||||
export function parseStoredProbe(codecInfo?: string | null): ProbeInfo | null {
|
||||
if (!codecInfo) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(codecInfo);
|
||||
const videoCodec = parsed.videoCodec || parsed.codec || parsed.video_codec;
|
||||
if (!videoCodec) return null;
|
||||
return {
|
||||
duration: Number(parsed.duration || 0),
|
||||
container: String(parsed.container || parsed.format || ''),
|
||||
videoCodec: String(videoCodec).toLowerCase(),
|
||||
audioCodec: parsed.audioCodec || parsed.audio_codec ? String(parsed.audioCodec || parsed.audio_codec).toLowerCase() : undefined,
|
||||
width: parsed.width ? Number(parsed.width) : undefined,
|
||||
height: parsed.height ? Number(parsed.height) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeMedia(filePath: string): Promise<ProbeInfo> {
|
||||
const config = getTranscodeConfig();
|
||||
const { stdout } = await execFileAsync(config.ffprobePath, [
|
||||
'-v',
|
||||
'error',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_format',
|
||||
'-show_streams',
|
||||
filePath,
|
||||
], { encoding: 'utf8', timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
|
||||
const parsed = JSON.parse(stdout) as FFProbeOutput;
|
||||
const video = parsed.streams?.find(stream => stream.codec_type === 'video');
|
||||
const audio = parsed.streams?.find(stream => stream.codec_type === 'audio');
|
||||
|
||||
if (!video?.codec_name) {
|
||||
throw new Error('No video stream found');
|
||||
}
|
||||
|
||||
return {
|
||||
duration: Number(parsed.format?.duration || 0),
|
||||
container: String(parsed.format?.format_name || '').toLowerCase(),
|
||||
videoCodec: video.codec_name.toLowerCase(),
|
||||
audioCodec: audio?.codec_name?.toLowerCase(),
|
||||
width: video.width,
|
||||
height: video.height,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
export class HeartbeatManager {
|
||||
private timers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
arm(jobId: string, timeoutMs: number, onTimeout: () => void): void {
|
||||
this.clear(jobId);
|
||||
const timer = setTimeout(onTimeout, timeoutMs);
|
||||
timer.unref();
|
||||
this.timers.set(jobId, timer);
|
||||
}
|
||||
|
||||
clear(jobId: string): void {
|
||||
const timer = this.timers.get(jobId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
for (const jobId of this.timers.keys()) {
|
||||
this.clear(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { getTranscodeConfig } from './config';
|
||||
|
||||
export interface HardwareEncoder {
|
||||
codec: string;
|
||||
presetArgs: string[];
|
||||
label: string;
|
||||
}
|
||||
|
||||
let cachedEncoder: HardwareEncoder | null = null;
|
||||
|
||||
export function probeHardwareEncoder(): HardwareEncoder {
|
||||
if (cachedEncoder) return cachedEncoder;
|
||||
|
||||
const config = getTranscodeConfig();
|
||||
const fallback: HardwareEncoder = {
|
||||
codec: 'libx264',
|
||||
presetArgs: ['-preset', 'veryfast'],
|
||||
label: 'libx264 software',
|
||||
};
|
||||
|
||||
if (config.hwAccel === 'none') {
|
||||
cachedEncoder = fallback;
|
||||
return cachedEncoder;
|
||||
}
|
||||
|
||||
let encoders = '';
|
||||
try {
|
||||
encoders = execFileSync(config.ffmpegPath, ['-hide_banner', '-encoders'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Transcode] Failed to probe FFmpeg encoders; using software fallback', error);
|
||||
cachedEncoder = fallback;
|
||||
return cachedEncoder;
|
||||
}
|
||||
|
||||
const wants = config.hwAccel;
|
||||
if ((wants === 'auto' || wants === 'videotoolbox') && encoders.includes('h264_videotoolbox')) {
|
||||
cachedEncoder = { codec: 'h264_videotoolbox', presetArgs: [], label: 'h264_videotoolbox' };
|
||||
} else if ((wants === 'auto' || wants === 'nvenc') && encoders.includes('h264_nvenc')) {
|
||||
cachedEncoder = { codec: 'h264_nvenc', presetArgs: ['-preset', 'p4'], label: 'h264_nvenc' };
|
||||
} else if ((wants === 'auto' || wants === 'qsv') && encoders.includes('h264_qsv')) {
|
||||
cachedEncoder = { codec: 'h264_qsv', presetArgs: [], label: 'h264_qsv' };
|
||||
} else {
|
||||
cachedEncoder = fallback;
|
||||
}
|
||||
|
||||
return cachedEncoder;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getTranscodeConfig } from './config';
|
||||
|
||||
function directorySize(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).reduce((total, entry) => {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return total + directorySize(fullPath);
|
||||
try {
|
||||
return total + fs.statSync(fullPath).size;
|
||||
} catch {
|
||||
return total;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function removeDirectory(dir: string): void {
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export class SegmentJanitor {
|
||||
scheduleCleanup(outDir: string, delayMs = getTranscodeConfig().cacheGraceMs): void {
|
||||
setTimeout(() => {
|
||||
removeDirectory(outDir);
|
||||
}, delayMs).unref();
|
||||
}
|
||||
|
||||
enforceBudget(): void {
|
||||
const config = getTranscodeConfig();
|
||||
if (!fs.existsSync(config.tmpDir)) return;
|
||||
|
||||
const budgetBytes = config.diskBudgetMb * 1024 * 1024;
|
||||
if (directorySize(config.tmpDir) <= budgetBytes) return;
|
||||
|
||||
const jobDirs = fs.readdirSync(config.tmpDir)
|
||||
.map(name => path.join(config.tmpDir, name))
|
||||
.filter(dir => fs.statSync(dir).isDirectory())
|
||||
.map(dir => ({ dir, mtime: fs.statSync(dir).mtimeMs }))
|
||||
.sort((a, b) => a.mtime - b.mtime);
|
||||
|
||||
for (const { dir } of jobDirs) {
|
||||
removeDirectory(dir);
|
||||
if (directorySize(config.tmpDir) <= budgetBytes) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
// TODO: Fix the .ts resegmentation implementation for HLS streaming
|
||||
const TS_STREAM_FORMATS = [
|
||||
const TS_STREAM_FORMATS: string[] = [
|
||||
// 'ts', // TEMPORARILY DISABLED - send to local player instead
|
||||
'm2ts', // Blu-ray Transport Stream
|
||||
'mts' // AVCHD Transport Stream
|
||||
// 'm2ts', // Blu-ray Transport Stream
|
||||
// 'mts' // AVCHD Transport Stream
|
||||
];
|
||||
|
||||
// Formats with limited support (may need transcoding)
|
||||
|
|
@ -191,27 +191,28 @@ function createDirectFormat(video: VideoFile, extension: string): VideoFormat {
|
|||
* Create HLS streaming format configuration
|
||||
*/
|
||||
function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||
return {
|
||||
type: 'hls',
|
||||
supportLevel: 'hls',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
qualities: [
|
||||
{
|
||||
html: 'Auto',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
html: '1080p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=1080`
|
||||
url: `${url}&profile=1080p-h264-aac`
|
||||
},
|
||||
{
|
||||
html: '720p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=720`
|
||||
url: `${url}&profile=720p-h264-aac`
|
||||
},
|
||||
{
|
||||
html: '480p',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8?quality=480`
|
||||
url: `${url}&profile=480p-h264-aac`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -222,14 +223,15 @@ function createHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
|||
* .ts files are already in HLS-compatible format, so we use HLS streaming
|
||||
*/
|
||||
function createTSHLSFormat(video: VideoFile, extension: string): VideoFormat {
|
||||
const url = `/api/transcode/start?mediaId=${video.id}`;
|
||||
return {
|
||||
type: 'hls',
|
||||
supportLevel: 'hls',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
qualities: [
|
||||
{
|
||||
html: 'Auto (HLS)',
|
||||
url: `/api/stream/hls/${video.id}/playlist.m3u8`,
|
||||
url,
|
||||
default: true
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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