File size: 7,930 Bytes
b84c410
b5c3309
b84c410
 
 
b5c3309
 
 
57f6065
b5c3309
 
b84c410
 
b5c3309
b84c410
0205fd4
b84c410
57f6065
 
b5c3309
 
 
57f6065
b5c3309
 
b84c410
b5c3309
 
 
 
 
 
 
57f6065
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5c3309
 
 
b84c410
 
 
 
b5c3309
 
b84c410
 
b5c3309
 
b84c410
 
 
 
b5c3309
 
 
b84c410
b5c3309
 
 
57f6065
 
 
 
 
 
 
 
 
b5c3309
 
 
 
b84c410
b5c3309
 
 
b84c410
 
 
 
 
 
 
 
 
 
b5c3309
57f6065
b5c3309
 
 
0205fd4
 
 
 
b5c3309
 
 
57f6065
b5c3309
 
 
0205fd4
 
 
 
b5c3309
 
b84c410
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b5c3309
 
 
b84c410
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
"""CMS API + browsing UI for the compression miner.

Reads the SQLite DB (populated by ingest.py), the calibration log, the CQ
tables' source, and the two sample libraries. Runs as its own process,
independent of the compression service and the miner -- see run_server.sh.
"""
from __future__ import annotations

import re
from pathlib import Path

import calibration
import cq_health
import db
import library
import preview
from auth import require_auth
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse

REFERENCE_LIBRARY_PATH = Path("/root/vidaio-real-content-library")
COMPRESSED_LIBRARY_PATH = Path("/root/vidaio-compressed-sample-library")
_RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)")

app = FastAPI(title="Vidaio Miner CMS")
Auth = Depends(require_auth)


def _find_sample(library_path: Path, task_id: str) -> Path | None:
    matches = list(library_path.glob(f"{task_id}_*.mp4"))
    return matches[0] if matches else None


_STREAM_CHUNK = 1024 * 1024


def _stream_video(path: Path, request: Request) -> StreamingResponse:
    """Plain FileResponse ignores the Range header and always returns 200
    with the entire file -- for a 200MB+ 4K reference clip, that means the
    browser has to download the whole thing before it can play anything,
    which looks exactly like the "spinning, never plays" symptom this was
    built to fix. Real 206 Partial Content support so the <video> element
    can seek and start playing immediately.
    """
    file_size = path.stat().st_size
    range_header = request.headers.get("range")
    start, end = 0, file_size - 1
    status_code = 200
    if range_header:
        match = _RANGE_RE.match(range_header)
        if match:
            if match.group(1):
                start = int(match.group(1))
            if match.group(2):
                end = int(match.group(2))
            end = min(end, file_size - 1)
            status_code = 206

    def iterfile():
        with open(path, "rb") as f:
            f.seek(start)
            remaining = end - start + 1
            while remaining > 0:
                chunk = f.read(min(_STREAM_CHUNK, remaining))
                if not chunk:
                    break
                remaining -= len(chunk)
                yield chunk

    headers = {
        "Content-Range": f"bytes {start}-{end}/{file_size}",
        "Accept-Ranges": "bytes",
        "Content-Length": str(end - start + 1),
    }
    return StreamingResponse(iterfile(), status_code=status_code, media_type="video/mp4", headers=headers)


@app.on_event("startup")
def _startup() -> None:
    db.init_db()
    from auth import get_credentials
    user, pw = get_credentials()
    print(f"CMS auth -- username: {user}  password: {pw}", flush=True)
    print(f"(persisted at {__import__('auth').CREDENTIALS_FILE})", flush=True)


# --- Items ------------------------------------------------------------

@app.get("/api/items")
def api_list_items(codec: str | None = None, vmaf_threshold: float | None = None,
                    limit: int = 100, offset: int = 0, sort: str = "ts", order: str = "desc",
                    user: str = Auth):
    return db.list_items(codec=codec, vmaf_threshold=vmaf_threshold, limit=limit,
                          offset=offset, sort=sort, order=order)


@app.get("/api/items/{task_id}")
def api_get_item(task_id: str, user: str = Auth):
    item = db.get_item(task_id)
    if item is None:
        raise HTTPException(status_code=404, detail="item not found")
    # The DB's has_*_sample flags are set once at ingest time, but the
    # compressed-sample capture is a fire-and-forget background task in a
    # separate process (neurons/miner.py) with no ordering guarantee
    # against the outcome-log write this row was ingested from -- ingest
    # can easily run before the file finishes copying, permanently
    # freezing the flag at False even though the file shows up moments
    # later. Check the filesystem live instead of trusting that snapshot.
    item["has_reference_sample"] = int(_find_sample(REFERENCE_LIBRARY_PATH, task_id) is not None)
    item["has_compressed_sample"] = int(_find_sample(COMPRESSED_LIBRARY_PATH, task_id) is not None)
    return item


@app.get("/api/stats")
def api_stats(user: str = Auth):
    return db.stats_summary()


@app.get("/api/score-trend")
def api_score_trend(days: int = 14, user: str = Auth):
    return db.score_trend(days=days)


@app.get("/api/rate-distortion")
def api_rate_distortion(limit: int = 500, user: str = Auth):
    return db.rate_distortion_points(limit=limit)


@app.get("/video/{task_id}/reference")
def video_reference(task_id: str, request: Request, user: str = Auth):
    path = _find_sample(REFERENCE_LIBRARY_PATH, task_id)
    if path is None:
        raise HTTPException(status_code=404, detail="reference sample not retained for this item")
    playable = preview.get_preview(path, f"{task_id}_reference")
    if playable is None:
        raise HTTPException(status_code=500, detail="failed to prepare a browser-playable preview")
    return _stream_video(playable, request)


@app.get("/video/{task_id}/compressed")
def video_compressed(task_id: str, request: Request, user: str = Auth):
    path = _find_sample(COMPRESSED_LIBRARY_PATH, task_id)
    if path is None:
        raise HTTPException(status_code=404, detail="compressed sample not retained for this item")
    playable = preview.get_preview(path, f"{task_id}_compressed")
    if playable is None:
        raise HTTPException(status_code=500, detail="failed to prepare a browser-playable preview")
    return _stream_video(playable, request)


# --- CQ table health ----------------------------------------------------

@app.get("/api/cq-health")
def api_cq_health(user: str = Auth):
    return cq_health.cq_table_health()


# --- Calibration ----------------------------------------------------------

@app.get("/api/calibration/log")
def api_calibration_log(codec: str | None = None, vmaf_threshold: float | None = None,
                         limit: int = 200, user: str = Auth):
    return calibration.read_calibration_log(codec=codec, vmaf_threshold=vmaf_threshold, limit=limit)


@app.post("/api/calibration/start")
def api_calibration_start(codec: str, vmaf_threshold: float, cq_grid: str = "30,33,35,38,40",
                           limit_clips: int = 6, user: str = Auth):
    run_id = calibration.start_calibration(codec, vmaf_threshold, cq_grid, limit_clips)
    return {"run_id": run_id}


@app.get("/api/calibration/runs")
def api_calibration_runs(user: str = Auth):
    return db.list_calibration_runs()


@app.get("/api/calibration/runs/{run_id}")
def api_calibration_run(run_id: int, user: str = Auth):
    run = calibration.poll_calibration_run(run_id)
    if run is None:
        raise HTTPException(status_code=404, detail="run not found")
    return run


# --- Content library --------------------------------------------------

@app.get("/api/library/coverage")
def api_library_coverage(user: str = Auth):
    return library.coverage_grid()


@app.get("/api/library/clips")
def api_library_clips(user: str = Auth):
    return library.list_reference_clips()


@app.get("/thumbnail/{task_id}")
def get_thumbnail(task_id: str, user: str = Auth):
    path = library.get_thumbnail(task_id)
    if path is None:
        raise HTTPException(status_code=404, detail="no thumbnail available")
    return FileResponse(path, media_type="image/jpeg")


# --- Alerts -----------------------------------------------------------

@app.get("/api/alerts")
def api_alerts(limit: int = 50, user: str = Auth):
    return db.recent_alerts(limit=limit)


# --- UI -----------------------------------------------------------------

_PAGE_PATH = Path(__file__).parent / "page.html"


@app.get("/", response_class=HTMLResponse)
def index(user: str = Auth):
    return _PAGE_PATH.read_text()