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,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("video_sum_core.transcribe_subprocess")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def configure_logging() -> None:
|
|
15
|
+
logging.basicConfig(
|
|
16
|
+
level=logging.INFO,
|
|
17
|
+
format="%(asctime)s %(levelname)s %(name)s | %(message)s",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_progress(progress_path: Path, payload: dict[str, object]) -> None:
|
|
22
|
+
with progress_path.open("a", encoding="utf-8") as handle:
|
|
23
|
+
handle.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
24
|
+
handle.flush()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def configure_runtime_library_dirs() -> None:
|
|
28
|
+
raw_paths = os.environ.get("VIDEO_SUM_DLL_PATHS", "")
|
|
29
|
+
if not raw_paths:
|
|
30
|
+
return
|
|
31
|
+
dll_paths: list[str] = []
|
|
32
|
+
for entry in raw_paths.split(os.pathsep):
|
|
33
|
+
path = entry.strip()
|
|
34
|
+
if path and path not in dll_paths and Path(path).exists():
|
|
35
|
+
dll_paths.append(path)
|
|
36
|
+
|
|
37
|
+
if not dll_paths:
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
os.environ["PATH"] = os.pathsep.join([*dll_paths, os.environ.get("PATH", "")])
|
|
41
|
+
add_dll_directory = getattr(os, "add_dll_directory", None)
|
|
42
|
+
if add_dll_directory is None:
|
|
43
|
+
return
|
|
44
|
+
for path in dll_paths:
|
|
45
|
+
try:
|
|
46
|
+
add_dll_directory(path)
|
|
47
|
+
except OSError:
|
|
48
|
+
logger.debug("skip dll directory path=%s", path, exc_info=True)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def transcribe(
|
|
52
|
+
audio_path: Path,
|
|
53
|
+
model_name: str,
|
|
54
|
+
device: str,
|
|
55
|
+
compute_type: str,
|
|
56
|
+
progress_path: Path,
|
|
57
|
+
output_path: Path,
|
|
58
|
+
duration: float | None,
|
|
59
|
+
) -> None:
|
|
60
|
+
configure_runtime_library_dirs()
|
|
61
|
+
from faster_whisper import WhisperModel
|
|
62
|
+
|
|
63
|
+
logger.info(
|
|
64
|
+
"child transcription start audio=%s model=%s device=%s compute_type=%s",
|
|
65
|
+
audio_path,
|
|
66
|
+
model_name,
|
|
67
|
+
device,
|
|
68
|
+
compute_type,
|
|
69
|
+
)
|
|
70
|
+
model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
|
71
|
+
raw_segments, _info = model.transcribe(str(audio_path), language="zh", vad_filter=True)
|
|
72
|
+
|
|
73
|
+
segments: list[dict[str, object]] = []
|
|
74
|
+
transcript_lines: list[str] = []
|
|
75
|
+
last_reported_progress = 58
|
|
76
|
+
|
|
77
|
+
for segment in raw_segments:
|
|
78
|
+
item = {
|
|
79
|
+
"start": round(segment.start, 3),
|
|
80
|
+
"end": round(segment.end, 3),
|
|
81
|
+
"text": segment.text.strip(),
|
|
82
|
+
}
|
|
83
|
+
segments.append(item)
|
|
84
|
+
transcript_lines.append(f"[{format_timestamp(item['start'])}] {item['text']}")
|
|
85
|
+
|
|
86
|
+
if duration and duration > 0:
|
|
87
|
+
progress = min(82, 58 + int((float(segment.end) / float(duration)) * 24))
|
|
88
|
+
else:
|
|
89
|
+
progress = min(82, 58 + min(24, len(segments)))
|
|
90
|
+
|
|
91
|
+
if progress > last_reported_progress:
|
|
92
|
+
last_reported_progress = progress
|
|
93
|
+
write_progress(
|
|
94
|
+
progress_path,
|
|
95
|
+
{
|
|
96
|
+
"stage": "transcribing",
|
|
97
|
+
"progress": progress,
|
|
98
|
+
"message": f"正在转写,已识别 {len(segments)} 段",
|
|
99
|
+
"payload": {
|
|
100
|
+
"segment_count": len(segments),
|
|
101
|
+
"current_time": round(float(segment.end), 3),
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
transcript = "\n".join(transcript_lines)
|
|
107
|
+
if not transcript.strip():
|
|
108
|
+
raise RuntimeError("Transcription produced empty output.")
|
|
109
|
+
|
|
110
|
+
write_progress(
|
|
111
|
+
progress_path,
|
|
112
|
+
{
|
|
113
|
+
"stage": "transcribing",
|
|
114
|
+
"progress": 84,
|
|
115
|
+
"message": f"转写完成,共识别 {len(segments)} 段",
|
|
116
|
+
"payload": {"segment_count": len(segments)},
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
output_path.write_text(
|
|
120
|
+
json.dumps({"transcript": transcript, "segments": segments}, ensure_ascii=False),
|
|
121
|
+
encoding="utf-8",
|
|
122
|
+
)
|
|
123
|
+
logger.info(
|
|
124
|
+
"child transcription finish audio=%s segments=%d transcript_chars=%d",
|
|
125
|
+
audio_path,
|
|
126
|
+
len(segments),
|
|
127
|
+
len(transcript),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_timestamp(seconds: float) -> str:
|
|
132
|
+
total = max(0, int(seconds))
|
|
133
|
+
hours = total // 3600
|
|
134
|
+
minutes = (total % 3600) // 60
|
|
135
|
+
secs = total % 60
|
|
136
|
+
if hours:
|
|
137
|
+
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
|
138
|
+
return f"{minutes:02d}:{secs:02d}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_args() -> argparse.Namespace:
|
|
142
|
+
parser = argparse.ArgumentParser(description="Run faster-whisper transcription in an isolated subprocess.")
|
|
143
|
+
parser.add_argument("--audio-path", required=True)
|
|
144
|
+
parser.add_argument("--model", required=True)
|
|
145
|
+
parser.add_argument("--device", required=True)
|
|
146
|
+
parser.add_argument("--compute-type", required=True)
|
|
147
|
+
parser.add_argument("--progress-path", required=True)
|
|
148
|
+
parser.add_argument("--output-path", required=True)
|
|
149
|
+
parser.add_argument("--duration", type=float)
|
|
150
|
+
return parser.parse_args()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main() -> int:
|
|
154
|
+
configure_logging()
|
|
155
|
+
args = parse_args()
|
|
156
|
+
try:
|
|
157
|
+
transcribe(
|
|
158
|
+
audio_path=Path(args.audio_path),
|
|
159
|
+
model_name=args.model,
|
|
160
|
+
device=args.device,
|
|
161
|
+
compute_type=args.compute_type,
|
|
162
|
+
progress_path=Path(args.progress_path),
|
|
163
|
+
output_path=Path(args.output_path),
|
|
164
|
+
duration=args.duration,
|
|
165
|
+
)
|
|
166
|
+
except Exception:
|
|
167
|
+
logger.exception("child transcription failed")
|
|
168
|
+
return 1
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class NormalizedVideoUrl:
|
|
11
|
+
normalized_url: str
|
|
12
|
+
canonical_id: str
|
|
13
|
+
platform: str = "unknown"
|
|
14
|
+
page_number: int | None = None
|
|
15
|
+
|
|
16
|
+
def __iter__(self):
|
|
17
|
+
# Backward-compatible tuple unpacking for existing call sites.
|
|
18
|
+
yield self.normalized_url
|
|
19
|
+
yield self.canonical_id
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_video_url(value: str) -> NormalizedVideoUrl:
|
|
23
|
+
raw = (value or "").strip()
|
|
24
|
+
if not raw:
|
|
25
|
+
return NormalizedVideoUrl(normalized_url="", canonical_id="")
|
|
26
|
+
|
|
27
|
+
match = re.search(r"(BV[0-9A-Za-z]+)", raw, flags=re.IGNORECASE)
|
|
28
|
+
if match:
|
|
29
|
+
bvid = match.group(1)
|
|
30
|
+
normalized_url = f"https://www.bilibili.com/video/{bvid}"
|
|
31
|
+
page = extract_bilibili_page(raw)
|
|
32
|
+
if page is not None:
|
|
33
|
+
normalized_url = f"{normalized_url}?{urlencode({'p': page})}"
|
|
34
|
+
return NormalizedVideoUrl(
|
|
35
|
+
normalized_url=normalized_url,
|
|
36
|
+
canonical_id=bvid,
|
|
37
|
+
platform="bilibili",
|
|
38
|
+
page_number=page,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
youtube_id = extract_youtube_video_id(raw)
|
|
42
|
+
if youtube_id:
|
|
43
|
+
return NormalizedVideoUrl(
|
|
44
|
+
normalized_url=f"https://www.youtube.com/watch?v={youtube_id}",
|
|
45
|
+
canonical_id=youtube_id,
|
|
46
|
+
platform="youtube",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return NormalizedVideoUrl(normalized_url=raw, canonical_id="")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_bilibili_page(value: str) -> int | None:
|
|
53
|
+
raw = (value or "").strip()
|
|
54
|
+
if not raw:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if not re.search(r"(BV[0-9A-Za-z]+|bilibili\.com|b23\.tv)", raw, flags=re.IGNORECASE):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
parsed = urlparse(raw)
|
|
62
|
+
query = parse_qs(parsed.query)
|
|
63
|
+
page_value = query.get("p", [None])[0]
|
|
64
|
+
except ValueError:
|
|
65
|
+
page_value = None
|
|
66
|
+
|
|
67
|
+
if page_value is None:
|
|
68
|
+
match = re.search(r"[?&]p=(\d+)", raw, flags=re.IGNORECASE)
|
|
69
|
+
page_value = match.group(1) if match else None
|
|
70
|
+
|
|
71
|
+
if page_value is None:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
page = int(str(page_value))
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
return page if page > 0 else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_youtube_video_id(value: str) -> str | None:
|
|
82
|
+
raw = (value or "").strip()
|
|
83
|
+
if not raw:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
parsed = urlparse(raw)
|
|
88
|
+
except ValueError:
|
|
89
|
+
parsed = None
|
|
90
|
+
|
|
91
|
+
if parsed is not None:
|
|
92
|
+
host = parsed.netloc.lower()
|
|
93
|
+
path = parsed.path or ""
|
|
94
|
+
|
|
95
|
+
if host.endswith("youtu.be"):
|
|
96
|
+
candidate = path.strip("/").split("/", 1)[0]
|
|
97
|
+
return candidate or None
|
|
98
|
+
|
|
99
|
+
if "youtube.com" in host:
|
|
100
|
+
if path == "/watch":
|
|
101
|
+
candidate = parse_qs(parsed.query).get("v", [None])[0]
|
|
102
|
+
return str(candidate).strip() or None
|
|
103
|
+
if path.startswith("/shorts/"):
|
|
104
|
+
candidate = path.split("/", 3)[2] if len(path.split("/")) > 2 else ""
|
|
105
|
+
return candidate.strip() or None
|
|
106
|
+
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def sanitize_filename(value: str) -> str:
|
|
111
|
+
sanitized = re.sub(r"[\\/:*?\"<>|]+", "_", value).strip()
|
|
112
|
+
return sanitized[:120] or "video_summary"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ensure_directory(path: Path) -> Path:
|
|
116
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
return path
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_timestamp(seconds: float) -> str:
|
|
121
|
+
total = max(0, int(seconds))
|
|
122
|
+
hours = total // 3600
|
|
123
|
+
minutes = (total % 3600) // 60
|
|
124
|
+
remaining = total % 60
|
|
125
|
+
if hours:
|
|
126
|
+
return f"{hours:02d}:{minutes:02d}:{remaining:02d}"
|
|
127
|
+
return f"{minutes:02d}:{remaining:02d}"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "video-sum-infra"
|
|
7
|
+
version = "1.13.3"
|
|
8
|
+
description = "Infrastructure layer for the local video summarizer."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pydantic>=2.10,<3.0",
|
|
12
|
+
"pydantic-settings>=2.7,<3.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/video_sum_infra"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Infrastructure package for local video summarizer."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from importlib import metadata
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class AppInfo:
|
|
9
|
+
name: str
|
|
10
|
+
version: str
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def load(cls) -> "AppInfo":
|
|
14
|
+
return cls(name="BiliSum", version=_resolve_version())
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_version() -> str:
|
|
18
|
+
version_file = Path(__file__).resolve().parents[4] / "VERSION"
|
|
19
|
+
|
|
20
|
+
# In local development, prefer the workspace VERSION file so the UI reflects
|
|
21
|
+
# the checked-out repo version immediately without reinstalling packages.
|
|
22
|
+
if version_file.exists() and not _is_running_from_installed_dist():
|
|
23
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
24
|
+
|
|
25
|
+
for distribution_name in ("video-sum-service", "video-sum-infra"):
|
|
26
|
+
try:
|
|
27
|
+
return metadata.version(distribution_name)
|
|
28
|
+
except metadata.PackageNotFoundError:
|
|
29
|
+
continue
|
|
30
|
+
|
|
31
|
+
if version_file.exists():
|
|
32
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
33
|
+
return "0.0.0"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_running_from_installed_dist() -> bool:
|
|
37
|
+
base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)).resolve()
|
|
38
|
+
current_file = Path(__file__).resolve()
|
|
39
|
+
return base_prefix in current_file.parents
|