bilisum 1.13.2 → 1.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +4 -4
  2. package/bin/bilisum.js +157 -76
  3. package/package.json +7 -2
  4. package/runtime/VERSION +1 -0
  5. package/runtime/apps/service/pyproject.toml +29 -0
  6. package/runtime/apps/service/src/video_sum_service/__init__.py +1 -0
  7. package/runtime/apps/service/src/video_sum_service/__main__.py +5 -0
  8. package/runtime/apps/service/src/video_sum_service/app.py +480 -0
  9. package/runtime/apps/service/src/video_sum_service/context.py +24 -0
  10. package/runtime/apps/service/src/video_sum_service/integrations.py +264 -0
  11. package/runtime/apps/service/src/video_sum_service/knowledge/__init__.py +5 -0
  12. package/runtime/apps/service/src/video_sum_service/knowledge/index_service.py +408 -0
  13. package/runtime/apps/service/src/video_sum_service/knowledge/local_llm.py +319 -0
  14. package/runtime/apps/service/src/video_sum_service/knowledge/rag_service.py +492 -0
  15. package/runtime/apps/service/src/video_sum_service/knowledge/tag_service.py +242 -0
  16. package/runtime/apps/service/src/video_sum_service/main.py +31 -0
  17. package/runtime/apps/service/src/video_sum_service/repository.py +942 -0
  18. package/runtime/apps/service/src/video_sum_service/routers/__init__.py +1 -0
  19. package/runtime/apps/service/src/video_sum_service/routers/knowledge.py +272 -0
  20. package/runtime/apps/service/src/video_sum_service/routers/system.py +280 -0
  21. package/runtime/apps/service/src/video_sum_service/routers/tasks.py +287 -0
  22. package/runtime/apps/service/src/video_sum_service/routers/videos.py +766 -0
  23. package/runtime/apps/service/src/video_sum_service/runtime_support.py +1007 -0
  24. package/runtime/apps/service/src/video_sum_service/schemas.py +418 -0
  25. package/runtime/apps/service/src/video_sum_service/settings_manager.py +130 -0
  26. package/runtime/apps/service/src/video_sum_service/task_artifacts.py +102 -0
  27. package/runtime/apps/service/src/video_sum_service/task_exports.py +108 -0
  28. package/runtime/apps/service/src/video_sum_service/transcribe_worker.py +9 -0
  29. package/runtime/apps/service/src/video_sum_service/video_assets.py +623 -0
  30. package/runtime/apps/service/src/video_sum_service/worker.py +381 -0
  31. package/runtime/apps/web/static/apple-touch-icon.png +0 -0
  32. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  33. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  34. package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  35. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  36. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  37. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  38. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  39. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  40. package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  41. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  42. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  43. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  44. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  45. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  46. package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  47. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  48. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  49. package/runtime/apps/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  50. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  51. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  52. package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  53. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  54. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  55. package/runtime/apps/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  56. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  57. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  58. package/runtime/apps/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  59. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  60. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  61. package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  62. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  63. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  64. package/runtime/apps/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  65. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  66. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  67. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  68. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  69. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  70. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  71. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  72. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  73. package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  74. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  75. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  76. package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  77. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  78. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  79. package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  80. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  81. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  82. package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  83. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  84. package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  85. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  86. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  87. package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  88. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  89. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  90. package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  91. package/runtime/apps/web/static/assets/icons/icon-180.png +0 -0
  92. package/runtime/apps/web/static/assets/icons/icon-512.png +0 -0
  93. package/runtime/apps/web/static/assets/icons/icon.svg +17 -0
  94. package/runtime/apps/web/static/assets/index-Cj5PVjQg.js +388 -0
  95. package/runtime/apps/web/static/assets/index-DU2t4_7s.css +1 -0
  96. package/runtime/apps/web/static/favicon-32x32.png +0 -0
  97. package/runtime/apps/web/static/favicon.ico +0 -0
  98. package/runtime/apps/web/static/favicon.svg +17 -0
  99. package/runtime/apps/web/static/index.html +19 -0
  100. package/runtime/apps/web/static/js/api.js +123 -0
  101. package/runtime/apps/web/static/js/main.js +932 -0
  102. package/runtime/apps/web/static/js/state.js +28 -0
  103. package/runtime/apps/web/static/js/utils.js +81 -0
  104. package/runtime/apps/web/static/js/views/home.js +604 -0
  105. package/runtime/apps/web/static/js/views/settings.js +477 -0
  106. package/runtime/apps/web/static/styles.css +2206 -0
  107. package/runtime/packages/core/pyproject.toml +18 -0
  108. package/runtime/packages/core/src/video_sum_core/__init__.py +1 -0
  109. package/runtime/packages/core/src/video_sum_core/errors.py +22 -0
  110. package/runtime/packages/core/src/video_sum_core/markdown_exports.py +154 -0
  111. package/runtime/packages/core/src/video_sum_core/models/__init__.py +1 -0
  112. package/runtime/packages/core/src/video_sum_core/models/tasks.py +70 -0
  113. package/runtime/packages/core/src/video_sum_core/pipeline/__init__.py +1 -0
  114. package/runtime/packages/core/src/video_sum_core/pipeline/base.py +46 -0
  115. package/runtime/packages/core/src/video_sum_core/pipeline/real.py +3467 -0
  116. package/runtime/packages/core/src/video_sum_core/transcribe_subprocess.py +173 -0
  117. package/runtime/packages/core/src/video_sum_core/utils.py +127 -0
  118. package/runtime/packages/infra/pyproject.toml +16 -0
  119. package/runtime/packages/infra/src/video_sum_infra/__init__.py +1 -0
  120. package/runtime/packages/infra/src/video_sum_infra/app.py +39 -0
  121. package/runtime/packages/infra/src/video_sum_infra/config.py +393 -0
  122. package/runtime/packages/infra/src/video_sum_infra/db.py +34 -0
  123. package/runtime/packages/infra/src/video_sum_infra/logging.py +54 -0
  124. package/runtime/packages/infra/src/video_sum_infra/paths.py +6 -0
  125. package/runtime/packages/infra/src/video_sum_infra/runtime.py +485 -0
@@ -0,0 +1,623 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+ from urllib.parse import unquote, urlencode, urlparse
12
+
13
+ import httpx
14
+ from fastapi import HTTPException
15
+ from yt_dlp import YoutubeDL
16
+ from yt_dlp.utils import DownloadError
17
+
18
+ from video_sum_core.models.tasks import InputType
19
+ from video_sum_core.utils import extract_bilibili_page, normalize_video_url
20
+ from video_sum_infra.runtime import ffmpeg_location
21
+
22
+ from video_sum_service.context import COVER_CACHE_DIR, settings_manager
23
+ from video_sum_service.integrations import cache_cover_image
24
+ from video_sum_service.repository import SqliteTaskRepository
25
+ from video_sum_service.schemas import VideoAssetRecord, VideoPageOptionResponse
26
+
27
+ logger = logging.getLogger("video_sum_service.app")
28
+
29
+ _BILIBILI_HTTP_HEADERS = {
30
+ "User-Agent": (
31
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
32
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
33
+ "Chrome/124.0.0.0 Safari/537.36"
34
+ ),
35
+ "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
36
+ "Referer": "https://www.bilibili.com/",
37
+ }
38
+ LOCAL_VIDEO_SUFFIXES = {
39
+ ".mp4",
40
+ ".mov",
41
+ ".mkv",
42
+ ".avi",
43
+ ".wmv",
44
+ ".webm",
45
+ ".flv",
46
+ ".m4v",
47
+ ".ts",
48
+ ".mpeg",
49
+ ".mpg",
50
+ }
51
+ LOCAL_AUDIO_SUFFIXES = {
52
+ ".mp3",
53
+ ".wav",
54
+ ".m4a",
55
+ ".aac",
56
+ ".flac",
57
+ ".ogg",
58
+ ".opus",
59
+ ".wma",
60
+ }
61
+
62
+
63
+ def with_bilibili_page(url: str, page: int) -> str:
64
+ parsed = urlparse(url)
65
+ query_pairs: list[tuple[str, str]] = []
66
+ if parsed.query:
67
+ for chunk in parsed.query.split("&"):
68
+ if not chunk:
69
+ continue
70
+ key, _, value = chunk.partition("=")
71
+ if key != "p":
72
+ query_pairs.append((key, value))
73
+ query_pairs.append(("p", str(page)))
74
+ encoded_query = urlencode(query_pairs)
75
+ return parsed._replace(query=encoded_query).geturl()
76
+
77
+
78
+ def build_page_canonical_id(base_id: str, page: int) -> str:
79
+ return f"{base_id}?p={page}"
80
+
81
+
82
+ def extract_page_part_from_title(base_title: str, page: int, title: str) -> str:
83
+ candidate = str(title or "").strip()
84
+ if not candidate:
85
+ return ""
86
+
87
+ normalized_base = re.sub(r"\s+", " ", base_title).strip()
88
+ normalized_candidate = re.sub(r"\s+", " ", candidate).strip()
89
+ if normalized_base and normalized_candidate.startswith(normalized_base):
90
+ candidate = normalized_candidate[len(normalized_base):].strip(" -_")
91
+ else:
92
+ candidate = normalized_candidate
93
+
94
+ candidate = re.sub(rf"^[Pp]0*{page}\s*", "", candidate).strip(" -_")
95
+ candidate = re.sub(rf"^第?\s*0*{page}\s*[Pp]?\s*", "", candidate).strip(" -_")
96
+ return candidate
97
+
98
+
99
+ def build_page_title(base_title: str, page: int, info: dict[str, object]) -> str:
100
+ part = str(info.get("part") or "").strip()
101
+ title = str(info.get("title") or "").strip()
102
+ candidate = part or extract_page_part_from_title(base_title, page, title)
103
+ if not candidate:
104
+ return f"P{page}"
105
+ return f"P{page} {candidate}"
106
+
107
+
108
+ def build_ydl_probe_options(*, extract_flat: bool = False) -> dict[str, object]:
109
+ options: dict[str, object] = {
110
+ "quiet": True,
111
+ "no_warnings": True,
112
+ "http_headers": _BILIBILI_HTTP_HEADERS,
113
+ "retries": 3,
114
+ "extractor_retries": 3,
115
+ "fragment_retries": 3,
116
+ "sleep_interval_requests": 1,
117
+ "socket_timeout": 30,
118
+ }
119
+ cookie_file = str(
120
+ settings_manager.current.ytdlp_cookies_file
121
+ or os.environ.get("VIDEO_SUM_YTDLP_COOKIES_FILE")
122
+ or os.environ.get("YTDLP_COOKIES_FILE")
123
+ or ""
124
+ ).strip()
125
+ if cookie_file:
126
+ cookie_path = Path(cookie_file).expanduser()
127
+ if cookie_path.exists() and cookie_path.is_file():
128
+ options["cookiefile"] = str(cookie_path)
129
+ else:
130
+ logger.warning("yt-dlp cookie file does not exist: %s", cookie_path)
131
+ if extract_flat:
132
+ options["extract_flat"] = "in_playlist"
133
+ return options
134
+
135
+
136
+ def extract_video_info(url: str, *, extract_flat: bool = False) -> dict[str, object]:
137
+ options = build_ydl_probe_options(extract_flat=extract_flat)
138
+ try:
139
+ with YoutubeDL(options) as ydl:
140
+ info = ydl.extract_info(url, download=False)
141
+ except DownloadError as exc:
142
+ message = str(exc)
143
+ if "Could not copy Chrome cookie database" in message:
144
+ raise HTTPException(
145
+ status_code=400,
146
+ detail=(
147
+ "无法读取 Chrome 登录态:yt-dlp 不能复制 Chrome Cookie 数据库。"
148
+ "请通过 BiliSum 的 B 站登录窗口重新捕获登录态,或按教程导出 cookies.txt 并填写 yt-dlp Cookies 文件。"
149
+ ),
150
+ ) from exc
151
+ if "Failed to decrypt with DPAPI" in message:
152
+ raise HTTPException(
153
+ status_code=400,
154
+ detail=(
155
+ "无法读取浏览器登录态:yt-dlp 解密 Windows 浏览器 Cookie 失败(DPAPI)。"
156
+ "请通过 BiliSum 的 B 站登录窗口重新捕获登录态,或按教程导出 cookies.txt 并填写 yt-dlp Cookies 文件。"
157
+ ),
158
+ ) from exc
159
+ if "HTTP Error 412" in message and "BiliBili" in message:
160
+ raise HTTPException(
161
+ status_code=400,
162
+ detail=(
163
+ "B 站返回 HTTP 412,当前请求可能被风控拦截。"
164
+ "请稍后重试、切换网络,或通过 BiliSum 的 B 站登录窗口捕获登录态。"
165
+ ),
166
+ ) from exc
167
+ raise
168
+ if not isinstance(info, dict):
169
+ raise HTTPException(status_code=400, detail="无法读取视频信息。")
170
+ return info
171
+
172
+
173
+ def fetch_bilibili_page_catalog(url: str) -> list[dict[str, object]]:
174
+ try:
175
+ response = httpx.get(
176
+ url,
177
+ headers={
178
+ "User-Agent": "Mozilla/5.0",
179
+ "Referer": "https://www.bilibili.com/",
180
+ },
181
+ follow_redirects=True,
182
+ timeout=30.0,
183
+ )
184
+ response.raise_for_status()
185
+ except httpx.HTTPError:
186
+ logger.warning("failed to fetch bilibili page catalog: %s", url, exc_info=True)
187
+ return []
188
+
189
+ match = re.search(r"__INITIAL_STATE__\s*=\s*(\{.*?\})\s*;\s*\(function", response.text, re.S)
190
+ if not match:
191
+ return []
192
+
193
+ try:
194
+ payload = json.loads(match.group(1))
195
+ except json.JSONDecodeError:
196
+ logger.warning("failed to parse bilibili page catalog payload: %s", url, exc_info=True)
197
+ return []
198
+
199
+ video_data = payload.get("videoData") or {}
200
+ pages = video_data.get("pages") or []
201
+ return [item for item in pages if isinstance(item, dict)]
202
+
203
+
204
+ def build_base_video_asset(
205
+ *,
206
+ canonical_id: str,
207
+ platform: str,
208
+ title: str,
209
+ source_url: str,
210
+ cover_url: str,
211
+ duration: float | None,
212
+ pages: list[VideoPageOptionResponse],
213
+ ) -> VideoAssetRecord:
214
+ return VideoAssetRecord(
215
+ canonical_id=canonical_id,
216
+ platform=platform,
217
+ title=title,
218
+ source_url=source_url,
219
+ cover_url=cover_url,
220
+ duration=duration,
221
+ pages=pages,
222
+ )
223
+
224
+
225
+ def merge_video_asset_metadata(
226
+ existing: VideoAssetRecord,
227
+ refreshed: VideoAssetRecord,
228
+ ) -> VideoAssetRecord:
229
+ existing_pages = {page.page: page for page in existing.pages}
230
+ merged_pages: list[VideoPageOptionResponse] = []
231
+
232
+ if refreshed.pages:
233
+ for refreshed_page in refreshed.pages:
234
+ existing_page = existing_pages.get(refreshed_page.page)
235
+ if existing_page is None:
236
+ merged_pages.append(refreshed_page)
237
+ continue
238
+ merged_pages.append(
239
+ existing_page.model_copy(
240
+ update={
241
+ "title": refreshed_page.title or existing_page.title,
242
+ "source_url": refreshed_page.source_url or existing_page.source_url,
243
+ "cover_url": refreshed_page.cover_url or existing_page.cover_url,
244
+ "duration": refreshed_page.duration if refreshed_page.duration is not None else existing_page.duration,
245
+ }
246
+ )
247
+ )
248
+ refreshed_page_numbers = {page.page for page in refreshed.pages}
249
+ for existing_page in existing.pages:
250
+ if existing_page.page not in refreshed_page_numbers:
251
+ merged_pages.append(existing_page)
252
+ else:
253
+ merged_pages = list(existing.pages)
254
+
255
+ cover_url = refreshed.cover_url or existing.cover_url
256
+ if not cover_url and merged_pages:
257
+ cover_url = next((page.cover_url for page in merged_pages if page.cover_url), "")
258
+
259
+ return existing.model_copy(
260
+ update={
261
+ "platform": refreshed.platform or existing.platform,
262
+ "title": refreshed.title or existing.title,
263
+ "source_url": refreshed.source_url or existing.source_url,
264
+ "cover_url": cover_url,
265
+ "duration": refreshed.duration if refreshed.duration is not None else existing.duration,
266
+ "pages": merged_pages,
267
+ }
268
+ )
269
+
270
+
271
+ def resolve_local_media_path(value: str) -> Path | None:
272
+ raw = str(value or "").strip()
273
+ if not raw:
274
+ return None
275
+
276
+ candidate_text = raw
277
+ parsed = urlparse(raw)
278
+ if parsed.scheme.lower() == "file":
279
+ candidate_text = unquote(parsed.path or "")
280
+ if re.match(r"^/[A-Za-z]:/", candidate_text):
281
+ candidate_text = candidate_text.lstrip("/")
282
+
283
+ candidate = Path(candidate_text).expanduser()
284
+ try:
285
+ resolved = candidate.resolve()
286
+ except OSError:
287
+ resolved = candidate
288
+ if not resolved.exists() or not resolved.is_file():
289
+ return None
290
+ return resolved
291
+
292
+
293
+ def is_supported_local_media_file(path: Path) -> bool:
294
+ return path.suffix.lower() in LOCAL_VIDEO_SUFFIXES | LOCAL_AUDIO_SUFFIXES
295
+
296
+
297
+ def infer_local_input_type(source: str | Path) -> InputType:
298
+ path = source if isinstance(source, Path) else Path(str(source))
299
+ return InputType.AUDIO_FILE if path.suffix.lower() in LOCAL_AUDIO_SUFFIXES else InputType.VIDEO_FILE
300
+
301
+
302
+ def build_local_media_canonical_id(file_path: Path) -> str:
303
+ try:
304
+ stats = file_path.stat()
305
+ mtime_ns = getattr(stats, "st_mtime_ns", int(stats.st_mtime * 1_000_000_000))
306
+ fingerprint = f"{str(file_path).lower()}|{stats.st_size}|{mtime_ns}"
307
+ except OSError:
308
+ fingerprint = str(file_path).lower()
309
+ digest = hashlib.sha1(fingerprint.encode("utf-8")).hexdigest()[:24]
310
+ return f"local-{digest}"
311
+
312
+
313
+ def resolve_ffprobe_executable() -> Path | None:
314
+ ffmpeg_exe = ffmpeg_location()
315
+ if ffmpeg_exe is not None:
316
+ candidate = ffmpeg_exe.with_name("ffprobe.exe" if ffmpeg_exe.suffix.lower() == ".exe" else "ffprobe")
317
+ if candidate.exists():
318
+ return candidate
319
+
320
+ ffprobe_path = shutil.which("ffprobe")
321
+ if ffprobe_path:
322
+ return Path(ffprobe_path).resolve()
323
+ return None
324
+
325
+
326
+ def probe_local_media_duration(file_path: Path) -> float | None:
327
+ ffprobe_exe = resolve_ffprobe_executable()
328
+ if ffprobe_exe is None:
329
+ return None
330
+
331
+ try:
332
+ result = subprocess.run(
333
+ [
334
+ str(ffprobe_exe),
335
+ "-v",
336
+ "error",
337
+ "-show_entries",
338
+ "format=duration",
339
+ "-of",
340
+ "default=noprint_wrappers=1:nokey=1",
341
+ str(file_path),
342
+ ],
343
+ capture_output=True,
344
+ text=True,
345
+ encoding="utf-8",
346
+ errors="replace",
347
+ timeout=30,
348
+ check=False,
349
+ )
350
+ except (OSError, subprocess.SubprocessError):
351
+ logger.warning("failed to probe local media duration: %s", file_path, exc_info=True)
352
+ return None
353
+
354
+ if result.returncode != 0:
355
+ logger.warning("ffprobe duration failed path=%s stderr=%s", file_path, result.stderr.strip())
356
+ return None
357
+
358
+ try:
359
+ duration = float((result.stdout or "").strip())
360
+ except (TypeError, ValueError):
361
+ return None
362
+ return duration if duration >= 0 else None
363
+
364
+
365
+ def extract_local_video_cover(
366
+ file_path: Path,
367
+ canonical_id: str,
368
+ duration: float | None,
369
+ *,
370
+ force_refresh: bool = False,
371
+ ) -> str:
372
+ if file_path.suffix.lower() not in LOCAL_VIDEO_SUFFIXES:
373
+ return ""
374
+
375
+ ffmpeg_exe = ffmpeg_location()
376
+ if ffmpeg_exe is None:
377
+ return ""
378
+
379
+ COVER_CACHE_DIR.mkdir(parents=True, exist_ok=True)
380
+ target = COVER_CACHE_DIR / f"{canonical_id}.jpg"
381
+ if target.exists() and not force_refresh:
382
+ return f"/media/covers/{target.name}"
383
+ if force_refresh and target.exists():
384
+ try:
385
+ target.unlink()
386
+ except OSError:
387
+ logger.warning("failed to remove stale local cover: %s", target, exc_info=True)
388
+
389
+ timestamp_seconds = 0.0
390
+ if duration and duration > 1:
391
+ timestamp_seconds = min(max(duration * 0.25, 0.2), max(duration - 0.2, 0.0))
392
+
393
+ command = [str(ffmpeg_exe), "-y"]
394
+ if timestamp_seconds > 0:
395
+ command.extend(["-ss", f"{timestamp_seconds:.3f}"])
396
+ command.extend(["-i", str(file_path), "-frames:v", "1", "-q:v", "2", str(target)])
397
+
398
+ try:
399
+ result = subprocess.run(
400
+ command,
401
+ capture_output=True,
402
+ text=True,
403
+ encoding="utf-8",
404
+ errors="replace",
405
+ timeout=45,
406
+ check=False,
407
+ )
408
+ except (OSError, subprocess.SubprocessError):
409
+ logger.warning("failed to extract local video cover: %s", file_path, exc_info=True)
410
+ return ""
411
+
412
+ if result.returncode != 0 or not target.exists():
413
+ logger.warning(
414
+ "ffmpeg cover extraction failed path=%s returncode=%s stderr=%s",
415
+ file_path,
416
+ result.returncode,
417
+ result.stderr.strip(),
418
+ )
419
+ return ""
420
+
421
+ return f"/media/covers/{target.name}"
422
+
423
+
424
+ def probe_local_video_asset(
425
+ file_path: Path,
426
+ *,
427
+ force_refresh: bool = False,
428
+ title_override: str | None = None,
429
+ canonical_id_override: str | None = None,
430
+ ) -> VideoAssetRecord:
431
+ canonical_id = canonical_id_override or build_local_media_canonical_id(file_path)
432
+ duration = probe_local_media_duration(file_path)
433
+ cover_url = extract_local_video_cover(
434
+ file_path,
435
+ canonical_id,
436
+ duration,
437
+ force_refresh=force_refresh,
438
+ )
439
+ title = str(title_override or "").strip() or file_path.stem.strip() or file_path.name
440
+ return build_base_video_asset(
441
+ canonical_id=canonical_id,
442
+ platform="local",
443
+ title=title,
444
+ source_url=str(file_path),
445
+ cover_url=cover_url,
446
+ duration=duration,
447
+ pages=[],
448
+ )
449
+
450
+
451
+ def resolve_video_page(video: VideoAssetRecord, page_number: int | None) -> VideoPageOptionResponse | None:
452
+ if not video.pages:
453
+ return None
454
+ target_page = page_number or 1
455
+ return next((page for page in video.pages if page.page == target_page), None)
456
+
457
+
458
+ def probe_video_asset(
459
+ url: str,
460
+ force_refresh: bool = False,
461
+ ) -> tuple[VideoAssetRecord, list[VideoPageOptionResponse], bool]:
462
+ local_media_path = resolve_local_media_path(url)
463
+ if local_media_path is not None:
464
+ if not is_supported_local_media_file(local_media_path):
465
+ raise HTTPException(status_code=400, detail="当前仅支持导入常见本地视频或音频文件。")
466
+ return probe_local_video_asset(local_media_path, force_refresh=force_refresh), [], False
467
+
468
+ normalized = normalize_video_url(url)
469
+ normalized_url = normalized.normalized_url
470
+ canonical_id = normalized.canonical_id
471
+ platform = normalized.platform
472
+
473
+ if platform not in {"bilibili", "youtube"}:
474
+ raise HTTPException(status_code=400, detail="当前仅支持 Bilibili 或 YouTube 单条视频链接。")
475
+
476
+ if platform == "youtube":
477
+ info = extract_video_info(normalized_url)
478
+ title = str(info.get("title") or canonical_id or normalized_url)
479
+ thumbnail = str(info.get("thumbnail") or "")
480
+ duration = float(info.get("duration")) if info.get("duration") else None
481
+ actual_id = str(info.get("id") or canonical_id or normalized_url)
482
+ cached_cover_url = cache_cover_image(thumbnail, actual_id, referer_url=normalized_url)
483
+ return (
484
+ build_base_video_asset(
485
+ canonical_id=actual_id,
486
+ platform="youtube",
487
+ title=title,
488
+ source_url=normalized_url,
489
+ cover_url=cached_cover_url,
490
+ duration=duration,
491
+ pages=[],
492
+ ),
493
+ [],
494
+ False,
495
+ )
496
+
497
+ requested_page = extract_bilibili_page(normalized_url)
498
+
499
+ if requested_page is None:
500
+ flat_info = extract_video_info(normalized_url, extract_flat=True)
501
+ flat_entries = [entry for entry in flat_info.get("entries", []) if isinstance(entry, dict)]
502
+ if len(flat_entries) > 1:
503
+ top_title = str(flat_info.get("title") or canonical_id or normalized_url)
504
+ top_id = str(flat_info.get("id") or canonical_id or normalized_url)
505
+ catalog_entries = fetch_bilibili_page_catalog(normalized_url)
506
+ pages: list[VideoPageOptionResponse] = []
507
+ for index, entry in enumerate(flat_entries, start=1):
508
+ page_url = str(
509
+ entry.get("url")
510
+ or with_bilibili_page(f"https://www.bilibili.com/video/{canonical_id}", index)
511
+ )
512
+ catalog_entry = next(
513
+ (
514
+ item
515
+ for item in catalog_entries
516
+ if int(item.get("page") or 0) == index
517
+ ),
518
+ None,
519
+ )
520
+ page_title = build_page_title(
521
+ top_title,
522
+ index,
523
+ {
524
+ "part": str((catalog_entry or {}).get("part") or "").strip(),
525
+ "title": str(entry.get("title") or "").strip(),
526
+ },
527
+ )
528
+ pages.append(
529
+ VideoPageOptionResponse(
530
+ page=index,
531
+ title=page_title,
532
+ source_url=page_url,
533
+ cover_url="",
534
+ duration=None,
535
+ )
536
+ )
537
+
538
+ return (
539
+ build_base_video_asset(
540
+ canonical_id=top_id,
541
+ platform=str(flat_info.get("extractor_key") or "video").lower(),
542
+ title=top_title,
543
+ source_url=f"https://www.bilibili.com/video/{canonical_id}",
544
+ cover_url="",
545
+ duration=None,
546
+ pages=pages,
547
+ ),
548
+ pages,
549
+ True,
550
+ )
551
+
552
+ info = extract_video_info(normalized_url)
553
+
554
+ title = str(info.get("title") or canonical_id or normalized_url)
555
+ thumbnail = str(info.get("thumbnail") or "")
556
+ duration = float(info.get("duration")) if info.get("duration") else None
557
+ platform = str(info.get("extractor_key") or "video").lower()
558
+ actual_id = str(info.get("id") or canonical_id or normalized_url)
559
+ entries = [entry for entry in info.get("entries", []) if isinstance(entry, dict)]
560
+
561
+ if entries:
562
+ pages: list[VideoPageOptionResponse] = []
563
+ for index, entry in enumerate(entries, start=1):
564
+ page = int(entry.get("page") or entry.get("playlist_index") or index)
565
+ page_title = build_page_title(title, page, entry)
566
+ page_url = with_bilibili_page(f"https://www.bilibili.com/video/{canonical_id}", page)
567
+ page_cover = str(entry.get("thumbnail") or thumbnail or "")
568
+ page_duration = float(entry.get("duration")) if entry.get("duration") else None
569
+ pages.append(
570
+ VideoPageOptionResponse(
571
+ page=page,
572
+ title=page_title,
573
+ source_url=page_url,
574
+ cover_url=page_cover,
575
+ duration=page_duration,
576
+ )
577
+ )
578
+
579
+ selected_page = requested_page or pages[0].page
580
+ selected = next((item for item in pages if item.page == selected_page), pages[0])
581
+ selected_cover = cache_cover_image(
582
+ selected.cover_url or thumbnail,
583
+ actual_id,
584
+ referer_url=selected.source_url,
585
+ )
586
+ return (
587
+ build_base_video_asset(
588
+ canonical_id=actual_id,
589
+ platform=platform,
590
+ title=title,
591
+ source_url=f"https://www.bilibili.com/video/{canonical_id}",
592
+ cover_url=selected_cover,
593
+ duration=duration,
594
+ pages=pages,
595
+ ),
596
+ pages,
597
+ requested_page is None and len(pages) > 1,
598
+ )
599
+
600
+ cached_cover_url = cache_cover_image(thumbnail, actual_id, referer_url=normalized_url)
601
+ return (
602
+ build_base_video_asset(
603
+ canonical_id=actual_id,
604
+ platform=platform,
605
+ title=title,
606
+ source_url=normalized_url,
607
+ cover_url=cached_cover_url,
608
+ duration=duration,
609
+ pages=[],
610
+ ),
611
+ [],
612
+ False,
613
+ )
614
+
615
+
616
+ def localize_video_cover(task_store: SqliteTaskRepository, video: VideoAssetRecord) -> VideoAssetRecord:
617
+ if not video.cover_url or video.cover_url.startswith("/media/"):
618
+ return video
619
+ localized = cache_cover_image(video.cover_url, video.canonical_id, referer_url=video.source_url)
620
+ if localized == video.cover_url:
621
+ return video
622
+ updated = video.model_copy(update={"cover_url": localized})
623
+ return task_store.upsert_video_asset(updated)