understanding commited on
Commit
d6d7161
·
verified ·
1 Parent(s): b16da09

Create app/youtube_api.py

Browse files
Files changed (1) hide show
  1. app/youtube_api.py +99 -0
app/youtube_api.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import os
3
+ import json
4
+ import httpx
5
+ from typing import Optional, Callable, Dict, Any
6
+ from app.progress import RateProgress, human_bytes, fmt_eta
7
+
8
+ YT_INIT_URL = "https://www.googleapis.com/upload/youtube/v3/videos"
9
+ YT_PARTS = "snippet,status"
10
+
11
+ def _clean_title(s: str) -> str:
12
+ s = (s or "").strip()
13
+ if not s:
14
+ return "Untitled"
15
+ # YouTube title limit is 100 chars
16
+ return s[:100]
17
+
18
+ async def youtube_resumable_upload(
19
+ access_token: str,
20
+ file_path: str,
21
+ title: str,
22
+ description: str,
23
+ privacy: str,
24
+ chunk_size: int,
25
+ progress_cb: Optional[Callable[[int, int, float, float], Any]] = None,
26
+ http: Optional[httpx.AsyncClient] = None,
27
+ ) -> Dict[str, Any]:
28
+ """
29
+ progress_cb(done, total, speed_bps, eta_sec)
30
+ """
31
+ total = os.path.getsize(file_path)
32
+ title = _clean_title(title)
33
+ description = (description or "").strip()
34
+ privacy = privacy if privacy in ("private", "unlisted", "public") else "private"
35
+
36
+ close_http = False
37
+ if http is None:
38
+ http = httpx.AsyncClient(timeout=None)
39
+ close_http = True
40
+
41
+ try:
42
+ # 1) Initiate resumable session
43
+ meta = {
44
+ "snippet": {"title": title, "description": description},
45
+ "status": {"privacyStatus": privacy},
46
+ }
47
+ init_headers = {
48
+ "Authorization": f"Bearer {access_token}",
49
+ "Content-Type": "application/json; charset=UTF-8",
50
+ "X-Upload-Content-Type": "video/*",
51
+ "X-Upload-Content-Length": str(total),
52
+ }
53
+ init_params = {"uploadType": "resumable", "part": YT_PARTS}
54
+ init_resp = await http.post(YT_INIT_URL, headers=init_headers, params=init_params, content=json.dumps(meta))
55
+ if init_resp.status_code not in (200, 201):
56
+ return {"ok": False, "err": f"init_failed:{init_resp.status_code}:{init_resp.text[:200]}"}
57
+
58
+ upload_url = init_resp.headers.get("Location")
59
+ if not upload_url:
60
+ return {"ok": False, "err": "no_upload_location"}
61
+
62
+ # 2) Upload chunks
63
+ rp = RateProgress(total=total, edit_every=0.0)
64
+ done = 0
65
+
66
+ with open(file_path, "rb") as f:
67
+ while done < total:
68
+ chunk = f.read(chunk_size)
69
+ if not chunk:
70
+ break
71
+ start = done
72
+ end = done + len(chunk) - 1
73
+ headers = {
74
+ "Authorization": f"Bearer {access_token}",
75
+ "Content-Length": str(len(chunk)),
76
+ "Content-Range": f"bytes {start}-{end}/{total}",
77
+ }
78
+
79
+ put_resp = await http.put(upload_url, headers=headers, content=chunk)
80
+
81
+ # 308 = Resume Incomplete
82
+ if put_resp.status_code == 308:
83
+ done = end + 1
84
+ elif put_resp.status_code in (200, 201):
85
+ # finished
86
+ j = put_resp.json()
87
+ vid = j.get("id")
88
+ return {"ok": True, "video_id": vid, "raw": j}
89
+ else:
90
+ return {"ok": False, "err": f"upload_failed:{put_resp.status_code}:{put_resp.text[:200]}"}
91
+
92
+ snap = rp.snapshot(done)
93
+ if progress_cb:
94
+ progress_cb(done, total, snap["speed"], snap["eta"])
95
+
96
+ return {"ok": False, "err": "upload_incomplete"}
97
+ finally:
98
+ if close_http:
99
+ await http.aclose()