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,766 @@
1
+ import hashlib
2
+ import json
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ from fastapi import APIRouter, HTTPException, Query, Request, status
7
+ from fastapi.responses import FileResponse
8
+
9
+ from video_sum_core.models.tasks import InputType, TaskInput
10
+
11
+ from video_sum_service.context import LOCAL_MEDIA_UPLOAD_DIR, logger, settings_manager
12
+ from video_sum_service.repository import SqliteTaskRepository
13
+ from video_sum_service.schemas import (
14
+ AggregateSummaryRequest,
15
+ ResummaryRequest,
16
+ TaskDetailResponse,
17
+ TaskSummaryResponse,
18
+ VideoAssetDetailResponse,
19
+ VideoAssetSummaryResponse,
20
+ VideoAssetRecord,
21
+ VideoProbeRequest,
22
+ VideoProbeResponse,
23
+ VideoTaskBatchPageResponse,
24
+ VideoTaskBatchRequest,
25
+ VideoTaskBatchResponse,
26
+ VideoTaskCreateRequest,
27
+ )
28
+ from video_sum_service.task_artifacts import cleanup_video_files, load_task_segments
29
+ from video_sum_service.video_assets import (
30
+ infer_local_input_type,
31
+ is_supported_local_media_file,
32
+ localize_video_cover,
33
+ merge_video_asset_metadata,
34
+ probe_local_video_asset,
35
+ probe_video_asset,
36
+ resolve_video_page,
37
+ )
38
+ from video_sum_service.worker import TaskWorker
39
+
40
+ router = APIRouter(prefix="/api/v1/videos")
41
+ AGGREGATE_SUMMARY_PAGE_NUMBER = 0
42
+ AGGREGATE_SUMMARY_PAGE_TITLE = "全集总结"
43
+ AGGREGATE_SUMMARY_TITLE_SUFFIX = "|全集总结"
44
+ AGGREGATE_OVERVIEW_LIMIT = 520
45
+ AGGREGATE_KEY_POINT_LIMIT = 160
46
+ AGGREGATE_CHAPTER_LIMIT = 180
47
+ AGGREGATE_NOTE_LIMIT = 360
48
+ AGGREGATE_MAX_KEY_POINTS_PER_PAGE = 8
49
+ AGGREGATE_MAX_CHAPTERS_PER_PAGE = 8
50
+
51
+
52
+ def _normalize_batch_page_numbers(video: VideoAssetRecord, page_numbers: list[int]) -> list[int]:
53
+ normalized: list[int] = []
54
+ seen: set[int] = set()
55
+ for raw_page_number in page_numbers:
56
+ if raw_page_number in seen:
57
+ continue
58
+ seen.add(raw_page_number)
59
+ normalized.append(int(raw_page_number))
60
+
61
+ if not normalized:
62
+ raise HTTPException(status_code=400, detail="至少选择一个分 P。")
63
+ if not video.pages:
64
+ raise HTTPException(status_code=400, detail="当前视频不包含可批量处理的分 P。")
65
+
66
+ existing_pages = {page.page for page in video.pages}
67
+ invalid_pages = [page_number for page_number in normalized if page_number not in existing_pages]
68
+ if invalid_pages:
69
+ raise HTTPException(
70
+ status_code=400,
71
+ detail=f"所选分 P 不存在:{', '.join(f'P{page_number}' for page_number in invalid_pages)}。",
72
+ )
73
+ return normalized
74
+
75
+
76
+ def _find_latest_completed_task_for_page(tasks, page_number: int):
77
+ return next(
78
+ (
79
+ task
80
+ for task in tasks
81
+ if (task.page_number if task.page_number is not None else 1) == page_number
82
+ and task.status.value == "completed"
83
+ and task.result is not None
84
+ ),
85
+ None,
86
+ )
87
+
88
+
89
+ def _find_resummary_source_task(
90
+ task_store: SqliteTaskRepository,
91
+ video_id: str,
92
+ body: ResummaryRequest,
93
+ ):
94
+ source_task = task_store.get_task(body.task_id) if body.task_id else None
95
+ if source_task is None:
96
+ source_task = next(
97
+ (
98
+ task
99
+ for task in task_store.list_tasks_for_video(video_id)
100
+ if (
101
+ task.result
102
+ and task.result.transcript_text.strip()
103
+ and task.result.artifacts.get("summary_path")
104
+ and (
105
+ body.page_number is None
106
+ or _task_page_number(task) == body.page_number
107
+ )
108
+ )
109
+ ),
110
+ None,
111
+ )
112
+ return source_task
113
+
114
+
115
+ def _task_page_number(task) -> int:
116
+ return task.page_number if task.page_number is not None else 1
117
+
118
+
119
+ def _compact_text(value: object, limit: int) -> str:
120
+ text = str(value or "").strip()
121
+ if len(text) <= limit:
122
+ return text
123
+ return f"{text[:limit].rstrip()}..."
124
+
125
+
126
+ def _has_aggregate_summary_material(result) -> bool:
127
+ return bool(
128
+ str(result.overview or "").strip()
129
+ or any(str(item).strip() for item in result.key_points)
130
+ or any(str(item).strip() for item in result.segment_summaries)
131
+ or any(isinstance(item, dict) and str(item.get("summary") or "").strip() for item in result.timeline)
132
+ or str(result.knowledge_note_markdown or "").strip()
133
+ )
134
+
135
+
136
+ def _select_aggregate_source_tasks(video: VideoAssetRecord, tasks, page_numbers: list[int] | None):
137
+ requested_pages = set(page_numbers) if page_numbers is not None else None
138
+ page_titles = {page.page: page.title for page in video.pages}
139
+ latest_completed_by_page = {}
140
+ for task in tasks:
141
+ page_number = _task_page_number(task)
142
+ if page_number <= 0:
143
+ continue
144
+ if requested_pages is not None and page_number not in requested_pages:
145
+ continue
146
+ if task.status.value != "completed" or task.result is None:
147
+ continue
148
+ if not _has_aggregate_summary_material(task.result):
149
+ continue
150
+ if page_number not in page_titles:
151
+ continue
152
+ latest_completed_by_page.setdefault(page_number, task)
153
+ return [
154
+ latest_completed_by_page[page_number]
155
+ for page_number in sorted(latest_completed_by_page)
156
+ ]
157
+
158
+
159
+ def _build_aggregate_summary_payload(
160
+ video: VideoAssetRecord,
161
+ source_tasks,
162
+ ) -> tuple[str, str, list[dict[str, object]]]:
163
+ title = f"{video.title}{AGGREGATE_SUMMARY_TITLE_SUFFIX}"
164
+ transcript_parts: list[str] = []
165
+ segments: list[dict[str, object]] = []
166
+
167
+ # 根据总 P 数动态调整每 P 的压缩限制
168
+ page_count = len(source_tasks)
169
+ if page_count <= 20:
170
+ overview_limit = 520
171
+ key_point_limit = 160
172
+ chapter_limit = 180
173
+ note_limit = 360
174
+ max_key_points_per_page = 8
175
+ max_chapters_per_page = 8
176
+ elif page_count <= 40:
177
+ overview_limit = 400
178
+ key_point_limit = 120
179
+ chapter_limit = 140
180
+ note_limit = 280
181
+ max_key_points_per_page = 6
182
+ max_chapters_per_page = 6
183
+ elif page_count <= 60:
184
+ overview_limit = 300
185
+ key_point_limit = 100
186
+ chapter_limit = 100
187
+ note_limit = 200
188
+ max_key_points_per_page = 4
189
+ max_chapters_per_page = 4
190
+ else:
191
+ overview_limit = 200
192
+ key_point_limit = 80
193
+ chapter_limit = 70
194
+ note_limit = 140
195
+ max_key_points_per_page = 3
196
+ max_chapters_per_page = 3
197
+
198
+ for task in source_tasks:
199
+ result = task.result
200
+ assert result is not None
201
+ page_number = _task_page_number(task)
202
+ page_title = task.page_title or task.task_input.title or f"P{page_number}"
203
+ key_points = [
204
+ _compact_text(item, key_point_limit)
205
+ for item in result.key_points
206
+ if str(item).strip()
207
+ ][:max_key_points_per_page]
208
+ timeline = [item for item in result.timeline if isinstance(item, dict)]
209
+ segment_summaries = [
210
+ _compact_text(item, chapter_limit)
211
+ for item in result.segment_summaries
212
+ if str(item).strip()
213
+ ]
214
+ note = _compact_text(result.knowledge_note_markdown, note_limit)
215
+ overview = _compact_text(result.overview, overview_limit)
216
+
217
+ chapter_fragments: list[str] = []
218
+ for chapter in timeline[:max_chapters_per_page]:
219
+ chapter_title = str(chapter.get("title") or "").strip() or "章节"
220
+ chapter_summary = _compact_text(chapter.get("summary"), chapter_limit)
221
+ if chapter_summary:
222
+ chapter_fragments.append(f"{chapter_title}:{chapter_summary}")
223
+
224
+ section_lines = [f"## P{page_number} {page_title}"]
225
+ if overview:
226
+ section_lines.append(f"[重点-概览] {overview}")
227
+ if key_points:
228
+ section_lines.append(f"[重点-要点] {';'.join(key_points)}")
229
+ if chapter_fragments:
230
+ section_lines.append(f"[重点-章节] {';'.join(chapter_fragments)}")
231
+ elif segment_summaries:
232
+ section_lines.append(
233
+ f"[重点-片段] {';'.join(segment_summaries[:max_chapters_per_page])}"
234
+ )
235
+ if note:
236
+ section_lines.append(f"[重点-笔记] {note}")
237
+
238
+ section_text = "\n".join(section_lines).strip()
239
+ transcript_parts.append(section_text)
240
+ segments.append(
241
+ {
242
+ "start": float(page_number),
243
+ "end": float(page_number),
244
+ "text": f"P{page_number} {page_title}:{overview or page_title}",
245
+ }
246
+ )
247
+
248
+ for chapter in timeline[:max_chapters_per_page]:
249
+ chapter_summary = _compact_text(chapter.get("summary"), chapter_limit)
250
+ if not chapter_summary:
251
+ continue
252
+ chapter_title = str(chapter.get("title") or "").strip() or "章节"
253
+ segments.append(
254
+ {
255
+ "start": float(page_number),
256
+ "end": float(page_number),
257
+ "text": f"P{page_number} {page_title} - {chapter_title}\n{chapter_summary}",
258
+ }
259
+ )
260
+
261
+ return title, "\n\n".join(transcript_parts).strip(), segments
262
+
263
+
264
+ def _create_video_task_record(
265
+ *,
266
+ task_store: SqliteTaskRepository,
267
+ task_worker: TaskWorker,
268
+ video: VideoAssetRecord,
269
+ page_number: int | None = None,
270
+ ):
271
+ page = resolve_video_page(video, page_number)
272
+ if video.pages and page_number is not None and page is None:
273
+ raise HTTPException(status_code=400, detail="Selected page not found.")
274
+
275
+ source_url = page.source_url if page else video.source_url
276
+ title = page.title if page else video.title
277
+ logger.info(
278
+ "create video task video_id=%s page=%s title=%s source=%s",
279
+ video.video_id,
280
+ page.page if page else None,
281
+ title,
282
+ source_url,
283
+ )
284
+ input_type = InputType.URL
285
+ if str(video.platform or "").lower() == "local":
286
+ input_type = infer_local_input_type(source_url)
287
+ record = task_store.create_task(
288
+ TaskInput(input_type=input_type, source=source_url, title=title, platform_hint=video.platform),
289
+ video_id=video.video_id,
290
+ page_number=page.page if page else None,
291
+ page_title=title,
292
+ )
293
+ task_worker.submit(record)
294
+ refreshed = task_store.get_task(record.task_id)
295
+ assert refreshed is not None
296
+ return refreshed
297
+
298
+
299
+ def _create_resummary_task_record(
300
+ *,
301
+ task_store: SqliteTaskRepository,
302
+ task_worker: TaskWorker,
303
+ video: VideoAssetRecord,
304
+ source_task,
305
+ ):
306
+ if source_task.video_id != video.video_id:
307
+ raise HTTPException(status_code=400, detail="所选任务不属于当前视频。")
308
+ if source_task.result is None or not source_task.result.transcript_text.strip():
309
+ raise HTTPException(status_code=400, detail="所选任务还没有可复用的转写文本。")
310
+
311
+ summary_path = source_task.result.artifacts.get("summary_path") if source_task.result else None
312
+ if not summary_path:
313
+ raise HTTPException(status_code=400, detail="所选任务缺少可复用的分段文件。")
314
+ segments = load_task_segments(summary_path)
315
+
316
+ payload = json.dumps(
317
+ {
318
+ "title": source_task.task_input.title or video.title,
319
+ "transcript": source_task.result.transcript_text,
320
+ "segments": segments,
321
+ },
322
+ ensure_ascii=False,
323
+ )
324
+ logger.info(
325
+ "create video resummary task video_id=%s source_task_id=%s title=%s",
326
+ video.video_id,
327
+ source_task.task_id,
328
+ source_task.task_input.title or video.title,
329
+ )
330
+ record = task_store.create_task(
331
+ TaskInput(
332
+ input_type=InputType.TRANSCRIPT_TEXT,
333
+ source=payload,
334
+ title=source_task.task_input.title or video.title,
335
+ platform_hint=source_task.task_input.platform_hint,
336
+ options=source_task.task_input.options,
337
+ ),
338
+ video_id=video.video_id,
339
+ page_number=source_task.page_number,
340
+ page_title=source_task.page_title or source_task.task_input.title or video.title,
341
+ )
342
+ task_worker.submit(record)
343
+ refreshed = task_store.get_task(record.task_id)
344
+ assert refreshed is not None
345
+ return refreshed
346
+
347
+
348
+ def normalize_uploaded_media_filename(filename: str) -> tuple[str, str]:
349
+ raw_name = Path(str(filename or "").strip()).name
350
+ if not raw_name:
351
+ raise HTTPException(status_code=400, detail="缺少有效文件名。")
352
+ suffix = Path(raw_name).suffix.lower()
353
+ if not suffix or not is_supported_local_media_file(Path(raw_name)):
354
+ raise HTTPException(status_code=400, detail="当前仅支持导入常见本地视频或音频文件。")
355
+ title = Path(raw_name).stem.strip() or "本地媒体"
356
+ return title, suffix
357
+
358
+
359
+ async def cache_uploaded_media_file(request: Request, filename: str) -> tuple[Path, str, str]:
360
+ title, suffix = normalize_uploaded_media_filename(filename)
361
+ LOCAL_MEDIA_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
362
+ temp_path = LOCAL_MEDIA_UPLOAD_DIR / f"{uuid4().hex}.upload"
363
+ digest = hashlib.sha1()
364
+ total_bytes = 0
365
+ try:
366
+ with temp_path.open("wb") as handle:
367
+ async for chunk in request.stream():
368
+ if not chunk:
369
+ continue
370
+ if isinstance(chunk, str):
371
+ chunk = chunk.encode("utf-8")
372
+ handle.write(chunk)
373
+ digest.update(chunk)
374
+ total_bytes += len(chunk)
375
+ except OSError as exc:
376
+ raise HTTPException(status_code=500, detail="保存上传视频时失败。") from exc
377
+
378
+ if total_bytes <= 0:
379
+ try:
380
+ temp_path.unlink(missing_ok=True)
381
+ except OSError:
382
+ logger.warning("failed to remove empty upload temp file: %s", temp_path, exc_info=True)
383
+ raise HTTPException(status_code=400, detail="上传文件为空。")
384
+
385
+ content_hash = digest.hexdigest()
386
+ final_path = LOCAL_MEDIA_UPLOAD_DIR / f"{content_hash}{suffix}"
387
+ if final_path.exists():
388
+ try:
389
+ temp_path.unlink()
390
+ except OSError:
391
+ logger.warning("failed to remove duplicate upload temp file: %s", temp_path, exc_info=True)
392
+ else:
393
+ try:
394
+ temp_path.replace(final_path)
395
+ except OSError as exc:
396
+ raise HTTPException(status_code=500, detail="整理上传视频文件时失败。") from exc
397
+ return final_path.resolve(), title, content_hash
398
+
399
+
400
+ @router.post("/{video_id}/favorite", response_model=VideoAssetDetailResponse)
401
+ def set_video_favorite(video_id: str, payload: dict[str, bool], request: Request) -> VideoAssetDetailResponse:
402
+ task_store: SqliteTaskRepository = request.app.state.task_repository
403
+ updated = task_store.set_video_favorite(video_id, bool(payload.get("is_favorite")))
404
+ if updated is None:
405
+ raise HTTPException(status_code=404, detail="Video not found.")
406
+ return localize_video_cover(task_store, updated).to_detail()
407
+
408
+
409
+ @router.post("/probe", response_model=VideoProbeResponse)
410
+ def probe_video(request: VideoProbeRequest, app_request: Request) -> VideoProbeResponse:
411
+ task_store: SqliteTaskRepository = app_request.app.state.task_repository
412
+ logger.info("probe video url=%s force_refresh=%s", request.url, request.force_refresh)
413
+ probed, pages, requires_selection = probe_video_asset(request.url, request.force_refresh)
414
+ existing = task_store.get_video_asset_by_canonical_id(probed.canonical_id)
415
+ cached = existing is not None and not request.force_refresh
416
+ asset = existing if cached else task_store.upsert_video_asset(merge_video_asset_metadata(existing, probed) if existing else probed)
417
+ asset = localize_video_cover(task_store, asset)
418
+ return VideoProbeResponse(
419
+ video=asset.to_summary(),
420
+ cached=cached,
421
+ requires_selection=requires_selection,
422
+ pages=asset.pages or pages,
423
+ )
424
+
425
+
426
+ @router.post("/upload", response_model=VideoProbeResponse)
427
+ async def upload_local_video(
428
+ request: Request,
429
+ filename: str = Query(..., min_length=1),
430
+ ) -> VideoProbeResponse:
431
+ task_store: SqliteTaskRepository = request.app.state.task_repository
432
+ saved_path, title, content_hash = await cache_uploaded_media_file(request, filename)
433
+ logger.info("upload local media filename=%s saved_path=%s", filename, saved_path)
434
+ probed = probe_local_video_asset(
435
+ saved_path,
436
+ title_override=title,
437
+ canonical_id_override=f"local-upload-{content_hash[:24]}",
438
+ )
439
+ existing = task_store.get_video_asset_by_canonical_id(probed.canonical_id)
440
+ cached = existing is not None
441
+ asset = existing if cached else task_store.upsert_video_asset(probed)
442
+ asset = localize_video_cover(task_store, asset)
443
+ return VideoProbeResponse(
444
+ video=asset.to_summary(),
445
+ cached=cached,
446
+ requires_selection=False,
447
+ pages=[],
448
+ )
449
+
450
+
451
+ @router.get("", response_model=list[VideoAssetSummaryResponse])
452
+ def list_videos(request: Request) -> list[VideoAssetSummaryResponse]:
453
+ task_store: SqliteTaskRepository = request.app.state.task_repository
454
+ return [localize_video_cover(task_store, video).to_summary() for video in task_store.list_video_assets()]
455
+
456
+
457
+ @router.get("/{video_id}", response_model=VideoAssetDetailResponse)
458
+ def get_video(video_id: str, request: Request) -> VideoAssetDetailResponse:
459
+ task_store: SqliteTaskRepository = request.app.state.task_repository
460
+ video = task_store.get_video_asset(video_id)
461
+ if video is None:
462
+ raise HTTPException(status_code=404, detail="Video not found.")
463
+ return localize_video_cover(task_store, video).to_detail()
464
+
465
+
466
+ @router.get("/{video_id}/media")
467
+ def get_local_video_media(video_id: str, request: Request) -> FileResponse:
468
+ task_store: SqliteTaskRepository = request.app.state.task_repository
469
+ video = task_store.get_video_asset(video_id)
470
+ if video is None:
471
+ raise HTTPException(status_code=404, detail="Video not found.")
472
+ if str(video.platform or "").lower() != "local":
473
+ raise HTTPException(status_code=400, detail="当前视频不支持本地播放器。")
474
+
475
+ source_path = Path(str(video.source_url or "")).expanduser()
476
+ try:
477
+ resolved_path = source_path.resolve()
478
+ except OSError:
479
+ resolved_path = source_path
480
+ if not resolved_path.exists() or not resolved_path.is_file():
481
+ raise HTTPException(status_code=404, detail="本地视频文件不存在或已被移动。")
482
+ if not is_supported_local_media_file(resolved_path):
483
+ raise HTTPException(status_code=400, detail="当前文件类型不支持本地播放器。")
484
+
485
+ media_type = None
486
+ suffix = resolved_path.suffix.lower()
487
+ if suffix == ".mp4":
488
+ media_type = "video/mp4"
489
+ elif suffix == ".webm":
490
+ media_type = "video/webm"
491
+ elif suffix in {".mov", ".m4v"}:
492
+ media_type = "video/quicktime"
493
+ elif suffix == ".ogg":
494
+ media_type = "audio/ogg"
495
+ elif suffix == ".mp3":
496
+ media_type = "audio/mpeg"
497
+ return FileResponse(resolved_path, media_type=media_type, filename=resolved_path.name)
498
+
499
+
500
+ @router.delete("/{video_id}")
501
+ def delete_video(video_id: str, request: Request) -> dict[str, object]:
502
+ task_store: SqliteTaskRepository = request.app.state.task_repository
503
+ video = task_store.get_video_asset(video_id)
504
+ if video is None:
505
+ raise HTTPException(status_code=404, detail="Video not found.")
506
+ tasks = task_store.list_tasks_for_video(video_id)
507
+ deleted = task_store.delete_video_asset(video_id)
508
+ if not deleted:
509
+ raise HTTPException(status_code=404, detail="Video not found.")
510
+ cleanup_video_files(video, tasks, settings_manager.current)
511
+ logger.info("delete video video_id=%s", video_id)
512
+ return {"deleted": True, "video_id": video_id}
513
+
514
+
515
+ @router.get("/{video_id}/tasks", response_model=list[TaskSummaryResponse])
516
+ def get_video_tasks(video_id: str, request: Request) -> list[TaskSummaryResponse]:
517
+ task_store: SqliteTaskRepository = request.app.state.task_repository
518
+ return [task.to_summary() for task in task_store.list_tasks_for_video(video_id)]
519
+
520
+
521
+ @router.post("/{video_id}/tasks", response_model=TaskDetailResponse, status_code=status.HTTP_201_CREATED)
522
+ def create_video_task(
523
+ request: Request,
524
+ video_id: str,
525
+ request_body: VideoTaskCreateRequest | None = None,
526
+ ) -> TaskDetailResponse:
527
+ task_store: SqliteTaskRepository = request.app.state.task_repository
528
+ task_worker: TaskWorker = request.app.state.task_worker
529
+ video = task_store.get_video_asset(video_id)
530
+ if video is None:
531
+ raise HTTPException(status_code=404, detail="Video not found.")
532
+ refreshed = _create_video_task_record(
533
+ task_store=task_store,
534
+ task_worker=task_worker,
535
+ video=video,
536
+ page_number=request_body.page_number if request_body else None,
537
+ )
538
+ return refreshed.to_detail()
539
+
540
+
541
+ @router.post("/{video_id}/tasks/resummary", response_model=TaskDetailResponse, status_code=status.HTTP_201_CREATED)
542
+ def create_video_resummary_task(video_id: str, body: ResummaryRequest, request: Request) -> TaskDetailResponse:
543
+ task_store: SqliteTaskRepository = request.app.state.task_repository
544
+ task_worker: TaskWorker = request.app.state.task_worker
545
+ video = task_store.get_video_asset(video_id)
546
+ if video is None:
547
+ raise HTTPException(status_code=404, detail="Video not found.")
548
+
549
+ source_task = _find_resummary_source_task(task_store, video_id, body)
550
+ if source_task is None:
551
+ raise HTTPException(status_code=400, detail="当前视频还没有可复用的转写结果。")
552
+
553
+ refreshed = _create_resummary_task_record(
554
+ task_store=task_store,
555
+ task_worker=task_worker,
556
+ video=video,
557
+ source_task=source_task,
558
+ )
559
+ return refreshed.to_detail()
560
+
561
+
562
+ @router.post(
563
+ "/{video_id}/tasks/aggregate-summary",
564
+ response_model=TaskDetailResponse,
565
+ status_code=status.HTTP_201_CREATED,
566
+ )
567
+ def create_video_aggregate_summary_task(
568
+ video_id: str,
569
+ request: Request,
570
+ body: AggregateSummaryRequest | None = None,
571
+ ) -> TaskDetailResponse:
572
+ task_store: SqliteTaskRepository = request.app.state.task_repository
573
+ task_worker: TaskWorker = request.app.state.task_worker
574
+ video = task_store.get_video_asset(video_id)
575
+ if video is None:
576
+ raise HTTPException(status_code=404, detail="Video not found.")
577
+ if not video.pages:
578
+ raise HTTPException(status_code=400, detail="当前视频不是多 P 视频,无法生成全集总结。")
579
+
580
+ requested_pages = body.page_numbers if body and body.page_numbers is not None else None
581
+ if requested_pages is not None:
582
+ _normalize_batch_page_numbers(video, requested_pages)
583
+
584
+ source_tasks = _select_aggregate_source_tasks(
585
+ video,
586
+ task_store.list_tasks_for_video(video_id),
587
+ requested_pages,
588
+ )
589
+ if not source_tasks:
590
+ raise HTTPException(status_code=400, detail="还没有可汇总的分 P 摘要,请先生成至少一个分 P 摘要。")
591
+
592
+ title, transcript, segments = _build_aggregate_summary_payload(video, source_tasks)
593
+ payload = json.dumps(
594
+ {
595
+ "title": title,
596
+ "source_kind": "aggregate_series",
597
+ "transcript": transcript,
598
+ "segments": segments,
599
+ },
600
+ ensure_ascii=False,
601
+ )
602
+ record = task_store.create_task(
603
+ TaskInput(
604
+ input_type=InputType.TRANSCRIPT_TEXT,
605
+ source=payload,
606
+ title=title,
607
+ platform_hint=video.platform,
608
+ ),
609
+ video_id=video.video_id,
610
+ page_number=AGGREGATE_SUMMARY_PAGE_NUMBER,
611
+ page_title=AGGREGATE_SUMMARY_PAGE_TITLE,
612
+ )
613
+ task_worker.submit(record)
614
+ refreshed = task_store.get_task(record.task_id)
615
+ assert refreshed is not None
616
+ return refreshed.to_detail()
617
+
618
+
619
+ @router.post("/{video_id}/tasks/batch", response_model=VideoTaskBatchResponse, status_code=status.HTTP_201_CREATED)
620
+ def create_video_tasks_batch(video_id: str, body: VideoTaskBatchRequest, request: Request) -> VideoTaskBatchResponse:
621
+ task_store: SqliteTaskRepository = request.app.state.task_repository
622
+ task_worker: TaskWorker = request.app.state.task_worker
623
+ video = task_store.get_video_asset(video_id)
624
+ if video is None:
625
+ raise HTTPException(status_code=404, detail="Video not found.")
626
+
627
+ page_numbers = _normalize_batch_page_numbers(video, body.page_numbers)
628
+ tasks = task_store.list_tasks_for_video(video_id)
629
+
630
+ conflict_pages: list[VideoTaskBatchPageResponse] = []
631
+ skipped_pages: list[VideoTaskBatchPageResponse] = []
632
+ created_tasks: list[TaskDetailResponse] = []
633
+ creatable_page_numbers: list[int] = []
634
+
635
+ for page_number in page_numbers:
636
+ page = resolve_video_page(video, page_number)
637
+ assert page is not None
638
+ existing_completed_task = _find_latest_completed_task_for_page(tasks, page_number)
639
+ if existing_completed_task is None:
640
+ creatable_page_numbers.append(page_number)
641
+ continue
642
+
643
+ conflict_pages.append(
644
+ VideoTaskBatchPageResponse(
645
+ page_number=page_number,
646
+ page_title=page.title,
647
+ action="skip",
648
+ reason="该分 P 已有成功摘要,批量生成将默认跳过。",
649
+ existing_task_id=existing_completed_task.task_id,
650
+ existing_status=existing_completed_task.status,
651
+ has_existing_result=True,
652
+ )
653
+ )
654
+
655
+ if conflict_pages and not body.confirm:
656
+ return VideoTaskBatchResponse(
657
+ operation="create",
658
+ requested_page_numbers=page_numbers,
659
+ requires_confirmation=True,
660
+ created_tasks=[],
661
+ skipped_pages=[],
662
+ conflict_pages=conflict_pages,
663
+ )
664
+
665
+ if conflict_pages:
666
+ skipped_pages.extend(conflict_pages)
667
+
668
+ for page_number in creatable_page_numbers:
669
+ refreshed = _create_video_task_record(
670
+ task_store=task_store,
671
+ task_worker=task_worker,
672
+ video=video,
673
+ page_number=page_number,
674
+ )
675
+ created_tasks.append(refreshed.to_detail())
676
+
677
+ return VideoTaskBatchResponse(
678
+ operation="create",
679
+ requested_page_numbers=page_numbers,
680
+ requires_confirmation=False,
681
+ created_tasks=created_tasks,
682
+ skipped_pages=skipped_pages,
683
+ conflict_pages=[],
684
+ )
685
+
686
+
687
+ @router.post("/{video_id}/tasks/resummary/batch", response_model=VideoTaskBatchResponse, status_code=status.HTTP_201_CREATED)
688
+ def create_video_resummary_tasks_batch(video_id: str, body: VideoTaskBatchRequest, request: Request) -> VideoTaskBatchResponse:
689
+ task_store: SqliteTaskRepository = request.app.state.task_repository
690
+ task_worker: TaskWorker = request.app.state.task_worker
691
+ video = task_store.get_video_asset(video_id)
692
+ if video is None:
693
+ raise HTTPException(status_code=404, detail="Video not found.")
694
+
695
+ page_numbers = _normalize_batch_page_numbers(video, body.page_numbers)
696
+ conflict_pages: list[VideoTaskBatchPageResponse] = []
697
+ skipped_pages: list[VideoTaskBatchPageResponse] = []
698
+ created_tasks: list[TaskDetailResponse] = []
699
+
700
+ source_tasks_by_page = {
701
+ page_number: _find_resummary_source_task(
702
+ task_store,
703
+ video_id,
704
+ ResummaryRequest(page_number=page_number),
705
+ )
706
+ for page_number in page_numbers
707
+ }
708
+
709
+ for page_number in page_numbers:
710
+ page = resolve_video_page(video, page_number)
711
+ assert page is not None
712
+ source_task = source_tasks_by_page[page_number]
713
+ if source_task is None:
714
+ skipped_pages.append(
715
+ VideoTaskBatchPageResponse(
716
+ page_number=page_number,
717
+ page_title=page.title,
718
+ action="skip",
719
+ reason="该分 P 暂无可复用的转写与摘要结果。",
720
+ has_existing_result=False,
721
+ )
722
+ )
723
+ continue
724
+
725
+ conflict_pages.append(
726
+ VideoTaskBatchPageResponse(
727
+ page_number=page_number,
728
+ page_title=page.title,
729
+ action="rerun",
730
+ reason="该分 P 已有成功摘要,确认后将复用转写重新生成摘要。",
731
+ existing_task_id=source_task.task_id,
732
+ existing_status=source_task.status,
733
+ has_existing_result=True,
734
+ )
735
+ )
736
+
737
+ if conflict_pages and not body.confirm:
738
+ return VideoTaskBatchResponse(
739
+ operation="resummary",
740
+ requested_page_numbers=page_numbers,
741
+ requires_confirmation=True,
742
+ created_tasks=[],
743
+ skipped_pages=skipped_pages,
744
+ conflict_pages=conflict_pages,
745
+ )
746
+
747
+ for page_number in page_numbers:
748
+ source_task = source_tasks_by_page[page_number]
749
+ if source_task is None:
750
+ continue
751
+ refreshed = _create_resummary_task_record(
752
+ task_store=task_store,
753
+ task_worker=task_worker,
754
+ video=video,
755
+ source_task=source_task,
756
+ )
757
+ created_tasks.append(refreshed.to_detail())
758
+
759
+ return VideoTaskBatchResponse(
760
+ operation="resummary",
761
+ requested_page_numbers=page_numbers,
762
+ requires_confirmation=False,
763
+ created_tasks=created_tasks,
764
+ skipped_pages=skipped_pages,
765
+ conflict_pages=[],
766
+ )