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,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
|
+
)
|