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,942 @@
1
+ import json
2
+ import sqlite3
3
+ from datetime import datetime, timedelta, timezone
4
+ from pathlib import Path
5
+ from threading import Lock
6
+
7
+ from video_sum_core.models.tasks import TaskInput, TaskResult, TaskStatus
8
+ from video_sum_infra.db import sqlite_cursor
9
+ from video_sum_service.schemas import (
10
+ KnowledgeIndexChunkRecord,
11
+ TaskEventRecord,
12
+ TaskRecord,
13
+ VideoTagRecord,
14
+ VideoAssetRecord,
15
+ VideoPageOptionResponse,
16
+ )
17
+
18
+
19
+ class SqliteTaskRepository:
20
+ def __init__(self, connection: sqlite3.Connection) -> None:
21
+ self._connection = connection
22
+ self._lock = Lock()
23
+
24
+ def initialize(self) -> None:
25
+ with self._lock, sqlite_cursor(self._connection) as cursor:
26
+ cursor.execute(
27
+ """
28
+ CREATE TABLE IF NOT EXISTS video_assets (
29
+ video_id TEXT PRIMARY KEY,
30
+ canonical_id TEXT NOT NULL UNIQUE,
31
+ platform TEXT NOT NULL,
32
+ title TEXT NOT NULL,
33
+ source_url TEXT NOT NULL,
34
+ cover_url TEXT,
35
+ duration REAL,
36
+ page_catalog_json TEXT NOT NULL DEFAULT '[]',
37
+ latest_task_id TEXT,
38
+ latest_status TEXT,
39
+ latest_stage TEXT,
40
+ latest_error_message TEXT,
41
+ is_favorite INTEGER NOT NULL DEFAULT 0,
42
+ favorite_updated_at TEXT,
43
+ created_at TEXT NOT NULL,
44
+ updated_at TEXT NOT NULL
45
+ )
46
+ """
47
+ )
48
+ self._ensure_column(cursor, "video_assets", "page_catalog_json", "TEXT NOT NULL DEFAULT '[]'")
49
+ self._ensure_column(cursor, "video_assets", "is_favorite", "INTEGER NOT NULL DEFAULT 0")
50
+ self._ensure_column(cursor, "video_assets", "favorite_updated_at", "TEXT")
51
+ cursor.execute(
52
+ """
53
+ CREATE TABLE IF NOT EXISTS tasks (
54
+ task_id TEXT PRIMARY KEY,
55
+ video_id TEXT,
56
+ status TEXT NOT NULL,
57
+ task_input_json TEXT NOT NULL,
58
+ page_number INTEGER,
59
+ page_title TEXT,
60
+ result_json TEXT,
61
+ error_code TEXT,
62
+ error_message TEXT,
63
+ created_at TEXT NOT NULL,
64
+ updated_at TEXT NOT NULL
65
+ )
66
+ """
67
+ )
68
+ self._ensure_column(cursor, "tasks", "video_id", "TEXT")
69
+ self._ensure_column(cursor, "tasks", "page_number", "INTEGER")
70
+ self._ensure_column(cursor, "tasks", "page_title", "TEXT")
71
+ cursor.execute(
72
+ """
73
+ CREATE TABLE IF NOT EXISTS task_results (
74
+ task_id TEXT PRIMARY KEY,
75
+ result_json TEXT NOT NULL,
76
+ updated_at TEXT NOT NULL,
77
+ FOREIGN KEY(task_id) REFERENCES tasks(task_id)
78
+ )
79
+ """
80
+ )
81
+ cursor.execute(
82
+ """
83
+ CREATE TABLE IF NOT EXISTS task_events (
84
+ event_id TEXT PRIMARY KEY,
85
+ task_id TEXT NOT NULL,
86
+ stage TEXT NOT NULL,
87
+ progress INTEGER NOT NULL,
88
+ message TEXT NOT NULL,
89
+ payload_json TEXT NOT NULL,
90
+ created_at TEXT NOT NULL,
91
+ FOREIGN KEY(task_id) REFERENCES tasks(task_id)
92
+ )
93
+ """
94
+ )
95
+ cursor.execute(
96
+ """
97
+ CREATE TABLE IF NOT EXISTS video_tags (
98
+ video_id TEXT NOT NULL,
99
+ tag TEXT NOT NULL,
100
+ source TEXT NOT NULL DEFAULT 'manual',
101
+ confidence REAL NOT NULL DEFAULT 1.0,
102
+ created_at TEXT NOT NULL,
103
+ PRIMARY KEY (video_id, tag),
104
+ FOREIGN KEY(video_id) REFERENCES video_assets(video_id)
105
+ )
106
+ """
107
+ )
108
+ cursor.execute(
109
+ """
110
+ CREATE TABLE IF NOT EXISTS knowledge_index (
111
+ chunk_id TEXT PRIMARY KEY,
112
+ video_id TEXT NOT NULL,
113
+ embedding_json TEXT NOT NULL,
114
+ indexed_content TEXT NOT NULL,
115
+ index_type TEXT NOT NULL DEFAULT 'video_summary',
116
+ segment_order INTEGER,
117
+ anchor_label TEXT,
118
+ anchor_seconds REAL,
119
+ created_at TEXT NOT NULL,
120
+ updated_at TEXT NOT NULL,
121
+ FOREIGN KEY(video_id) REFERENCES video_assets(video_id)
122
+ )
123
+ """
124
+ )
125
+ self._ensure_column(cursor, "knowledge_index", "chunk_id", "TEXT")
126
+ self._ensure_column(cursor, "knowledge_index", "video_id", "TEXT NOT NULL DEFAULT ''")
127
+ self._ensure_column(cursor, "knowledge_index", "embedding_json", "TEXT NOT NULL DEFAULT '[]'")
128
+ self._ensure_column(cursor, "knowledge_index", "indexed_content", "TEXT NOT NULL DEFAULT ''")
129
+ self._ensure_column(cursor, "knowledge_index", "index_type", "TEXT NOT NULL DEFAULT 'video_summary'")
130
+ self._ensure_column(cursor, "knowledge_index", "segment_order", "INTEGER")
131
+ self._ensure_column(cursor, "knowledge_index", "anchor_label", "TEXT")
132
+ self._ensure_column(cursor, "knowledge_index", "anchor_seconds", "REAL")
133
+ self._ensure_column(cursor, "knowledge_index", "created_at", "TEXT")
134
+ self._ensure_column(cursor, "knowledge_index", "updated_at", "TEXT")
135
+
136
+ def _ensure_column(self, cursor: sqlite3.Cursor, table: str, column: str, definition: str) -> None:
137
+ rows = cursor.execute(f"PRAGMA table_info({table})").fetchall()
138
+ names = {row["name"] if isinstance(row, sqlite3.Row) else row[1] for row in rows}
139
+ if column not in names:
140
+ cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
141
+
142
+ def _canonical_family_pattern(self, canonical_id: str) -> tuple[str, str]:
143
+ base = str(canonical_id or "").split("?", 1)[0]
144
+ return base, f"{base}?p=%"
145
+
146
+ def _consolidate_video_family(self, cursor: sqlite3.Cursor, canonical_id: str) -> tuple[str, str] | None:
147
+ family, pattern = self._canonical_family_pattern(canonical_id)
148
+ rows = cursor.execute(
149
+ """
150
+ SELECT video_id, canonical_id, created_at, updated_at
151
+ FROM video_assets
152
+ WHERE canonical_id = ? OR canonical_id LIKE ?
153
+ ORDER BY
154
+ CASE WHEN canonical_id = ? THEN 0 ELSE 1 END,
155
+ updated_at DESC,
156
+ created_at ASC
157
+ """,
158
+ (family, pattern, family),
159
+ ).fetchall()
160
+ if not rows:
161
+ return None
162
+
163
+ primary = rows[0]
164
+ primary_video_id = primary["video_id"]
165
+ created_at = primary["created_at"]
166
+
167
+ if primary["canonical_id"] != family:
168
+ cursor.execute(
169
+ "UPDATE video_assets SET canonical_id = ? WHERE video_id = ?",
170
+ (family, primary_video_id),
171
+ )
172
+
173
+ duplicate_ids = [row["video_id"] for row in rows[1:]]
174
+ for duplicate_id in duplicate_ids:
175
+ cursor.execute(
176
+ "UPDATE tasks SET video_id = ? WHERE video_id = ?",
177
+ (primary_video_id, duplicate_id),
178
+ )
179
+ cursor.execute("DELETE FROM video_assets WHERE video_id = ?", (duplicate_id,))
180
+
181
+ return primary_video_id, created_at
182
+
183
+ def upsert_video_asset(self, asset: VideoAssetRecord) -> VideoAssetRecord:
184
+ updated_at = datetime.now(timezone.utc).isoformat()
185
+ created_at = asset.created_at.isoformat()
186
+ with self._lock, sqlite_cursor(self._connection) as cursor:
187
+ consolidated = self._consolidate_video_family(cursor, asset.canonical_id)
188
+ existing = cursor.execute(
189
+ "SELECT video_id, created_at FROM video_assets WHERE canonical_id = ?",
190
+ (asset.canonical_id,),
191
+ ).fetchone()
192
+ if existing is not None:
193
+ video_id = existing["video_id"]
194
+ created = existing["created_at"]
195
+ elif consolidated is not None:
196
+ video_id, created = consolidated
197
+ else:
198
+ video_id = asset.video_id
199
+ created = created_at
200
+ cursor.execute(
201
+ """
202
+ INSERT INTO video_assets (
203
+ video_id, canonical_id, platform, title, source_url, cover_url, duration, page_catalog_json,
204
+ latest_task_id, latest_status, latest_stage, latest_error_message, is_favorite, favorite_updated_at,
205
+ created_at, updated_at
206
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
207
+ ON CONFLICT(canonical_id) DO UPDATE SET
208
+ title = excluded.title,
209
+ source_url = excluded.source_url,
210
+ cover_url = excluded.cover_url,
211
+ duration = excluded.duration,
212
+ page_catalog_json = excluded.page_catalog_json,
213
+ updated_at = excluded.updated_at
214
+ """,
215
+ (
216
+ video_id,
217
+ asset.canonical_id,
218
+ asset.platform,
219
+ asset.title,
220
+ asset.source_url,
221
+ asset.cover_url,
222
+ asset.duration,
223
+ json.dumps([page.model_dump(mode="json") for page in asset.pages], ensure_ascii=False),
224
+ asset.latest_task_id,
225
+ asset.latest_status.value if asset.latest_status else None,
226
+ asset.latest_stage,
227
+ asset.latest_error_message,
228
+ 1 if asset.is_favorite else 0,
229
+ asset.favorite_updated_at.isoformat() if asset.favorite_updated_at else None,
230
+ created,
231
+ updated_at,
232
+ ),
233
+ )
234
+ refreshed = self.get_video_asset(video_id)
235
+ assert refreshed is not None
236
+ return refreshed
237
+
238
+ def get_video_asset(self, video_id: str) -> VideoAssetRecord | None:
239
+ with self._lock, sqlite_cursor(self._connection) as cursor:
240
+ row = cursor.execute(
241
+ """
242
+ SELECT
243
+ v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
244
+ v.page_catalog_json,
245
+ v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
246
+ v.is_favorite, v.favorite_updated_at,
247
+ v.created_at, v.updated_at, r.result_json AS latest_result_json
248
+ FROM video_assets v
249
+ LEFT JOIN task_results r ON r.task_id = v.latest_task_id
250
+ WHERE v.video_id = ?
251
+ """,
252
+ (video_id,),
253
+ ).fetchone()
254
+ return self._row_to_video_asset(row) if row is not None else None
255
+
256
+ def get_video_asset_by_canonical_id(self, canonical_id: str) -> VideoAssetRecord | None:
257
+ with self._lock, sqlite_cursor(self._connection) as cursor:
258
+ row = cursor.execute(
259
+ """
260
+ SELECT
261
+ v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
262
+ v.page_catalog_json,
263
+ v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
264
+ v.is_favorite, v.favorite_updated_at,
265
+ v.created_at, v.updated_at, r.result_json AS latest_result_json
266
+ FROM video_assets v
267
+ LEFT JOIN task_results r ON r.task_id = v.latest_task_id
268
+ WHERE v.canonical_id = ?
269
+ """,
270
+ (canonical_id,),
271
+ ).fetchone()
272
+ return self._row_to_video_asset(row) if row is not None else None
273
+
274
+ def list_video_assets(self) -> list[VideoAssetRecord]:
275
+ with self._lock, sqlite_cursor(self._connection) as cursor:
276
+ rows = cursor.execute(
277
+ """
278
+ SELECT
279
+ v.video_id, v.canonical_id, v.platform, v.title, v.source_url, v.cover_url, v.duration,
280
+ v.page_catalog_json,
281
+ v.latest_task_id, v.latest_status, v.latest_stage, v.latest_error_message,
282
+ v.is_favorite, v.favorite_updated_at,
283
+ v.created_at, v.updated_at, r.result_json AS latest_result_json
284
+ FROM video_assets v
285
+ LEFT JOIN task_results r ON r.task_id = v.latest_task_id
286
+ ORDER BY v.updated_at DESC
287
+ """
288
+ ).fetchall()
289
+ videos = [self._row_to_video_asset(row) for row in rows]
290
+ grouped: dict[str, VideoAssetRecord] = {}
291
+ for video in videos:
292
+ family, _ = self._canonical_family_pattern(video.canonical_id)
293
+ current = grouped.get(family)
294
+ if current is None:
295
+ grouped[family] = video
296
+ continue
297
+ current_updated = current.updated_at.timestamp()
298
+ video_updated = video.updated_at.timestamp()
299
+ if video_updated > current_updated or ("?p=" in current.canonical_id and "?p=" not in video.canonical_id):
300
+ grouped[family] = video
301
+ return list(grouped.values())
302
+
303
+ def create_task(
304
+ self,
305
+ task_input: TaskInput,
306
+ video_id: str | None = None,
307
+ *,
308
+ page_number: int | None = None,
309
+ page_title: str | None = None,
310
+ ) -> TaskRecord:
311
+ record = TaskRecord(
312
+ task_input=task_input,
313
+ video_id=video_id,
314
+ page_number=page_number,
315
+ page_title=page_title,
316
+ )
317
+ payload = self._serialize_record(record)
318
+ with self._lock, sqlite_cursor(self._connection) as cursor:
319
+ cursor.execute(
320
+ """
321
+ INSERT INTO tasks (
322
+ task_id, video_id, status, task_input_json, page_number, page_title, result_json, error_code,
323
+ error_message, created_at, updated_at
324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
325
+ """,
326
+ (
327
+ payload["task_id"],
328
+ payload["video_id"],
329
+ payload["status"],
330
+ payload["task_input_json"],
331
+ payload["page_number"],
332
+ payload["page_title"],
333
+ payload["result_json"],
334
+ payload["error_code"],
335
+ payload["error_message"],
336
+ payload["created_at"],
337
+ payload["updated_at"],
338
+ ),
339
+ )
340
+ if video_id:
341
+ cursor.execute(
342
+ """
343
+ UPDATE video_assets
344
+ SET latest_task_id = ?, latest_status = ?, latest_stage = ?, latest_error_message = NULL, updated_at = ?
345
+ WHERE video_id = ?
346
+ """,
347
+ (record.task_id, record.status.value, "queued", payload["updated_at"], video_id),
348
+ )
349
+ return record
350
+
351
+ def list_tasks(self) -> list[TaskRecord]:
352
+ with self._lock, sqlite_cursor(self._connection) as cursor:
353
+ rows = cursor.execute(
354
+ """
355
+ SELECT
356
+ t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
357
+ r.result_json, t.error_code,
358
+ t.error_message, t.created_at, t.updated_at
359
+ FROM tasks t
360
+ LEFT JOIN task_results r ON r.task_id = t.task_id
361
+ ORDER BY t.created_at DESC
362
+ """
363
+ ).fetchall()
364
+ return [self._row_to_record(row) for row in rows]
365
+
366
+ def list_tasks_for_video(self, video_id: str) -> list[TaskRecord]:
367
+ with self._lock, sqlite_cursor(self._connection) as cursor:
368
+ asset_row = cursor.execute(
369
+ "SELECT canonical_id FROM video_assets WHERE video_id = ?",
370
+ (video_id,),
371
+ ).fetchone()
372
+ if asset_row is None:
373
+ return []
374
+ family, pattern = self._canonical_family_pattern(asset_row["canonical_id"])
375
+ rows = cursor.execute(
376
+ """
377
+ SELECT
378
+ t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
379
+ r.result_json, t.error_code,
380
+ t.error_message, t.created_at, t.updated_at
381
+ FROM tasks t
382
+ JOIN video_assets v ON v.video_id = t.video_id
383
+ LEFT JOIN task_results r ON r.task_id = t.task_id
384
+ WHERE v.canonical_id = ? OR v.canonical_id LIKE ?
385
+ ORDER BY t.created_at DESC
386
+ """,
387
+ (family, pattern),
388
+ ).fetchall()
389
+ return [self._row_to_record(row) for row in rows]
390
+
391
+ def get_task(self, task_id: str) -> TaskRecord | None:
392
+ with self._lock, sqlite_cursor(self._connection) as cursor:
393
+ row = cursor.execute(
394
+ """
395
+ SELECT
396
+ t.task_id, t.video_id, t.status, t.task_input_json, t.page_number, t.page_title,
397
+ r.result_json, t.error_code,
398
+ t.error_message, t.created_at, t.updated_at
399
+ FROM tasks t
400
+ LEFT JOIN task_results r ON r.task_id = t.task_id
401
+ WHERE t.task_id = ?
402
+ """,
403
+ (task_id,),
404
+ ).fetchone()
405
+ return self._row_to_record(row) if row is not None else None
406
+
407
+ def delete_task(self, task_id: str) -> bool:
408
+ with self._lock, sqlite_cursor(self._connection) as cursor:
409
+ row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
410
+ if row is None:
411
+ return False
412
+ video_id = row["video_id"]
413
+ cursor.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
414
+ cursor.execute("DELETE FROM task_results WHERE task_id = ?", (task_id,))
415
+ cursor.execute("DELETE FROM tasks WHERE task_id = ?", (task_id,))
416
+ if video_id:
417
+ latest = cursor.execute(
418
+ """
419
+ SELECT task_id, status, error_message, updated_at
420
+ FROM tasks
421
+ WHERE video_id = ?
422
+ ORDER BY updated_at DESC
423
+ LIMIT 1
424
+ """,
425
+ (video_id,),
426
+ ).fetchone()
427
+ if latest is None:
428
+ cursor.execute(
429
+ """
430
+ UPDATE video_assets
431
+ SET latest_task_id = NULL, latest_status = NULL, latest_stage = NULL,
432
+ latest_error_message = NULL, updated_at = ?
433
+ WHERE video_id = ?
434
+ """,
435
+ (datetime.now(timezone.utc).isoformat(), video_id),
436
+ )
437
+ else:
438
+ cursor.execute(
439
+ """
440
+ UPDATE video_assets
441
+ SET latest_task_id = ?, latest_status = ?, latest_error_message = ?, updated_at = ?
442
+ WHERE video_id = ?
443
+ """,
444
+ (
445
+ latest["task_id"],
446
+ latest["status"],
447
+ latest["error_message"],
448
+ latest["updated_at"],
449
+ video_id,
450
+ ),
451
+ )
452
+ return True
453
+
454
+ def delete_video_asset(self, video_id: str) -> bool:
455
+ with self._lock, sqlite_cursor(self._connection) as cursor:
456
+ video_row = cursor.execute(
457
+ "SELECT canonical_id, cover_url FROM video_assets WHERE video_id = ?",
458
+ (video_id,),
459
+ ).fetchone()
460
+ if video_row is None:
461
+ return False
462
+
463
+ family, pattern = self._canonical_family_pattern(video_row["canonical_id"])
464
+ family_rows = cursor.execute(
465
+ "SELECT video_id FROM video_assets WHERE canonical_id = ? OR canonical_id LIKE ?",
466
+ (family, pattern),
467
+ ).fetchall()
468
+ video_ids = [row["video_id"] for row in family_rows]
469
+
470
+ placeholders = ",".join("?" for _ in video_ids)
471
+ task_rows = cursor.execute(
472
+ f"SELECT task_id FROM tasks WHERE video_id IN ({placeholders})",
473
+ tuple(video_ids),
474
+ ).fetchall()
475
+ task_ids = [row["task_id"] for row in task_rows]
476
+
477
+ for task_id in task_ids:
478
+ cursor.execute("DELETE FROM task_events WHERE task_id = ?", (task_id,))
479
+ cursor.execute("DELETE FROM task_results WHERE task_id = ?", (task_id,))
480
+ cursor.execute(f"DELETE FROM video_tags WHERE video_id IN ({placeholders})", tuple(video_ids))
481
+ cursor.execute(f"DELETE FROM knowledge_index WHERE video_id IN ({placeholders})", tuple(video_ids))
482
+ cursor.execute(f"DELETE FROM tasks WHERE video_id IN ({placeholders})", tuple(video_ids))
483
+ cursor.execute(f"DELETE FROM video_assets WHERE video_id IN ({placeholders})", tuple(video_ids))
484
+ return True
485
+
486
+ def add_video_tag(self, video_id: str, tag: str, source: str = "manual", confidence: float = 1.0) -> bool:
487
+ normalized_tag = str(tag or "").strip()
488
+ if not normalized_tag:
489
+ return False
490
+
491
+ created_at = datetime.now(timezone.utc).isoformat()
492
+ with self._lock, sqlite_cursor(self._connection) as cursor:
493
+ video = cursor.execute("SELECT video_id FROM video_assets WHERE video_id = ?", (video_id,)).fetchone()
494
+ if video is None:
495
+ return False
496
+ cursor.execute(
497
+ """
498
+ INSERT INTO video_tags (video_id, tag, source, confidence, created_at)
499
+ VALUES (?, ?, ?, ?, ?)
500
+ ON CONFLICT(video_id, tag) DO UPDATE SET
501
+ source = CASE
502
+ WHEN excluded.source = 'manual' THEN 'manual'
503
+ ELSE video_tags.source
504
+ END,
505
+ confidence = CASE
506
+ WHEN excluded.source = 'manual' THEN 1.0
507
+ ELSE excluded.confidence
508
+ END,
509
+ created_at = CASE
510
+ WHEN excluded.source = 'manual' THEN excluded.created_at
511
+ ELSE video_tags.created_at
512
+ END
513
+ """,
514
+ (video_id, normalized_tag, source, float(confidence), created_at),
515
+ )
516
+ return True
517
+
518
+ def remove_video_tag(self, video_id: str, tag: str) -> bool:
519
+ normalized_tag = str(tag or "").strip()
520
+ if not normalized_tag:
521
+ return False
522
+ with self._lock, sqlite_cursor(self._connection) as cursor:
523
+ cursor.execute("DELETE FROM video_tags WHERE video_id = ? AND tag = ?", (video_id, normalized_tag))
524
+ return cursor.rowcount > 0
525
+
526
+ def list_video_tags(self, video_id: str) -> list[VideoTagRecord]:
527
+ with self._lock, sqlite_cursor(self._connection) as cursor:
528
+ rows = cursor.execute(
529
+ """
530
+ SELECT video_id, tag, source, confidence, created_at
531
+ FROM video_tags
532
+ WHERE video_id = ?
533
+ ORDER BY source = 'manual' DESC, confidence DESC, tag COLLATE NOCASE ASC
534
+ """,
535
+ (video_id,),
536
+ ).fetchall()
537
+ return [
538
+ VideoTagRecord(
539
+ video_id=row["video_id"],
540
+ tag=row["tag"],
541
+ source=row["source"],
542
+ confidence=float(row["confidence"]),
543
+ created_at=datetime.fromisoformat(row["created_at"]),
544
+ )
545
+ for row in rows
546
+ ]
547
+
548
+ def list_all_tags(self) -> list[dict[str, object]]:
549
+ with self._lock, sqlite_cursor(self._connection) as cursor:
550
+ rows = cursor.execute(
551
+ """
552
+ SELECT tag, COUNT(*) AS count
553
+ FROM video_tags
554
+ GROUP BY tag
555
+ ORDER BY count DESC, tag COLLATE NOCASE ASC
556
+ """
557
+ ).fetchall()
558
+ videos_by_tag_rows = cursor.execute(
559
+ """
560
+ SELECT tag, video_id
561
+ FROM video_tags
562
+ ORDER BY tag COLLATE NOCASE ASC, video_id ASC
563
+ """
564
+ ).fetchall()
565
+ videos_by_tag: dict[str, list[str]] = {}
566
+ for row in videos_by_tag_rows:
567
+ videos_by_tag.setdefault(row["tag"], []).append(row["video_id"])
568
+ return [
569
+ {"tag": row["tag"], "count": int(row["count"]), "videos": videos_by_tag.get(row["tag"], [])}
570
+ for row in rows
571
+ ]
572
+
573
+ def list_all_video_tags(self) -> list[VideoTagRecord]:
574
+ with self._lock, sqlite_cursor(self._connection) as cursor:
575
+ rows = cursor.execute(
576
+ """
577
+ SELECT video_id, tag, source, confidence, created_at
578
+ FROM video_tags
579
+ ORDER BY tag COLLATE NOCASE ASC, confidence DESC, video_id ASC
580
+ """
581
+ ).fetchall()
582
+ return [
583
+ VideoTagRecord(
584
+ video_id=row["video_id"],
585
+ tag=row["tag"],
586
+ source=row["source"],
587
+ confidence=float(row["confidence"]),
588
+ created_at=datetime.fromisoformat(row["created_at"]),
589
+ )
590
+ for row in rows
591
+ ]
592
+
593
+ def list_untagged_video_ids(self) -> list[str]:
594
+ with self._lock, sqlite_cursor(self._connection) as cursor:
595
+ rows = cursor.execute(
596
+ """
597
+ SELECT v.video_id
598
+ FROM video_assets v
599
+ LEFT JOIN video_tags t ON t.video_id = v.video_id
600
+ WHERE t.video_id IS NULL
601
+ ORDER BY v.updated_at DESC
602
+ """
603
+ ).fetchall()
604
+ return [row["video_id"] for row in rows]
605
+
606
+ def replace_knowledge_chunks(self, video_id: str, chunks: list[KnowledgeIndexChunkRecord]) -> int:
607
+ with self._lock, sqlite_cursor(self._connection) as cursor:
608
+ cursor.execute("DELETE FROM knowledge_index WHERE video_id = ?", (video_id,))
609
+ for chunk in chunks:
610
+ cursor.execute(
611
+ """
612
+ INSERT INTO knowledge_index (
613
+ chunk_id, video_id, embedding_json, indexed_content, index_type,
614
+ segment_order, anchor_label, anchor_seconds, created_at, updated_at
615
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
616
+ """,
617
+ (
618
+ chunk.chunk_id,
619
+ chunk.video_id,
620
+ chunk.embedding_json,
621
+ chunk.indexed_content,
622
+ chunk.index_type,
623
+ chunk.segment_order,
624
+ chunk.anchor_label,
625
+ chunk.anchor_seconds,
626
+ chunk.created_at.isoformat(),
627
+ chunk.updated_at.isoformat(),
628
+ ),
629
+ )
630
+ return len(chunks)
631
+
632
+ def delete_knowledge_chunks(self, video_id: str) -> bool:
633
+ with self._lock, sqlite_cursor(self._connection) as cursor:
634
+ cursor.execute("DELETE FROM knowledge_index WHERE video_id = ?", (video_id,))
635
+ return cursor.rowcount > 0
636
+
637
+ def clear_knowledge_chunks(self) -> None:
638
+ with self._lock, sqlite_cursor(self._connection) as cursor:
639
+ cursor.execute("DELETE FROM knowledge_index")
640
+
641
+ def list_knowledge_chunks(self, video_id: str | None = None) -> list[KnowledgeIndexChunkRecord]:
642
+ with self._lock, sqlite_cursor(self._connection) as cursor:
643
+ if video_id:
644
+ rows = cursor.execute(
645
+ """
646
+ SELECT
647
+ chunk_id, video_id, embedding_json, indexed_content, index_type,
648
+ segment_order, anchor_label, anchor_seconds, created_at, updated_at
649
+ FROM knowledge_index
650
+ WHERE video_id = ?
651
+ ORDER BY segment_order ASC, updated_at DESC
652
+ """,
653
+ (video_id,),
654
+ ).fetchall()
655
+ else:
656
+ rows = cursor.execute(
657
+ """
658
+ SELECT
659
+ chunk_id, video_id, embedding_json, indexed_content, index_type,
660
+ segment_order, anchor_label, anchor_seconds, created_at, updated_at
661
+ FROM knowledge_index
662
+ ORDER BY updated_at DESC
663
+ """
664
+ ).fetchall()
665
+ return [
666
+ KnowledgeIndexChunkRecord(
667
+ chunk_id=row["chunk_id"],
668
+ video_id=row["video_id"],
669
+ embedding_json=row["embedding_json"],
670
+ indexed_content=row["indexed_content"],
671
+ index_type=row["index_type"],
672
+ segment_order=row["segment_order"],
673
+ anchor_label=row["anchor_label"],
674
+ anchor_seconds=row["anchor_seconds"],
675
+ created_at=datetime.fromisoformat(row["created_at"]),
676
+ updated_at=datetime.fromisoformat(row["updated_at"]),
677
+ )
678
+ for row in rows
679
+ ]
680
+
681
+ def get_knowledge_chunk_count(self) -> int:
682
+ with self._lock, sqlite_cursor(self._connection) as cursor:
683
+ row = cursor.execute("SELECT COUNT(*) AS count FROM knowledge_index").fetchone()
684
+ return int(row["count"]) if row is not None else 0
685
+
686
+ def set_video_favorite(self, video_id: str, is_favorite: bool) -> VideoAssetRecord | None:
687
+ favorite_updated_at = datetime.now(timezone.utc).isoformat() if is_favorite else None
688
+ with self._lock, sqlite_cursor(self._connection) as cursor:
689
+ row = cursor.execute("SELECT video_id FROM video_assets WHERE video_id = ?", (video_id,)).fetchone()
690
+ if row is None:
691
+ return None
692
+ cursor.execute(
693
+ """
694
+ UPDATE video_assets
695
+ SET is_favorite = ?, favorite_updated_at = ?
696
+ WHERE video_id = ?
697
+ """,
698
+ (1 if is_favorite else 0, favorite_updated_at, video_id),
699
+ )
700
+ return self.get_video_asset(video_id)
701
+
702
+ def update_status(self, task_id: str, status: TaskStatus) -> TaskRecord | None:
703
+ updated_at = datetime.now(timezone.utc).isoformat()
704
+ with self._lock, sqlite_cursor(self._connection) as cursor:
705
+ cursor.execute(
706
+ "UPDATE tasks SET status = ?, updated_at = ? WHERE task_id = ?",
707
+ (status.value, updated_at, task_id),
708
+ )
709
+ row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
710
+ if row is not None and row["video_id"]:
711
+ cursor.execute(
712
+ """
713
+ UPDATE video_assets
714
+ SET latest_task_id = ?, latest_status = ?, updated_at = ?
715
+ WHERE video_id = ?
716
+ """,
717
+ (task_id, status.value, updated_at, row["video_id"]),
718
+ )
719
+ return self.get_task(task_id)
720
+
721
+ def save_result(self, task_id: str, result: TaskResult) -> TaskRecord | None:
722
+ updated_at = datetime.now(timezone.utc).isoformat()
723
+ payload = json.dumps(result.model_dump(mode="json"), ensure_ascii=False)
724
+ with self._lock, sqlite_cursor(self._connection) as cursor:
725
+ cursor.execute(
726
+ """
727
+ INSERT INTO task_results (task_id, result_json, updated_at)
728
+ VALUES (?, ?, ?)
729
+ ON CONFLICT(task_id) DO UPDATE SET result_json = excluded.result_json, updated_at = excluded.updated_at
730
+ """,
731
+ (task_id, payload, updated_at),
732
+ )
733
+ cursor.execute("UPDATE tasks SET updated_at = ? WHERE task_id = ?", (updated_at, task_id))
734
+ row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
735
+ if row is not None and row["video_id"]:
736
+ cursor.execute(
737
+ """
738
+ UPDATE video_assets
739
+ SET latest_task_id = ?, updated_at = ?
740
+ WHERE video_id = ?
741
+ """,
742
+ (task_id, updated_at, row["video_id"]),
743
+ )
744
+ return self.get_task(task_id)
745
+
746
+ def update_error(self, task_id: str, error_code: str, error_message: str) -> TaskRecord | None:
747
+ updated_at = datetime.now(timezone.utc).isoformat()
748
+ with self._lock, sqlite_cursor(self._connection) as cursor:
749
+ cursor.execute(
750
+ """
751
+ UPDATE tasks SET error_code = ?, error_message = ?, updated_at = ? WHERE task_id = ?
752
+ """,
753
+ (error_code, error_message, updated_at, task_id),
754
+ )
755
+ row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
756
+ if row is not None and row["video_id"]:
757
+ cursor.execute(
758
+ """
759
+ UPDATE video_assets
760
+ SET latest_task_id = ?, latest_error_message = ?, updated_at = ?
761
+ WHERE video_id = ?
762
+ """,
763
+ (task_id, error_message, updated_at, row["video_id"]),
764
+ )
765
+ return self.get_task(task_id)
766
+
767
+ def append_event(
768
+ self,
769
+ task_id: str,
770
+ stage: str,
771
+ progress: int,
772
+ message: str,
773
+ payload: dict[str, object] | None = None,
774
+ ) -> TaskEventRecord:
775
+ event = TaskEventRecord(task_id=task_id, stage=stage, progress=progress, message=message, payload=payload or {})
776
+ updated_at = datetime.now(timezone.utc).isoformat()
777
+ with self._lock, sqlite_cursor(self._connection) as cursor:
778
+ latest_row = cursor.execute(
779
+ """
780
+ SELECT created_at
781
+ FROM task_events
782
+ WHERE task_id = ?
783
+ ORDER BY created_at DESC
784
+ LIMIT 1
785
+ """,
786
+ (task_id,),
787
+ ).fetchone()
788
+ if latest_row is not None:
789
+ latest_created_at = datetime.fromisoformat(latest_row["created_at"])
790
+ if event.created_at <= latest_created_at:
791
+ event.created_at = latest_created_at + timedelta(microseconds=1)
792
+ cursor.execute(
793
+ """
794
+ INSERT INTO task_events (event_id, task_id, stage, progress, message, payload_json, created_at)
795
+ VALUES (?, ?, ?, ?, ?, ?, ?)
796
+ """,
797
+ (
798
+ event.event_id,
799
+ event.task_id,
800
+ event.stage,
801
+ event.progress,
802
+ event.message,
803
+ json.dumps(event.payload, ensure_ascii=False),
804
+ event.created_at.isoformat(),
805
+ ),
806
+ )
807
+ row = cursor.execute("SELECT video_id FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
808
+ if row is not None and row["video_id"]:
809
+ cursor.execute(
810
+ """
811
+ UPDATE video_assets
812
+ SET latest_stage = ?, updated_at = ?
813
+ WHERE video_id = ?
814
+ """,
815
+ (stage, updated_at, row["video_id"]),
816
+ )
817
+ return event
818
+
819
+ def list_events(self, task_id: str) -> list[TaskEventRecord]:
820
+ with self._lock, sqlite_cursor(self._connection) as cursor:
821
+ rows = cursor.execute(
822
+ """
823
+ SELECT event_id, task_id, stage, progress, message, payload_json, created_at
824
+ FROM task_events
825
+ WHERE task_id = ?
826
+ ORDER BY created_at ASC
827
+ """,
828
+ (task_id,),
829
+ ).fetchall()
830
+ return [self._row_to_event(row) for row in rows]
831
+
832
+ def list_events_after(self, task_id: str, after_created_at: str | None) -> list[TaskEventRecord]:
833
+ with self._lock, sqlite_cursor(self._connection) as cursor:
834
+ if after_created_at:
835
+ rows = cursor.execute(
836
+ """
837
+ SELECT event_id, task_id, stage, progress, message, payload_json, created_at
838
+ FROM task_events
839
+ WHERE task_id = ? AND created_at > ?
840
+ ORDER BY created_at ASC
841
+ """,
842
+ (task_id, after_created_at),
843
+ ).fetchall()
844
+ else:
845
+ rows = cursor.execute(
846
+ """
847
+ SELECT event_id, task_id, stage, progress, message, payload_json, created_at
848
+ FROM task_events
849
+ WHERE task_id = ?
850
+ ORDER BY created_at ASC
851
+ """,
852
+ (task_id,),
853
+ ).fetchall()
854
+ return [self._row_to_event(row) for row in rows]
855
+
856
+ def get_latest_event(self, task_id: str) -> TaskEventRecord | None:
857
+ with self._lock, sqlite_cursor(self._connection) as cursor:
858
+ row = cursor.execute(
859
+ """
860
+ SELECT event_id, task_id, stage, progress, message, payload_json, created_at
861
+ FROM task_events WHERE task_id = ?
862
+ ORDER BY created_at DESC LIMIT 1
863
+ """,
864
+ (task_id,),
865
+ ).fetchone()
866
+ return self._row_to_event(row) if row is not None else None
867
+
868
+ def _serialize_record(self, record: TaskRecord) -> dict[str, str | None]:
869
+ return {
870
+ "task_id": record.task_id,
871
+ "video_id": record.video_id,
872
+ "status": record.status.value,
873
+ "task_input_json": json.dumps(record.task_input.model_dump(mode="json"), ensure_ascii=False),
874
+ "page_number": str(record.page_number) if record.page_number is not None else None,
875
+ "page_title": record.page_title,
876
+ "result_json": (
877
+ json.dumps(record.result.model_dump(mode="json"), ensure_ascii=False)
878
+ if record.result is not None
879
+ else None
880
+ ),
881
+ "error_code": record.error_code,
882
+ "error_message": record.error_message,
883
+ "created_at": record.created_at.isoformat(),
884
+ "updated_at": record.updated_at.isoformat(),
885
+ }
886
+
887
+ def _row_to_record(self, row: sqlite3.Row) -> TaskRecord:
888
+ task_input = TaskInput.model_validate(json.loads(row["task_input_json"]))
889
+ result = TaskResult.model_validate(json.loads(row["result_json"])) if row["result_json"] else None
890
+ return TaskRecord(
891
+ task_id=row["task_id"],
892
+ video_id=row["video_id"],
893
+ status=TaskStatus(row["status"]),
894
+ task_input=task_input,
895
+ page_number=row["page_number"],
896
+ page_title=row["page_title"],
897
+ created_at=datetime.fromisoformat(row["created_at"]),
898
+ updated_at=datetime.fromisoformat(row["updated_at"]),
899
+ result=result,
900
+ error_code=row["error_code"],
901
+ error_message=row["error_message"],
902
+ )
903
+
904
+ def _row_to_video_asset(self, row: sqlite3.Row) -> VideoAssetRecord:
905
+ latest_result = (
906
+ TaskResult.model_validate(json.loads(row["latest_result_json"]))
907
+ if row["latest_result_json"]
908
+ else None
909
+ )
910
+ return VideoAssetRecord(
911
+ video_id=row["video_id"],
912
+ canonical_id=row["canonical_id"],
913
+ platform=row["platform"],
914
+ title=row["title"],
915
+ source_url=row["source_url"],
916
+ cover_url=row["cover_url"] or "",
917
+ duration=row["duration"],
918
+ pages=[
919
+ VideoPageOptionResponse.model_validate(item)
920
+ for item in json.loads(row["page_catalog_json"] or "[]")
921
+ ],
922
+ latest_task_id=row["latest_task_id"],
923
+ latest_status=TaskStatus(row["latest_status"]) if row["latest_status"] else None,
924
+ latest_stage=row["latest_stage"],
925
+ latest_result=latest_result,
926
+ latest_error_message=row["latest_error_message"],
927
+ is_favorite=bool(row["is_favorite"]),
928
+ favorite_updated_at=datetime.fromisoformat(row["favorite_updated_at"]) if row["favorite_updated_at"] else None,
929
+ created_at=datetime.fromisoformat(row["created_at"]),
930
+ updated_at=datetime.fromisoformat(row["updated_at"]),
931
+ )
932
+
933
+ def _row_to_event(self, row: sqlite3.Row) -> TaskEventRecord:
934
+ return TaskEventRecord(
935
+ event_id=row["event_id"],
936
+ task_id=row["task_id"],
937
+ stage=row["stage"],
938
+ progress=row["progress"],
939
+ message=row["message"],
940
+ created_at=datetime.fromisoformat(row["created_at"]),
941
+ payload=json.loads(row["payload_json"]),
942
+ )