Spaces:
Runtime error
Runtime error
Delete app
Browse files- app/cf_api.py +0 -109
- app/config.py +0 -38
- app/main.py +0 -38
- app/progress.py +0 -58
- app/settings.py +0 -24
- app/state.py +0 -38
- app/youtube_api.py +0 -99
app/cf_api.py
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
import httpx
|
| 3 |
-
from typing import Any, Dict, Optional
|
| 4 |
-
|
| 5 |
-
class CFClient:
|
| 6 |
-
def __init__(self, worker1_url: str, worker2_url: str, bot_backend_key: str, hf_api_key: str):
|
| 7 |
-
self.w1 = worker1_url.rstrip("/")
|
| 8 |
-
self.w2 = worker2_url.rstrip("/")
|
| 9 |
-
self.bot_backend_key = bot_backend_key
|
| 10 |
-
self.hf_api_key = hf_api_key
|
| 11 |
-
self._client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, read=120.0))
|
| 12 |
-
|
| 13 |
-
async def close(self):
|
| 14 |
-
await self._client.aclose()
|
| 15 |
-
|
| 16 |
-
# ---------- Worker2 (HF-only) ----------
|
| 17 |
-
async def is_allowed(self, tg_id: str) -> bool:
|
| 18 |
-
r = await self._client.post(
|
| 19 |
-
f"{self.w2}/api/is_allowed",
|
| 20 |
-
headers={"Authorization": f"Bearer {self.hf_api_key}"},
|
| 21 |
-
json={"tg_id": tg_id},
|
| 22 |
-
)
|
| 23 |
-
if r.status_code != 200:
|
| 24 |
-
return False
|
| 25 |
-
j = r.json()
|
| 26 |
-
return bool(j.get("allowed", False))
|
| 27 |
-
|
| 28 |
-
async def allow_user(self, tg_id: str) -> Dict[str, Any]:
|
| 29 |
-
return await self._post_w2("/api/allow_user", {"tg_id": tg_id})
|
| 30 |
-
|
| 31 |
-
async def disallow_user(self, tg_id: str) -> Dict[str, Any]:
|
| 32 |
-
return await self._post_w2("/api/disallow_user", {"tg_id": tg_id})
|
| 33 |
-
|
| 34 |
-
async def list_profiles_w2(self, tg_id: str) -> Dict[str, Any]:
|
| 35 |
-
return await self._post_w2("/api/list_profiles", {"tg_id": tg_id})
|
| 36 |
-
|
| 37 |
-
async def pick_profile(self, tg_id: str, channel_id: str, rotate_after: int) -> Dict[str, Any]:
|
| 38 |
-
return await self._post_w2("/api/pick_profile", {"tg_id": tg_id, "channel_id": channel_id, "rotate_after": rotate_after})
|
| 39 |
-
|
| 40 |
-
async def access_token(self, tg_id: str, profile_id: str) -> Dict[str, Any]:
|
| 41 |
-
return await self._post_w2("/api/access_token", {"tg_id": tg_id, "profile_id": profile_id})
|
| 42 |
-
|
| 43 |
-
async def record_upload(self, tg_id: str, profile_id: str) -> Dict[str, Any]:
|
| 44 |
-
return await self._post_w2("/api/record_upload", {"tg_id": tg_id, "profile_id": profile_id})
|
| 45 |
-
|
| 46 |
-
async def stats_today(self) -> Dict[str, Any]:
|
| 47 |
-
return await self._post_w2("/api/stats_today", {})
|
| 48 |
-
|
| 49 |
-
async def log_error(self, tg_id: str, profile_id: str, where: str, err: str) -> None:
|
| 50 |
-
try:
|
| 51 |
-
await self._post_w2("/api/log_error", {"tg_id": tg_id, "profile_id": profile_id, "where": where, "err": err})
|
| 52 |
-
except Exception:
|
| 53 |
-
pass
|
| 54 |
-
|
| 55 |
-
async def _post_w2(self, path: str, body: Dict[str, Any]) -> Dict[str, Any]:
|
| 56 |
-
r = await self._client.post(
|
| 57 |
-
f"{self.w2}{path}",
|
| 58 |
-
headers={"Authorization": f"Bearer {self.hf_api_key}"},
|
| 59 |
-
json=body,
|
| 60 |
-
)
|
| 61 |
-
try:
|
| 62 |
-
return r.json()
|
| 63 |
-
except Exception:
|
| 64 |
-
return {"ok": False, "err": f"bad_json_{r.status_code}"}
|
| 65 |
-
|
| 66 |
-
# ---------- Worker1 (BOT backend key) ----------
|
| 67 |
-
async def profile_add(self, tg_id: str, client_id: str, client_secret: str, label: str = "main", ttl_sec: int = 600) -> Dict[str, Any]:
|
| 68 |
-
r = await self._client.post(
|
| 69 |
-
f"{self.w1}/api/profile/add",
|
| 70 |
-
headers={"Authorization": f"Bearer {self.bot_backend_key}"},
|
| 71 |
-
json={"tg_id": tg_id, "client_id": client_id, "client_secret": client_secret, "label": label, "ttl_sec": ttl_sec},
|
| 72 |
-
)
|
| 73 |
-
try:
|
| 74 |
-
return r.json()
|
| 75 |
-
except Exception:
|
| 76 |
-
return {"ok": False, "err": f"bad_json_{r.status_code}"}
|
| 77 |
-
|
| 78 |
-
async def profile_list_w1(self, tg_id: str) -> Dict[str, Any]:
|
| 79 |
-
r = await self._client.post(
|
| 80 |
-
f"{self.w1}/api/profile/list",
|
| 81 |
-
headers={"Authorization": f"Bearer {self.bot_backend_key}"},
|
| 82 |
-
json={"tg_id": tg_id},
|
| 83 |
-
)
|
| 84 |
-
try:
|
| 85 |
-
return r.json()
|
| 86 |
-
except Exception:
|
| 87 |
-
return {"ok": False, "err": f"bad_json_{r.status_code}"}
|
| 88 |
-
|
| 89 |
-
async def profile_set_default(self, tg_id: str, profile_id: str) -> Dict[str, Any]:
|
| 90 |
-
r = await self._client.post(
|
| 91 |
-
f"{self.w1}/api/profile/set_default",
|
| 92 |
-
headers={"Authorization": f"Bearer {self.bot_backend_key}"},
|
| 93 |
-
json={"tg_id": tg_id, "profile_id": profile_id},
|
| 94 |
-
)
|
| 95 |
-
try:
|
| 96 |
-
return r.json()
|
| 97 |
-
except Exception:
|
| 98 |
-
return {"ok": False, "err": f"bad_json_{r.status_code}"}
|
| 99 |
-
|
| 100 |
-
async def profile_remove(self, tg_id: str, profile_id: str) -> Dict[str, Any]:
|
| 101 |
-
r = await self._client.post(
|
| 102 |
-
f"{self.w1}/api/profile/remove",
|
| 103 |
-
headers={"Authorization": f"Bearer {self.bot_backend_key}"},
|
| 104 |
-
json={"tg_id": tg_id, "profile_id": profile_id},
|
| 105 |
-
)
|
| 106 |
-
try:
|
| 107 |
-
return r.json()
|
| 108 |
-
except Exception:
|
| 109 |
-
return {"ok": False, "err": f"bad_json_{r.status_code}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/config.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from dataclasses import dataclass
|
| 3 |
-
|
| 4 |
-
@dataclass(frozen=True)
|
| 5 |
-
class Config:
|
| 6 |
-
# Telegram
|
| 7 |
-
BOT_TOKEN: str
|
| 8 |
-
API_ID: int
|
| 9 |
-
API_HASH: str
|
| 10 |
-
OWNER_ID: int
|
| 11 |
-
|
| 12 |
-
# Workers
|
| 13 |
-
WORKER1_URL: str
|
| 14 |
-
WORKER2_URL: str
|
| 15 |
-
BOT_BACKEND_KEY: str
|
| 16 |
-
HF_API_KEY: str
|
| 17 |
-
|
| 18 |
-
# Optional
|
| 19 |
-
BOT_USERNAME: str = ""
|
| 20 |
-
|
| 21 |
-
def _must(name: str) -> str:
|
| 22 |
-
v = os.getenv(name, "").strip()
|
| 23 |
-
if not v:
|
| 24 |
-
raise RuntimeError(f"Missing env: {name}")
|
| 25 |
-
return v
|
| 26 |
-
|
| 27 |
-
def load_config() -> Config:
|
| 28 |
-
return Config(
|
| 29 |
-
BOT_TOKEN=_must("BOT_TOKEN"),
|
| 30 |
-
API_ID=int(_must("API_ID")),
|
| 31 |
-
API_HASH=_must("API_HASH"),
|
| 32 |
-
OWNER_ID=int(_must("OWNER_ID")),
|
| 33 |
-
WORKER1_URL=_must("WORKER1_URL").rstrip("/"),
|
| 34 |
-
WORKER2_URL=_must("WORKER2_URL").rstrip("/"),
|
| 35 |
-
BOT_BACKEND_KEY=_must("BOT_BACKEND_KEY"),
|
| 36 |
-
HF_API_KEY=_must("HF_API_KEY"),
|
| 37 |
-
BOT_USERNAME=os.getenv("BOT_USERNAME", "").strip(),
|
| 38 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/main.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import asyncio
|
| 4 |
-
from contextlib import asynccontextmanager
|
| 5 |
-
from fastapi import FastAPI
|
| 6 |
-
|
| 7 |
-
from app.config import load_config
|
| 8 |
-
from app.cf_api import CFClient
|
| 9 |
-
from bot.client import build_bot
|
| 10 |
-
from bot.handlers import register_handlers
|
| 11 |
-
|
| 12 |
-
cfg = load_config()
|
| 13 |
-
cf = CFClient(cfg.WORKER1_URL, cfg.WORKER2_URL, cfg.BOT_BACKEND_KEY, cfg.HF_API_KEY)
|
| 14 |
-
bot = build_bot(cfg)
|
| 15 |
-
|
| 16 |
-
@asynccontextmanager
|
| 17 |
-
async def lifespan(app: FastAPI):
|
| 18 |
-
# start bot
|
| 19 |
-
await register_handlers(bot, cfg, cf)
|
| 20 |
-
await bot.start()
|
| 21 |
-
yield
|
| 22 |
-
# stop bot
|
| 23 |
-
await bot.stop()
|
| 24 |
-
await cf.close()
|
| 25 |
-
|
| 26 |
-
app = FastAPI(lifespan=lifespan)
|
| 27 |
-
|
| 28 |
-
@app.get("/")
|
| 29 |
-
async def root():
|
| 30 |
-
return {
|
| 31 |
-
"ok": True,
|
| 32 |
-
"service": "yt-uploader-bot",
|
| 33 |
-
"bot_username": cfg.BOT_USERNAME,
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
@app.get("/health")
|
| 37 |
-
async def health():
|
| 38 |
-
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/progress.py
DELETED
|
@@ -1,58 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
import time
|
| 3 |
-
|
| 4 |
-
def human_bytes(n: float) -> str:
|
| 5 |
-
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
| 6 |
-
if n < 1024:
|
| 7 |
-
return f"{n:.1f}{unit}"
|
| 8 |
-
n /= 1024
|
| 9 |
-
return f"{n:.1f}PB"
|
| 10 |
-
|
| 11 |
-
def fmt_eta(sec: float) -> str:
|
| 12 |
-
sec = max(0, int(sec))
|
| 13 |
-
if sec < 60:
|
| 14 |
-
return f"{sec}s"
|
| 15 |
-
m = sec // 60
|
| 16 |
-
s = sec % 60
|
| 17 |
-
if m < 60:
|
| 18 |
-
return f"{m}m {s}s"
|
| 19 |
-
h = m // 60
|
| 20 |
-
m = m % 60
|
| 21 |
-
return f"{h}h {m}m"
|
| 22 |
-
|
| 23 |
-
class RateProgress:
|
| 24 |
-
def __init__(self, total: int, edit_every: float = 2.5):
|
| 25 |
-
self.total = max(1, total)
|
| 26 |
-
self.edit_every = edit_every
|
| 27 |
-
self.t0 = time.time()
|
| 28 |
-
self.last_edit = 0.0
|
| 29 |
-
self.last_bytes = 0
|
| 30 |
-
self.last_t = self.t0
|
| 31 |
-
|
| 32 |
-
def snapshot(self, done: int) -> dict:
|
| 33 |
-
now = time.time()
|
| 34 |
-
done = max(0, min(done, self.total))
|
| 35 |
-
dt = max(1e-6, now - self.last_t)
|
| 36 |
-
dbytes = done - self.last_bytes
|
| 37 |
-
speed = dbytes / dt # B/s
|
| 38 |
-
elapsed = now - self.t0
|
| 39 |
-
pct = (done / self.total) * 100.0
|
| 40 |
-
remaining = self.total - done
|
| 41 |
-
eta = remaining / speed if speed > 1 else float("inf")
|
| 42 |
-
return {
|
| 43 |
-
"pct": pct,
|
| 44 |
-
"done": done,
|
| 45 |
-
"total": self.total,
|
| 46 |
-
"speed": speed,
|
| 47 |
-
"eta": eta,
|
| 48 |
-
"elapsed": elapsed,
|
| 49 |
-
"now": now
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
def should_edit(self, now: float) -> bool:
|
| 53 |
-
return (now - self.last_edit) >= self.edit_every
|
| 54 |
-
|
| 55 |
-
def mark_edit(self, done: int, now: float):
|
| 56 |
-
self.last_edit = now
|
| 57 |
-
self.last_bytes = done
|
| 58 |
-
self.last_t = now
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/settings.py
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
# Settings that you can edit safely WITHOUT touching .env
|
| 2 |
-
|
| 3 |
-
# Upload rotation: per profile per UTC day limit
|
| 4 |
-
ROTATE_AFTER_PER_PROFILE = 30 # change anytime
|
| 5 |
-
|
| 6 |
-
# Default upload privacy (bulk-friendly)
|
| 7 |
-
DEFAULT_PRIVACY = "private" # private | unlisted | public
|
| 8 |
-
|
| 9 |
-
# Default title mode for new videos
|
| 10 |
-
# "caption" (if exists else filename) | "filename"
|
| 11 |
-
DEFAULT_TITLE_MODE = "caption"
|
| 12 |
-
|
| 13 |
-
# YouTube resumable upload chunk size (bytes)
|
| 14 |
-
# 8MB is safe; bigger may speed up but more risk on unstable net
|
| 15 |
-
YOUTUBE_CHUNK_SIZE = 8 * 1024 * 1024
|
| 16 |
-
|
| 17 |
-
# Bot rate-limit for progress edits (seconds)
|
| 18 |
-
PROGRESS_EDIT_EVERY_SEC = 2.5
|
| 19 |
-
|
| 20 |
-
# Max concurrent uploads per bot (avoid HF overload)
|
| 21 |
-
MAX_CONCURRENT_UPLOADS = 1
|
| 22 |
-
|
| 23 |
-
# Local temp dir
|
| 24 |
-
TMP_DIR = "/tmp/ytbot"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/state.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
from dataclasses import dataclass, field
|
| 3 |
-
from typing import Dict, Optional, Any
|
| 4 |
-
import asyncio
|
| 5 |
-
import secrets
|
| 6 |
-
import time
|
| 7 |
-
|
| 8 |
-
@dataclass
|
| 9 |
-
class UploadJob:
|
| 10 |
-
upload_id: str
|
| 11 |
-
user_id: int
|
| 12 |
-
chat_id: int
|
| 13 |
-
src_msg_id: int
|
| 14 |
-
file_type: str # "video" | "document"
|
| 15 |
-
tg_file_id: str
|
| 16 |
-
file_name: str
|
| 17 |
-
caption: str
|
| 18 |
-
|
| 19 |
-
privacy: str = "private"
|
| 20 |
-
title_mode: str = "caption" # caption | filename | custom
|
| 21 |
-
custom_title: str = ""
|
| 22 |
-
|
| 23 |
-
status_msg_id: Optional[int] = None
|
| 24 |
-
created_at: float = field(default_factory=time.time)
|
| 25 |
-
|
| 26 |
-
class MemoryState:
|
| 27 |
-
def __init__(self):
|
| 28 |
-
self.uploads: Dict[str, UploadJob] = {}
|
| 29 |
-
self.waiting_client_id: Dict[int, bool] = {}
|
| 30 |
-
self.waiting_client_secret: Dict[int, str] = {} # user_id -> client_id
|
| 31 |
-
self.waiting_custom_title: Dict[int, str] = {} # user_id -> upload_id
|
| 32 |
-
self.auto_mode: Dict[int, bool] = {} # user_id -> bool
|
| 33 |
-
self.sem = asyncio.Semaphore(1) # overwritten by settings at runtime
|
| 34 |
-
|
| 35 |
-
def new_upload_id(self) -> str:
|
| 36 |
-
return secrets.token_urlsafe(8)[:10]
|
| 37 |
-
|
| 38 |
-
STATE = MemoryState()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/youtube_api.py
DELETED
|
@@ -1,99 +0,0 @@
|
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|