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.
- package/README.md +4 -4
- package/bin/bilisum.js +157 -76
- package/package.json +7 -2
- package/runtime/VERSION +1 -0
- package/runtime/apps/service/pyproject.toml +29 -0
- package/runtime/apps/service/src/video_sum_service/__init__.py +1 -0
- package/runtime/apps/service/src/video_sum_service/__main__.py +5 -0
- package/runtime/apps/service/src/video_sum_service/app.py +480 -0
- package/runtime/apps/service/src/video_sum_service/context.py +24 -0
- package/runtime/apps/service/src/video_sum_service/integrations.py +264 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/__init__.py +5 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/index_service.py +408 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/local_llm.py +319 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/rag_service.py +492 -0
- package/runtime/apps/service/src/video_sum_service/knowledge/tag_service.py +242 -0
- package/runtime/apps/service/src/video_sum_service/main.py +31 -0
- package/runtime/apps/service/src/video_sum_service/repository.py +942 -0
- package/runtime/apps/service/src/video_sum_service/routers/__init__.py +1 -0
- package/runtime/apps/service/src/video_sum_service/routers/knowledge.py +272 -0
- package/runtime/apps/service/src/video_sum_service/routers/system.py +280 -0
- package/runtime/apps/service/src/video_sum_service/routers/tasks.py +287 -0
- package/runtime/apps/service/src/video_sum_service/routers/videos.py +766 -0
- package/runtime/apps/service/src/video_sum_service/runtime_support.py +1007 -0
- package/runtime/apps/service/src/video_sum_service/schemas.py +418 -0
- package/runtime/apps/service/src/video_sum_service/settings_manager.py +130 -0
- package/runtime/apps/service/src/video_sum_service/task_artifacts.py +102 -0
- package/runtime/apps/service/src/video_sum_service/task_exports.py +108 -0
- package/runtime/apps/service/src/video_sum_service/transcribe_worker.py +9 -0
- package/runtime/apps/service/src/video_sum_service/video_assets.py +623 -0
- package/runtime/apps/service/src/video_sum_service/worker.py +381 -0
- package/runtime/apps/web/static/apple-touch-icon.png +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/runtime/apps/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/runtime/apps/web/static/assets/icons/icon-180.png +0 -0
- package/runtime/apps/web/static/assets/icons/icon-512.png +0 -0
- package/runtime/apps/web/static/assets/icons/icon.svg +17 -0
- package/runtime/apps/web/static/assets/index-Cj5PVjQg.js +388 -0
- package/runtime/apps/web/static/assets/index-DU2t4_7s.css +1 -0
- package/runtime/apps/web/static/favicon-32x32.png +0 -0
- package/runtime/apps/web/static/favicon.ico +0 -0
- package/runtime/apps/web/static/favicon.svg +17 -0
- package/runtime/apps/web/static/index.html +19 -0
- package/runtime/apps/web/static/js/api.js +123 -0
- package/runtime/apps/web/static/js/main.js +932 -0
- package/runtime/apps/web/static/js/state.js +28 -0
- package/runtime/apps/web/static/js/utils.js +81 -0
- package/runtime/apps/web/static/js/views/home.js +604 -0
- package/runtime/apps/web/static/js/views/settings.js +477 -0
- package/runtime/apps/web/static/styles.css +2206 -0
- package/runtime/packages/core/pyproject.toml +18 -0
- package/runtime/packages/core/src/video_sum_core/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/errors.py +22 -0
- package/runtime/packages/core/src/video_sum_core/markdown_exports.py +154 -0
- package/runtime/packages/core/src/video_sum_core/models/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/models/tasks.py +70 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/__init__.py +1 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/base.py +46 -0
- package/runtime/packages/core/src/video_sum_core/pipeline/real.py +3467 -0
- package/runtime/packages/core/src/video_sum_core/transcribe_subprocess.py +173 -0
- package/runtime/packages/core/src/video_sum_core/utils.py +127 -0
- package/runtime/packages/infra/pyproject.toml +16 -0
- package/runtime/packages/infra/src/video_sum_infra/__init__.py +1 -0
- package/runtime/packages/infra/src/video_sum_infra/app.py +39 -0
- package/runtime/packages/infra/src/video_sum_infra/config.py +393 -0
- package/runtime/packages/infra/src/video_sum_infra/db.py +34 -0
- package/runtime/packages/infra/src/video_sum_infra/logging.py +54 -0
- package/runtime/packages/infra/src/video_sum_infra/paths.py +6 -0
- 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)
|