factorstudios commited on
Commit
17a3f15
·
verified ·
1 Parent(s): 4445143

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +80 -16
app.py CHANGED
@@ -7,17 +7,20 @@ Run with:
7
  from __future__ import annotations
8
 
9
  import asyncio
 
 
10
  import json
 
11
  import re
12
  import tempfile
13
  from pathlib import Path
14
  from typing import Final
15
- from urllib.parse import urlparse
16
  from uuid import uuid4
17
 
18
  from fastapi import FastAPI, Form
19
- from fastapi.responses import FileResponse, HTMLResponse
20
- from starlette.background import BackgroundTask
21
  from playwright.async_api import Error as PlaywrightError
22
  from playwright.async_api import TimeoutError as PlaywrightTimeoutError
23
  from playwright.async_api import async_playwright
@@ -28,6 +31,10 @@ RESULT_PAGE_TIMEOUT_MS: Final = 90_000
28
  MEDIA_TIMEOUT_MS: Final = 90_000
29
  SAVE_TT_ATTEMPTS: Final = 2
30
  TMP_DIR: Final = Path(tempfile.gettempdir()) / "savett_fastapi_downloads"
 
 
 
 
31
 
32
  app = FastAPI(title="TikTok MP4 Downloader", docs_url=None, redoc_url=None)
33
  # The downloader uses a full Chromium browser for each request. Serializing jobs
@@ -35,10 +42,27 @@ app = FastAPI(title="TikTok MP4 Downloader", docs_url=None, redoc_url=None)
35
  download_lock = asyncio.Lock()
36
 
37
 
38
- def render_home(error: str | None = None) -> str:
 
 
 
 
 
 
 
 
39
  error_markup = ""
 
40
  if error:
41
- error_markup = f'<div class="notice error" role="alert">{error}</div>'
 
 
 
 
 
 
 
 
42
 
43
  return f"""<!doctype html>
44
  <html lang="en">
@@ -86,6 +110,9 @@ def render_home(error: str | None = None) -> str:
86
  button:active {{ transform: translateY(1px); }}
87
  .notice {{ margin: 0 38px; padding: 12px 14px; border-radius: 10px; font-size: .92rem; line-height: 1.4; }}
88
  .error {{ background: #fff0f0; border: 1px solid #f3c2c2; color: #9a2424; }}
 
 
 
89
  .footer {{ margin: 0; padding: 6px 38px 34px; color: #697488; font-size: .82rem; line-height: 1.55; }}
90
  .loading {{ display: none; margin-top: 12px; color: #526379; font-size: .9rem; text-align: center; }}
91
  form.is-submitting button {{ opacity: .75; cursor: wait; }}
@@ -98,16 +125,17 @@ def render_home(error: str | None = None) -> str:
98
  <header class="header">
99
  <p class="eyebrow">Local FastAPI utility</p>
100
  <h1 id="page-title">Download a TikTok MP4</h1>
101
- <p class="subhead">Paste a public TikTok video link. The server fetches the MP4 and your browser saves the completed file.</p>
102
  </header>
103
  {error_markup}
 
104
  <form id="download-form" action="/download" method="post">
105
  <label for="tiktok-url">TikTok video URL</label>
106
  <input id="tiktok-url" name="tiktok_url" type="url" required autocomplete="off" placeholder="https://www.tiktok.com/@creator/video/...">
107
- <button type="submit">Download MP4</button>
108
- <p class="loading" aria-live="polite">Processing the link and preparing your download. This can take up to a minute.</p>
109
  </form>
110
- <p class="footer">Use this utility only to download videos that you own or are otherwise authorized to save. Download links returned by the source service are temporary.</p>
111
  </section>
112
  </main>
113
  <script>
@@ -201,6 +229,35 @@ async def wait_for_mp4_payload(page, tiktok_url: str) -> str:
201
  )
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  async def fetch_mp4(tiktok_url: str) -> tuple[Path, str]:
205
  """Use Playwright to get SaveTT's MP4 URLs and store the first usable file."""
206
  TMP_DIR.mkdir(parents=True, exist_ok=True)
@@ -281,15 +338,22 @@ async def download(tiktok_url: str = Form(...)):
281
  except ValueError as exc:
282
  return HTMLResponse(render_home(str(exc)), status_code=400)
283
 
 
284
  try:
285
  async with download_lock:
286
- file_path, download_name = await fetch_mp4(validated_url)
 
 
 
 
 
 
 
 
287
  except Exception as exc:
288
  return HTMLResponse(render_home(f"Download failed: {exc}"), status_code=502)
 
 
 
289
 
290
- return FileResponse(
291
- file_path,
292
- media_type="video/mp4",
293
- filename=download_name,
294
- background=BackgroundTask(remove_file, file_path),
295
- )
 
7
  from __future__ import annotations
8
 
9
  import asyncio
10
+ from datetime import datetime, timezone
11
+ from html import escape
12
  import json
13
+ import os
14
  import re
15
  import tempfile
16
  from pathlib import Path
17
  from typing import Final
18
+ from urllib.parse import quote, urlparse
19
  from uuid import uuid4
20
 
21
  from fastapi import FastAPI, Form
22
+ from fastapi.responses import HTMLResponse
23
+ from huggingface_hub import HfApi
24
  from playwright.async_api import Error as PlaywrightError
25
  from playwright.async_api import TimeoutError as PlaywrightTimeoutError
26
  from playwright.async_api import async_playwright
 
31
  MEDIA_TIMEOUT_MS: Final = 90_000
32
  SAVE_TT_ATTEMPTS: Final = 2
33
  TMP_DIR: Final = Path(tempfile.gettempdir()) / "savett_fastapi_downloads"
34
+ HF_TOKEN_ENV: Final = "HF_TOKEN"
35
+ HF_DATASET_REPO_ID: Final = "Elias2211/cont"
36
+ HF_DATASET_DIRECTORY: Final = "v1"
37
+ HF_DATASET_FILE_BASE_URL: Final = f"https://huggingface.co/datasets/{HF_DATASET_REPO_ID}/blob/main"
38
 
39
  app = FastAPI(title="TikTok MP4 Downloader", docs_url=None, redoc_url=None)
40
  # The downloader uses a full Chromium browser for each request. Serializing jobs
 
42
  download_lock = asyncio.Lock()
43
 
44
 
45
+ class HuggingFaceUploadError(RuntimeError):
46
+ """Raised for non-secret-bearing Hugging Face upload failures."""
47
+
48
+
49
+ class HuggingFaceConfigurationError(HuggingFaceUploadError):
50
+ """Raised when the server was deployed without its required HF_TOKEN secret."""
51
+
52
+
53
+ def render_home(error: str | None = None, success: tuple[str, str] | None = None) -> str:
54
  error_markup = ""
55
+ success_markup = ""
56
  if error:
57
+ error_markup = f'<div class="notice error" role="alert">{escape(error)}</div>'
58
+ if success:
59
+ remote_path, dataset_url = success
60
+ success_markup = (
61
+ '<div class="notice success" role="status">'
62
+ f'Upload complete: <code>{escape(remote_path)}</code>. '
63
+ f'<a href="{escape(dataset_url, quote=True)}" target="_blank" rel="noreferrer">Open in dataset</a>.'
64
+ '</div>'
65
+ )
66
 
67
  return f"""<!doctype html>
68
  <html lang="en">
 
110
  button:active {{ transform: translateY(1px); }}
111
  .notice {{ margin: 0 38px; padding: 12px 14px; border-radius: 10px; font-size: .92rem; line-height: 1.4; }}
112
  .error {{ background: #fff0f0; border: 1px solid #f3c2c2; color: #9a2424; }}
113
+ .success {{ background: #edf9f1; border: 1px solid #b9e2c5; color: #17683a; }}
114
+ .success a {{ color: inherit; font-weight: 700; }}
115
+ code {{ overflow-wrap: anywhere; }}
116
  .footer {{ margin: 0; padding: 6px 38px 34px; color: #697488; font-size: .82rem; line-height: 1.55; }}
117
  .loading {{ display: none; margin-top: 12px; color: #526379; font-size: .9rem; text-align: center; }}
118
  form.is-submitting button {{ opacity: .75; cursor: wait; }}
 
125
  <header class="header">
126
  <p class="eyebrow">Local FastAPI utility</p>
127
  <h1 id="page-title">Download a TikTok MP4</h1>
128
+ <p class="subhead">Paste a public TikTok video link. The server retrieves the MP4 and uploads it to the configured Hugging Face dataset.</p>
129
  </header>
130
  {error_markup}
131
+ {success_markup}
132
  <form id="download-form" action="/download" method="post">
133
  <label for="tiktok-url">TikTok video URL</label>
134
  <input id="tiktok-url" name="tiktok_url" type="url" required autocomplete="off" placeholder="https://www.tiktok.com/@creator/video/...">
135
+ <button type="submit">Upload MP4 to Dataset</button>
136
+ <p class="loading" aria-live="polite">Downloading the video and uploading it to the dataset. This can take a few minutes.</p>
137
  </form>
138
+ <p class="footer">Use this utility only to archive videos that you own or are otherwise authorized to save. Temporary local files are removed after the dataset upload completes.</p>
139
  </section>
140
  </main>
141
  <script>
 
229
  )
230
 
231
 
232
+ def upload_mp4_to_dataset(local_path: Path, video_id: str) -> tuple[str, str]:
233
+ """Upload a local MP4 to the configured dataset without persisting its token."""
234
+ token = os.getenv(HF_TOKEN_ENV)
235
+ if not token:
236
+ raise HuggingFaceConfigurationError(
237
+ f"{HF_TOKEN_ENV} is not configured in the server environment. Add a write token as a deployment secret."
238
+ )
239
+
240
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
241
+ remote_path = f"{HF_DATASET_DIRECTORY}/{timestamp}_{video_id}_{uuid4().hex[:8]}.mp4"
242
+ try:
243
+ api = HfApi(token=token, library_name="savett_fastapi_uploader")
244
+ api.upload_file(
245
+ path_or_fileobj=str(local_path),
246
+ path_in_repo=remote_path,
247
+ repo_id=HF_DATASET_REPO_ID,
248
+ repo_type="dataset",
249
+ commit_message=f"Add TikTok video {video_id}",
250
+ )
251
+ except Exception as exc:
252
+ raise HuggingFaceUploadError(
253
+ "Hugging Face rejected or interrupted the dataset upload. "
254
+ "Check that HF_TOKEN has write access to Elias2211/cont."
255
+ ) from exc
256
+
257
+ dataset_url = f"{HF_DATASET_FILE_BASE_URL}/{quote(remote_path, safe='/')}"
258
+ return remote_path, dataset_url
259
+
260
+
261
  async def fetch_mp4(tiktok_url: str) -> tuple[Path, str]:
262
  """Use Playwright to get SaveTT's MP4 URLs and store the first usable file."""
263
  TMP_DIR.mkdir(parents=True, exist_ok=True)
 
338
  except ValueError as exc:
339
  return HTMLResponse(render_home(str(exc)), status_code=400)
340
 
341
+ file_path: Path | None = None
342
  try:
343
  async with download_lock:
344
+ file_path, _download_name = await fetch_mp4(validated_url)
345
+ video_id = video_id_from_url(validated_url)
346
+ remote_path, dataset_url = await asyncio.to_thread(
347
+ upload_mp4_to_dataset, file_path, video_id
348
+ )
349
+ except HuggingFaceConfigurationError as exc:
350
+ return HTMLResponse(render_home(f"Server configuration error: {exc}"), status_code=503)
351
+ except HuggingFaceUploadError as exc:
352
+ return HTMLResponse(render_home(f"Upload failed: {exc}"), status_code=502)
353
  except Exception as exc:
354
  return HTMLResponse(render_home(f"Download failed: {exc}"), status_code=502)
355
+ finally:
356
+ if file_path is not None:
357
+ remove_file(file_path)
358
 
359
+ return HTMLResponse(render_home(success=(remote_path, dataset_url)))