understanding commited on
Commit
94d6896
·
verified ·
1 Parent(s): 3be0cd3

Delete app

Browse files
Files changed (7) hide show
  1. app/cf_api.py +0 -109
  2. app/config.py +0 -38
  3. app/main.py +0 -38
  4. app/progress.py +0 -58
  5. app/settings.py +0 -24
  6. app/state.py +0 -38
  7. 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()