triflix commited on
Commit
a1cab9f
·
verified ·
1 Parent(s): b643397

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +121 -518
app.py CHANGED
@@ -1,542 +1,145 @@
1
- # app.py
2
  import os
3
  import re
4
- import shutil
5
  import aiofiles
6
- import asyncio
7
- import mimetypes
8
- import subprocess
9
  from pathlib import Path
10
- from typing import Optional, Tuple
11
-
12
- from fastapi import FastAPI, UploadFile, File, Request, HTTPException
13
- from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse, FileResponse
14
- from fastapi.middleware.cors import CORSMiddleware
15
-
16
- # ---------- Config ----------
17
- BASE_DIR = Path("/tmp/videos") # ephemeral storage suitable for HF Spaces free tier
18
- BASE_DIR.mkdir(parents=True, exist_ok=True)
19
- ALLOWED_EXT = {".mp4", ".webm", ".ogg", ".mov", ".mkv", ".avi", ".flv", ".ts", ".m4v"}
20
- MAX_FILENAME_LEN = 150
21
- CHUNK_SIZE = 1024 * 1024 # 1MB
22
-
23
- FFMPEG_PATH = shutil.which("ffmpeg") # None if not installed
24
-
25
- app = FastAPI(title="FastAPI Video Player (complete)")
26
-
27
- app.add_middleware(
28
- CORSMiddleware,
29
- allow_origins=["*"],
30
- allow_methods=["*"],
31
- allow_headers=["*"],
32
- )
33
-
34
-
35
- # ---------- Utilities ----------
36
- def secure_filename(filename: str) -> str:
37
- """
38
- Sanitize filename:
39
- - Keep ascii letters, digits, dot, underscore, hyphen
40
- - Replace other chars with underscore
41
- - Trim long names to avoid issues
42
- """
43
- name = os.path.basename(filename)
44
- # Normalize unicode -> ascii (drop accents)
45
- try:
46
- import unicodedata
47
- name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
48
- except Exception:
49
- pass
50
- name = re.sub(r"[^A-Za-z0-9._-]", "_", name)
51
- if len(name) > MAX_FILENAME_LEN:
52
- base, ext = os.path.splitext(name)
53
- name = base[:100] + ext
54
- if name == "" or name in {".", ".."}:
55
- name = "file"
56
- return name
57
-
58
 
59
- def needs_transcode(ext: str) -> bool:
60
- """
61
- Decide if we should transcode for browser compatibility.
62
- Browsers commonly support mp4 (h264/aac), webm (vp9/vp8), ogg.
63
- We will transcode if file is .mkv (common problem) or .avi, .flv etc.
64
- """
65
- ext = ext.lower()
66
- return ext in {".mkv", ".avi", ".flv", ".ts", ".m4v"}
67
 
 
 
68
 
69
- def parse_range(range_header: Optional[str], file_size: int) -> Optional[Tuple[int, int]]:
70
- if not range_header:
71
- return None
72
- try:
73
- units, rng = range_header.split("=")
74
- if units.strip() != "bytes":
75
- return None
76
- start_str, end_str = rng.split("-")
77
- if start_str == "":
78
- # suffix bytes: -500
79
- length = int(end_str)
80
- start = file_size - length
81
- end = file_size - 1
82
- else:
83
- start = int(start_str)
84
- end = int(end_str) if end_str else file_size - 1
85
- if start < 0:
86
- start = 0
87
- if end >= file_size:
88
- end = file_size - 1
89
- if start > end:
90
- return None
91
- return start, end
92
- except Exception:
93
- return None
94
 
95
 
96
- async def transcode_to_mp4(src: Path, dest: Path) -> bool:
97
- """
98
- Transcode using ffmpeg to mp4 (H.264 AAC). Returns True if success.
99
- Blocking ffmpeg run executed asynchronously via asyncio to avoid blocking loop.
100
- """
101
- if not FFMPEG_PATH:
102
- return False
103
- cmd = [
104
- FFMPEG_PATH,
105
- "-y",
106
- "-i", str(src),
107
- "-c:v", "libx264",
108
- "-preset", "fast",
109
- "-crf", "23",
110
- "-c:a", "aac",
111
- "-b:a", "128k",
112
- str(dest)
113
- ]
114
- proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
115
- stdout, stderr = await proc.communicate()
116
- return proc.returncode == 0
117
 
118
 
119
- # ---------- Routes ----------
120
  @app.get("/", response_class=HTMLResponse)
121
  async def index():
122
- # Embedded HTML + JS (single-page app)
123
- # Contains: upload form (xhr progress), file list, player with controls
124
- html = f"""
125
- <!doctype html>
126
- <html>
127
- <head>
128
- <meta charset="utf-8"/>
129
- <title>FastAPI Video Player</title>
130
- <meta name="viewport" content="width=device-width,initial-scale=1"/>
131
- <style>
132
- :root{{--bg:#0b1020;--card:#0f1724;--muted:#9aa7bb;--accent1:#3b82f6;--accent2:#06b6d4;--glass: rgba(255,255,255,0.03)}}
133
- body{{margin:0;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto;color:#e6eef8;background:linear-gradient(180deg,var(--bg),#071029);padding:20px}}
134
- .wrap{{max-width:1100px;margin:0 auto}}
135
- .card{{background:var(--card);padding:18px;border-radius:12px;box-shadow:0 8px 30px rgba(2,6,23,0.6)}}
136
- h1{{
137
- margin:0 0 8px 0;font-size:20px;
138
- }}
139
- .row{{display:flex;gap:12px;align-items:center;flex-wrap:wrap}}
140
- .uploader{{border:1px dashed var(--glass);padding:14px;border-radius:10px;display:flex;gap:12px;align-items:center}}
141
- .btn{{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 12px;border-radius:10px;color:inherit;cursor:pointer}}
142
- input[type=file]{{color:transparent}}
143
- video{{width:100%;max-height:62vh;border-radius:8px;background:#000;margin-top:12px}}
144
- .controls{{display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap}}
145
- .progress-wrap{{margin-top:12px;display:none}}
146
- .progress{{height:14px;background:rgba(255,255,255,0.06);border-radius:10px;overflow:hidden}}
147
- .progress > .fill{{height:100%;width:0%;background:linear-gradient(90deg,var(--accent1),var(--accent2));transition:width .12s}}
148
- .status{{font-size:13px;color:var(--muted);margin-top:6px}}
149
- ul.files{{list-style:none;padding:0;margin:8px 0 0 0;display:grid;grid-template-columns:1fr;gap:8px}}
150
- li.file{{background:rgba(255,255,255,0.02);padding:8px;border-radius:8px;display:flex;justify-content:space-between;align-items:center;gap:8px}}
151
- .meta{{font-size:13px;color:var(--muted)}}
152
- .small{{font-size:13px;color:var(--muted)}}
153
- .actions a{margin-left:8px;color:var(--muted);text-decoration:none}
154
- @media(min-width:700px){{ul.files{{grid-template-columns:1fr 1fr}}}}
155
- </style>
156
- </head>
157
- <body>
158
- <div class="wrap">
159
- <div class="card">
160
- <h1>FastAPI Video Player — Upload & Play (/tmp/videos)</h1>
161
- <div class="small">Files are stored in <code>/tmp/videos</code> (ephemeral on free Spaces). MKV/AVI auto-transcode to MP4 if <code>ffmpeg</code> is installed.</div>
162
-
163
- <div style="height:12px"></div>
164
-
165
- <div class="uploader row">
166
- <input id="fileInput" type="file" accept="video/*,video/x-matroska,.mkv,.mp4,.webm,.avi,.mov,.flv,.ts"/>
167
- <button id="uploadBtn" class="btn">Upload</button>
168
- <div style="flex:1"></div>
169
- <button id="refreshBtn" class="btn">Refresh List</button>
170
- <button id="clearBtn" class="btn">Clear /tmp</button>
171
- </div>
172
-
173
- <div class="progress-wrap" id="progressWrap">
174
- <div class="progress"><div class="fill" id="progressFill"></div></div>
175
- <div class="status" id="status">Idle</div>
176
- </div>
177
-
178
- <video id="player" controls preload="metadata">
179
- <source id="source" src=""/>
180
- Your browser does not support HTML5 video.
181
- </video>
182
-
183
- <div class="controls">
184
- <button id="playPause" class="btn">Play/Pause</button>
185
- <button id="muteBtn" class="btn">Mute/Unmute</button>
186
- <label class="small">Rate:
187
- <select id="rate">
188
- <option value="0.5">0.5x</option><option value="0.75">0.75x</option>
189
- <option value="1" selected>1x</option><option value="1.25">1.25x</option>
190
- <option value="1.5">1.5x</option><option value="2">2x</option>
191
- </select>
192
- </label>
193
- <label class="small">Vol:
194
- <input type="range" id="volume" min="0" max="1" step="0.01" value="1">
195
- </label>
196
- <button id="pipBtn" class="btn">Picture-in-Picture</button>
197
- <button id="fsBtn" class="btn">Fullscreen</button>
198
- <div style="flex:1"></div>
199
- <div id="playInfo" class="small">No file loaded</div>
200
- </div>
201
-
202
- <hr style="border:none;border-top:1px solid rgba(255,255,255,0.03);margin:12px 0"/>
203
-
204
- <h3>Files in /tmp/videos</h3>
205
- <ul class="files" id="fileList"></ul>
206
-
207
- </div>
208
- </div>
209
-
210
- <script>
211
- const uploadBtn = document.getElementById('uploadBtn');
212
- const fileInput = document.getElementById('fileInput');
213
- const progressWrap = document.getElementById('progressWrap');
214
- const progressFill = document.getElementById('progressFill');
215
- const statusEl = document.getElementById('status');
216
- const refreshBtn = document.getElementById('refreshBtn');
217
- const clearBtn = document.getElementById('clearBtn');
218
- const fileList = document.getElementById('fileList');
219
- const player = document.getElementById('player');
220
- const source = document.getElementById('source');
221
- const playPause = document.getElementById('playPause');
222
- const muteBtn = document.getElementById('muteBtn');
223
- const rate = document.getElementById('rate');
224
- const volume = document.getElementById('volume');
225
- const pipBtn = document.getElementById('pipBtn');
226
- const fsBtn = document.getElementById('fsBtn');
227
- const playInfo = document.getElementById('playInfo');
228
-
229
- let currentFile = null;
230
-
231
- function fmtBytes(bytes) {
232
- if (bytes === 0) return '0 B';
233
- const k = 1024;
234
- const sizes = ['B','KB','MB','GB','TB'];
235
- const i = Math.floor(Math.log(bytes)/Math.log(k));
236
- return parseFloat((bytes / Math.pow(k,i)).toFixed(2)) + ' ' + sizes[i];
237
- }
238
-
239
- async function fetchList(){
240
- try {
241
- const res = await fetch('/list');
242
- const arr = await res.json();
243
- fileList.innerHTML = '';
244
- if (!arr.length) {
245
- fileList.innerHTML = '<li class="file small">No files uploaded yet.</li>';
246
- return;
247
- }
248
- for (let item of arr){
249
- const li = document.createElement('li');
250
- li.className = 'file';
251
- const left = document.createElement('div');
252
- left.innerHTML = `<div style="font-weight:600">${item.name}</div><div class="meta">${item.size_human} • ${item.mtime}</div>`;
253
- const right = document.createElement('div');
254
- right.className = 'actions';
255
- right.innerHTML = `
256
- <a href="#" onclick="playFile('${encodeURIComponent(item.name)}');return false;" title="Play">▶️ Play</a>
257
- <a href="/video/${encodeURIComponent(item.name)}" target="_blank" title="Open">⤴ Open</a>
258
- <a href="/download/${encodeURIComponent(item.name)}" title="Download">⬇️</a>
259
- <a href="#" onclick="deleteFile('${encodeURIComponent(item.name)}');return false;" title="Delete">🗑️</a>
260
- `;
261
- li.appendChild(left); li.appendChild(right);
262
- fileList.appendChild(li);
263
- }
264
- } catch (e) {
265
- console.error(e);
266
- fileList.innerHTML = '<li class="file small">Failed to load files</li>';
267
- }
268
- }
269
-
270
- async function playFile(nameEncoded){
271
- const name = decodeURIComponent(nameEncoded);
272
- currentFile = name;
273
- source.src = '/video/' + encodeURIComponent(name);
274
- player.load();
275
- try { await player.play(); } catch(e) {}
276
- playInfo.textContent = name;
277
- }
278
-
279
- uploadBtn.addEventListener('click', async ()=>{
280
- const file = fileInput.files[0];
281
- if (!file) { alert('Pick a file first'); return; }
282
-
283
- const form = new FormData();
284
- form.append('file', file);
285
-
286
- const xhr = new XMLHttpRequest();
287
- xhr.open('POST', '/upload', true);
288
-
289
- let startTime = 0;
290
- xhr.upload.onloadstart = function(){ startTime = Date.now(); progressWrap.style.display='block'; progressFill.style.width='0%'; statusEl.textContent = 'Starting upload...'; }
291
- xhr.upload.onprogress = function(e){
292
- if (e.lengthComputable){
293
- const pct = (e.loaded / e.total) * 100;
294
- progressFill.style.width = pct.toFixed(2) + '%';
295
- const elapsed = (Date.now() - startTime)/1000;
296
- const speed = e.loaded / elapsed; // bytes/sec
297
- const speedStr = fmtBytes(speed) + '/s';
298
- const remain = (e.total - e.loaded) / (speed || 1);
299
- const eta = Math.max(0, remain).toFixed(1) + 's';
300
- statusEl.textContent = `Uploading ${pct.toFixed(1)}% • ${speedStr} • ETA ${eta}`;
301
- }
302
- };
303
- xhr.onreadystatechange = function(){
304
- if (xhr.readyState === 4){
305
- if (xhr.status === 200){
306
- try {
307
- const json = JSON.parse(xhr.responseText);
308
- // server may return {"status":"ok","transcoded":true,"filename":"x.mp4"}
309
- statusEl.textContent = 'Upload complete. ' + (json.transcoded ? 'Transcoded to MP4.' : 'Stored.');
310
- } catch(e){
311
- statusEl.textContent = 'Upload complete.';
312
- }
313
- setTimeout(()=>{ progressWrap.style.display='none'; progressFill.style.width='0%'; }, 900);
314
- fetchList();
315
- } else {
316
- statusEl.textContent = 'Upload failed: ' + xhr.responseText;
317
- }
318
- }
319
- };
320
-
321
- xhr.send(form);
322
- });
323
-
324
- refreshBtn.addEventListener('click', fetchList);
325
- window.addEventListener('load', fetchList);
326
-
327
- clearBtn.addEventListener('click', async ()=>{
328
- if (!confirm('Delete ALL files in /tmp/videos?')) return;
329
- const res = await fetch('/clear_tmp', { method:'POST' });
330
- if (res.ok) { fetchList(); alert('Cleared'); } else alert('Clear failed');
331
- });
332
-
333
- async function deleteFile(nameEncoded){
334
- const name = decodeURIComponent(nameEncoded);
335
- if (!confirm('Delete ' + name + '?')) return;
336
- const res = await fetch('/delete/' + encodeURIComponent(name), { method:'DELETE' });
337
- if (res.ok) { fetchList(); } else alert('Delete failed');
338
- }
339
-
340
- // Player controls
341
- playPause.addEventListener('click', ()=>{ if (player.paused) player.play(); else player.pause(); });
342
- muteBtn.addEventListener('click', ()=>{ player.muted = !player.muted; muteBtn.textContent = player.muted ? 'Unmute' : 'Mute'; });
343
- rate.addEventListener('change', ()=>{ player.playbackRate = parseFloat(rate.value); });
344
- volume.addEventListener('input', ()=>{ player.volume = parseFloat(volume.value); });
345
-
346
- pipBtn.addEventListener('click', async ()=>{
347
- if (document.pictureInPictureElement) {
348
- await document.exitPictureInPicture();
349
- } else {
350
- try { await player.requestPictureInPicture(); } catch(e){ alert('PiP not supported'); }
351
- }
352
- });
353
-
354
- fsBtn.addEventListener('click', ()=>{
355
- if (!document.fullscreenElement) player.requestFullscreen().catch(()=>{});
356
- else document.exitFullscreen();
357
- });
358
-
359
- player.addEventListener('timeupdate', ()=> {
360
- // you can update a progress indicator here if you want
361
- });
362
-
363
- // expose helper for play links
364
- window.playFile = playFile;
365
- window.deleteFile = deleteFile;
366
- </script>
367
- </body>
368
- </html>
369
- """
370
- return HTMLResponse(content=html)
371
 
372
 
373
  @app.post("/upload")
374
- async def upload(file: UploadFile = File(...)):
375
- """
376
- Upload file, sanitize filename, save in /tmp/videos.
377
- If file type needs transcode and ffmpeg is available, transcode to MP4 and return transcoded filename.
378
- """
379
- orig_name = secure_filename(file.filename)
380
- ext = Path(orig_name).suffix.lower()
381
- if ext not in ALLOWED_EXT:
382
- raise HTTPException(status_code=400, detail=f"Extension '{ext}' not allowed.")
383
-
384
- # ensure unique name
385
- dest = BASE_DIR / orig_name
386
- base, extn = os.path.splitext(dest)
387
- counter = 1
388
- while dest.exists():
389
- dest = Path(f"{base}_{counter}{extn}")
390
- counter += 1
391
-
392
- # save uploaded file in chunks
393
  try:
394
- async with aiofiles.open(dest, "wb") as out_f:
395
- while True:
396
- chunk = await file.read(CHUNK_SIZE)
397
- if not chunk:
398
- break
399
- await out_f.write(chunk)
400
- except PermissionError:
401
- raise HTTPException(status_code=500, detail="Permission denied writing to storage.")
402
  except Exception as e:
403
- raise HTTPException(status_code=500, detail=str(e))
404
-
405
- # If transcode is recommended, try to transcode synchronously (blocking)
406
- transcoded = False
407
- if needs_transcode(ext) and FFMPEG_PATH:
408
- target = dest.with_suffix(".mp4")
409
- # avoid re-transcoding if existing
410
- try:
411
- # run transcode
412
- success = await transcode_to_mp4(dest, target)
413
- if success:
414
- # remove original (to save space) and set dest to mp4
415
- try:
416
- dest.unlink(missing_ok=True)
417
- except Exception:
418
- pass
419
- dest = target
420
- transcoded = True
421
- else:
422
- # transcode failed: keep original
423
- transcoded = False
424
- except Exception:
425
- transcoded = False
426
-
427
- return JSONResponse({"status": "ok", "filename": dest.name, "transcoded": transcoded})
428
 
429
 
430
  @app.get("/list")
431
- async def list_files():
432
- """
433
- Return JSON list of files with name, size, modified time.
434
- """
435
- files = []
436
- for p in sorted(BASE_DIR.glob("*")):
437
- if p.is_file():
438
- stat = p.stat()
439
- files.append({
440
- "name": p.name,
441
- "size": stat.st_size,
442
- "size_human": human_readable_size(stat.st_size),
443
- "mtime": format_mtime(stat.st_mtime)
444
- })
445
- return JSONResponse(files)
446
-
447
-
448
- def human_readable_size(n: int) -> str:
449
- for unit in ['B','KB','MB','GB','TB']:
450
- if n < 1024.0:
451
- return f"{n:.2f} {unit}"
452
- n /= 1024.0
453
- return f"{n:.2f} PB"
454
-
455
 
456
- def format_mtime(ts: float) -> str:
457
- from datetime import datetime
458
- return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
459
 
460
-
461
- @app.get("/video/{name}")
462
- async def stream_video(request: Request, name: str):
463
- """
464
- Stream with Range support for seeking.
465
- """
466
- filename = secure_filename(name)
467
- path = BASE_DIR / filename
468
- if not path.exists():
469
- raise HTTPException(status_code=404, detail="File not found")
470
- file_size = path.stat().st_size
471
- range_header = request.headers.get("range")
472
- range_parsed = parse_range(range_header, file_size)
473
-
474
- mime_type, _ = mimetypes.guess_type(str(path))
475
  if mime_type is None:
476
- mime_type = "application/octet-stream"
477
-
478
- if range_parsed:
479
- start, end = range_parsed
480
- chunk_size = (end - start) + 1
481
- async def iter_file():
482
- with open(path, "rb") as f:
483
- f.seek(start)
484
- remaining = chunk_size
485
- while remaining > 0:
486
- read_bytes = min(CHUNK_SIZE, remaining)
487
- data = f.read(read_bytes)
488
- if not data:
489
- break
490
- remaining -= len(data)
491
- yield data
492
- headers = {
493
- "Content-Range": f"bytes {start}-{end}/{file_size}",
494
- "Accept-Ranges": "bytes",
495
- "Content-Length": str(chunk_size),
496
- "Content-Type": mime_type
497
- }
498
- return StreamingResponse(iter_file(), status_code=206, headers=headers)
499
- else:
500
- return FileResponse(path, media_type=mime_type, filename=path.name)
501
-
502
-
503
- @app.get("/download/{name}")
504
- async def download(name: str):
505
- filename = secure_filename(name)
506
- path = BASE_DIR / filename
507
- if not path.exists():
508
- raise HTTPException(status_code=404)
509
- return FileResponse(path, media_type="application/octet-stream", filename=path.name)
510
-
511
-
512
- @app.delete("/delete/{name}")
513
- async def delete(name: str):
514
- filename = secure_filename(name)
515
- path = BASE_DIR / filename
516
- if not path.exists():
517
- raise HTTPException(status_code=404)
518
- try:
519
- path.unlink()
520
- return JSONResponse({"status":"ok"})
521
- except Exception as e:
522
- raise HTTPException(status_code=500, detail=str(e))
523
-
524
-
525
- @app.post("/clear_tmp")
526
- async def clear_tmp():
527
- try:
528
- for f in BASE_DIR.iterdir():
529
- if f.is_file():
530
- f.unlink()
531
- elif f.is_dir():
532
- shutil.rmtree(f)
533
- return JSONResponse({"status":"ok"})
534
- except Exception as e:
535
- raise HTTPException(status_code=500, detail=str(e))
536
-
537
-
538
- # ---------- Run ----------
539
- # Use uvicorn externally: uvicorn app:app --host 0.0.0.0 --port 7860
540
- if __name__ == "__main__":
541
- import uvicorn
542
- uvicorn.run("app:app", host="0.0.0.0", port=7860, reload=False)
 
 
1
  import os
2
  import re
 
3
  import aiofiles
 
 
 
4
  from pathlib import Path
5
+ from fastapi import FastAPI, UploadFile, Request
6
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
7
+ from fastapi.staticfiles import StaticFiles
8
+ import mimetypes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ app = FastAPI()
 
 
 
 
 
 
 
11
 
12
+ VIDEO_DIR = Path("/tmp/videos")
13
+ VIDEO_DIR.mkdir(parents=True, exist_ok=True)
14
 
15
+ app.mount("/videos", StaticFiles(directory=VIDEO_DIR), name="videos")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
 
18
+ def safe_filename(filename: str) -> str:
19
+ return re.sub(r"[^a-zA-Z0-9._-]", "_", filename)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
 
 
22
  @app.get("/", response_class=HTMLResponse)
23
  async def index():
24
+ return HTMLResponse(f"""
25
+ <!DOCTYPE html>
26
+ <html>
27
+ <head>
28
+ <title>FastAPI Video Player</title>
29
+ <style>
30
+ body {{ font-family: Arial, sans-serif; background: #121212; color: white; text-align: center; }}
31
+ .upload-section {{ margin: 20px; }}
32
+ .progress-container {{ width: 60%; margin: auto; background: #333; border-radius: 10px; }}
33
+ .progress-bar {{ width: 0%; height: 20px; background: lime; border-radius: 10px; }}
34
+ video {{ width: 80%; margin-top: 20px; border: 2px solid #444; border-radius: 8px; }}
35
+ .controls button {{ margin: 5px; padding: 10px; }}
36
+ ul {{ list-style: none; padding: 0; }}
37
+ li {{ margin: 5px; }}
38
+ a {{ color: #0af; text-decoration: none; }}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <h1>🎥 FastAPI Video Player</h1>
43
+ <div class="upload-section">
44
+ <input type="file" id="fileInput" />
45
+ <button onclick="uploadFile()">Upload</button>
46
+ <div class="progress-container">
47
+ <div class="progress-bar" id="progressBar"></div>
48
+ </div>
49
+ <p id="status"></p>
50
+ </div>
51
+ <h2>Available Videos</h2>
52
+ <ul id="videoList"></ul>
53
+ <video id="player" controls></video>
54
+ <div class="controls">
55
+ <button onclick="document.getElementById('player').play()">▶ Play</button>
56
+ <button onclick="document.getElementById('player').pause()">⏸ Pause</button>
57
+ <button onclick="document.getElementById('player').muted=!document.getElementById('player').muted">🔇 Mute/Unmute</button>
58
+ <button onclick="document.getElementById('player').playbackRate-=0.25">⏪ Slower</button>
59
+ <button onclick="document.getElementById('player').playbackRate+=0.25">⏩ Faster</button>
60
+ </div>
61
+ <script>
62
+ async function fetchList() {{
63
+ const res = await fetch('/list');
64
+ const data = await res.json();
65
+ const list = document.getElementById('videoList');
66
+ list.innerHTML = '';
67
+ data.forEach(file => {{
68
+ const li = document.createElement('li');
69
+ const a = document.createElement('a');
70
+ a.href = '#';
71
+ a.textContent = file;
72
+ a.onclick = () => {{
73
+ document.getElementById('player').src = '/videos/' + file;
74
+ document.getElementById('player').play();
75
+ }};
76
+ li.appendChild(a);
77
+ list.appendChild(li);
78
+ }});
79
+ }}
80
+
81
+ async function uploadFile() {{
82
+ const fileInput = document.getElementById('fileInput');
83
+ if (!fileInput.files.length) return alert("Select a file first!");
84
+ const file = fileInput.files[0];
85
+ const formData = new FormData();
86
+ formData.append("file", file);
87
+
88
+ const xhr = new XMLHttpRequest();
89
+ xhr.open("POST", "/upload", true);
90
+
91
+ xhr.upload.onprogress = function(event) {{
92
+ if (event.lengthComputable) {{
93
+ let percent = (event.loaded / event.total) * 100;
94
+ document.getElementById("progressBar").style.width = percent + "%";
95
+ document.getElementById("status").innerText = "Uploading " + Math.round(percent) + "%";
96
+ }}
97
+ }};
98
+
99
+ xhr.onload = function() {{
100
+ if (xhr.status === 200) {{
101
+ document.getElementById("status").innerText = " Upload complete";
102
+ fetchList();
103
+ }} else {{
104
+ document.getElementById("status").innerText = "❌ Error: " + xhr.responseText;
105
+ }}
106
+ }};
107
+
108
+ xhr.send(formData);
109
+ }}
110
+
111
+ fetchList();
112
+ </script>
113
+ </body>
114
+ </html>
115
+ """)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
 
117
 
118
  @app.post("/upload")
119
+ async def upload(file: UploadFile):
120
+ filename = safe_filename(file.filename)
121
+ final_path = VIDEO_DIR / filename
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  try:
123
+ async with aiofiles.open(final_path, "wb") as out_file:
124
+ while chunk := await file.read(1024 * 1024):
125
+ await out_file.write(chunk)
 
 
 
 
 
126
  except Exception as e:
127
+ return JSONResponse({"error": str(e)}, status_code=500)
128
+ return {"filename": filename}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
 
131
  @app.get("/list")
132
+ async def list_videos():
133
+ files = [f.name for f in VIDEO_DIR.iterdir() if f.is_file()]
134
+ return files
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
 
 
 
136
 
137
+ @app.get("/videos/{filename}")
138
+ async def serve_video(filename: str):
139
+ file_path = VIDEO_DIR / filename
140
+ if not file_path.exists():
141
+ return JSONResponse({"error": "File not found"}, status_code=404)
142
+ mime_type, _ = mimetypes.guess_type(str(file_path))
 
 
 
 
 
 
 
 
 
143
  if mime_type is None:
144
+ mime_type = "video/mp4"
145
+ return FileResponse(file_path, media_type=mime_type)