videonote 0.1.7__py3-none-any.whl
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.
- app/__init__.py +2 -0
- app/db/__init__.py +0 -0
- app/db/builtin_providers.json +68 -0
- app/db/engine.py +71 -0
- app/db/init_db.py +60 -0
- app/db/model_dao.py +55 -0
- app/db/models/__init__.py +0 -0
- app/db/models/models.py +12 -0
- app/db/models/providers.py +16 -0
- app/db/models/video_tasks.py +18 -0
- app/db/provider_dao.py +147 -0
- app/db/video_task_dao.py +159 -0
- app/decorators/__init__.py +0 -0
- app/decorators/timeit.py +20 -0
- app/downloaders/__init__.py +0 -0
- app/downloaders/base.py +53 -0
- app/downloaders/bilibili_comment.py +293 -0
- app/downloaders/bilibili_dm_patch.py +77 -0
- app/downloaders/bilibili_downloader.py +404 -0
- app/downloaders/bilibili_subtitle.py +205 -0
- app/downloaders/common.py +127 -0
- app/downloaders/douyin_downloader.py +364 -0
- app/downloaders/douyin_helper/__init__.py +0 -0
- app/downloaders/douyin_helper/abogus.py +588 -0
- app/downloaders/generic_downloader.py +133 -0
- app/downloaders/kuaishou_downloader.py +168 -0
- app/downloaders/kuaishou_helper/__init__.py +0 -0
- app/downloaders/kuaishou_helper/kuaishou.py +115 -0
- app/downloaders/local_downloader.py +180 -0
- app/downloaders/youtube_downloader.py +200 -0
- app/downloaders/youtube_subtitle.py +142 -0
- app/enmus/exception.py +21 -0
- app/enmus/note_enums.py +7 -0
- app/enmus/task_status_enums.py +30 -0
- app/exceptions/__init__.py +0 -0
- app/exceptions/note.py +9 -0
- app/exceptions/provider.py +12 -0
- app/exceptions/task.py +13 -0
- app/gpt/__init__.py +0 -0
- app/gpt/base.py +17 -0
- app/gpt/gpt_factory.py +12 -0
- app/gpt/prompt.py +80 -0
- app/gpt/prompt_builder.py +139 -0
- app/gpt/provider/OpenAI_compatible_provider.py +15 -0
- app/gpt/request_chunker.py +241 -0
- app/gpt/universal_gpt.py +509 -0
- app/models/__init__.py +0 -0
- app/models/audio_model.py +15 -0
- app/models/gpt_model.py +20 -0
- app/models/model_config.py +16 -0
- app/models/notes_model.py +14 -0
- app/models/transcriber_model.py +19 -0
- app/services/constant.py +67 -0
- app/services/cookie_manager.py +65 -0
- app/services/diarization.py +148 -0
- app/services/inspect.py +275 -0
- app/services/merge.py +99 -0
- app/services/note.py +1185 -0
- app/services/note_cache.py +323 -0
- app/services/pipeline.py +520 -0
- app/services/provider.py +213 -0
- app/services/proxy_config_manager.py +60 -0
- app/services/transcriber_config_manager.py +190 -0
- app/transcriber/__init__.py +0 -0
- app/transcriber/audio_preprocess.py +153 -0
- app/transcriber/base.py +14 -0
- app/transcriber/bcut.py +282 -0
- app/transcriber/funasr_transcriber.py +125 -0
- app/transcriber/groq.py +99 -0
- app/transcriber/kuaishou.py +98 -0
- app/transcriber/mlx_whisper_transcriber.py +135 -0
- app/transcriber/model_download_state.py +95 -0
- app/transcriber/transcriber_provider.py +166 -0
- app/transcriber/whisper.py +190 -0
- app/transcriber/whisper_models.py +133 -0
- app/utils/env_checker.py +12 -0
- app/utils/json_store.py +86 -0
- app/utils/logger.py +63 -0
- app/utils/model_status.py +74 -0
- app/utils/note_helper.py +78 -0
- app/utils/openai_client.py +61 -0
- app/utils/path_helper.py +42 -0
- app/utils/screenshot_marker.py +15 -0
- app/utils/task_manifest.py +383 -0
- app/utils/url_parser.py +120 -0
- app/utils/url_safety.py +105 -0
- app/utils/video_helper.py +73 -0
- app/utils/video_reader.py +231 -0
- skills/videonote/SKILL.md +56 -0
- skills/videonote/reference/output-formats.md +57 -0
- skills/videonote/reference/tools.md +163 -0
- skills/videonote/reference/troubleshooting.md +37 -0
- skills/videonote/templates/latex/English Article/README.md +181 -0
- skills/videonote/templates/latex/English Article/image-1.png +0 -0
- skills/videonote/templates/latex/English Article/image.png +0 -0
- skills/videonote/templates/latex/English Article/main.pdf +0 -0
- skills/videonote/templates/latex/English Article/main.tex +153 -0
- skills/videonote/templates/latex/LICENSE-LPPL-1.3c.txt +416 -0
- skills/videonote/templates/latex/Math Note/MathNote.cls +76 -0
- skills/videonote/templates/latex/Math Note/MathNoteCN.cls +79 -0
- skills/videonote/templates/latex/Math Note/README.md +75 -0
- skills/videonote/templates/latex/Math Note/image.png +0 -0
- skills/videonote/templates/latex/Math Note/main.pdf +0 -0
- skills/videonote/templates/latex/Math Note/main.tex +45 -0
- skills/videonote/templates/latex/NOTICE.md +30 -0
- skills/videonote/templates/typst/zju-lab/LICENSE +21 -0
- skills/videonote/templates/typst/zju-lab/README.md +50 -0
- skills/videonote/templates/typst/zju-lab/demo.pdf +8808 -6
- skills/videonote/templates/typst/zju-lab/demo.png +0 -0
- skills/videonote/templates/typst/zju-lab/demo.typ +820 -0
- skills/videonote/templates/typst/zju-lab/example.pdf +1499 -2
- skills/videonote/templates/typst/zju-lab/example.png +0 -0
- skills/videonote/templates/typst/zju-lab/example.typ +53 -0
- skills/videonote/templates/typst/zju-lab/img/ZJU-logo.png +0 -0
- skills/videonote/templates/typst/zju-lab/img/ZJU-name.png +0 -0
- skills/videonote/templates/typst/zju-lab/imports.typ +32 -0
- skills/videonote/templates/typst/zju-lab/template.typ +182 -0
- skills/videonote/templates/typst/zju-lab/works.bib +38 -0
- videonote-0.1.7.dist-info/METADATA +289 -0
- videonote-0.1.7.dist-info/RECORD +134 -0
- videonote-0.1.7.dist-info/WHEEL +4 -0
- videonote-0.1.7.dist-info/entry_points.txt +2 -0
- videonote-0.1.7.dist-info/licenses/LICENSE +21 -0
- videonote_mcp/__init__.py +6 -0
- videonote_mcp/cli.py +1653 -0
- videonote_mcp/config.py +305 -0
- videonote_mcp/crypto.py +118 -0
- videonote_mcp/export/__init__.py +36 -0
- videonote_mcp/export/exporter.py +94 -0
- videonote_mcp/export/json.py +70 -0
- videonote_mcp/export/srt.py +47 -0
- videonote_mcp/export/vtt.py +50 -0
- videonote_mcp/provider_probe.py +71 -0
- videonote_mcp/server.py +2091 -0
app/__init__.py
ADDED
app/db/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "openai",
|
|
4
|
+
"name": "OpenAI",
|
|
5
|
+
"type": "built-in",
|
|
6
|
+
"logo": "OpenAI",
|
|
7
|
+
"api_key": "",
|
|
8
|
+
"base_url": "https://api.openai.com/v1"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"id": "deepseek",
|
|
12
|
+
"name": "DeepSeek",
|
|
13
|
+
"type": "built-in",
|
|
14
|
+
"logo": "DeepSeek",
|
|
15
|
+
"api_key": "",
|
|
16
|
+
"base_url": "https://api.deepseek.com",
|
|
17
|
+
"models": [
|
|
18
|
+
"deepseek-chat",
|
|
19
|
+
"deepseek-reasoner"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "qwen",
|
|
24
|
+
"name": "Qwen",
|
|
25
|
+
"type": "built-in",
|
|
26
|
+
"logo": "Qwen",
|
|
27
|
+
"api_key": "",
|
|
28
|
+
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
29
|
+
"models": [
|
|
30
|
+
"qwen-plus",
|
|
31
|
+
"qwen-turbo",
|
|
32
|
+
"qwen-max",
|
|
33
|
+
"qwen-long"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "Claude",
|
|
38
|
+
"name": "Claude",
|
|
39
|
+
"type": "built-in",
|
|
40
|
+
"logo": "Claude",
|
|
41
|
+
"api_key": "",
|
|
42
|
+
"base_url": "https://api.anthropic.com/v1/"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "gemini",
|
|
46
|
+
"name": "Gemini",
|
|
47
|
+
"type": "built-in",
|
|
48
|
+
"logo": "Gemini",
|
|
49
|
+
"api_key": "",
|
|
50
|
+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "groq",
|
|
54
|
+
"name": "Groq",
|
|
55
|
+
"type": "built-in",
|
|
56
|
+
"logo": "Groq",
|
|
57
|
+
"api_key": "",
|
|
58
|
+
"base_url": "https://api.groq.com/openai/v1"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "ollama",
|
|
62
|
+
"name": "ollama",
|
|
63
|
+
"type": "built-in",
|
|
64
|
+
"logo": "Ollama",
|
|
65
|
+
"api_key": "",
|
|
66
|
+
"base_url": "http://127.0.0.1:11434/v1"
|
|
67
|
+
}
|
|
68
|
+
]
|
app/db/engine.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from dotenv import load_dotenv
|
|
4
|
+
from sqlalchemy import create_engine, event
|
|
5
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
6
|
+
|
|
7
|
+
from app.utils.path_helper import get_data_dir
|
|
8
|
+
|
|
9
|
+
if not os.environ.get("VIDEONOTE_DATA_DIR"):
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
# 默认 SQLite,如果想换 PostgreSQL 或 MySQL,可以直接改 .env。
|
|
13
|
+
# 默认路径固定到稳定数据目录(与 videonote_mcp.config 的 setdefault 同值)——
|
|
14
|
+
# 相对路径 `sqlite:///video_note.db` 会随进程 CWD 漂移,在仓库根/其它目录
|
|
15
|
+
# 跑脚本或测试会分裂出多个互不相通的 DB(本仓库根目录曾泄漏出 video_note.db)。
|
|
16
|
+
DATABASE_URL = os.getenv("DATABASE_URL") or f"sqlite:///{os.path.join(get_data_dir(), 'video_note.db')}"
|
|
17
|
+
|
|
18
|
+
# SQLite 需要特定连接参数,其他数据库不需要
|
|
19
|
+
engine_args = {}
|
|
20
|
+
if DATABASE_URL.startswith("sqlite"):
|
|
21
|
+
engine_args["connect_args"] = {
|
|
22
|
+
"check_same_thread": False,
|
|
23
|
+
# 连接级 busy timeout(秒):MCP 多线程并发写(任务索引/provider 更新)
|
|
24
|
+
# 时避免立刻抛 "database is locked"
|
|
25
|
+
"timeout": 30,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_pool_args = {}
|
|
29
|
+
if not DATABASE_URL.startswith("sqlite"):
|
|
30
|
+
_pool_args = {
|
|
31
|
+
"pool_size": int(os.getenv("DB_POOL_SIZE", "10")),
|
|
32
|
+
"max_overflow": int(os.getenv("DB_MAX_OVERFLOW", "20")),
|
|
33
|
+
"pool_pre_ping": True,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
engine = create_engine(
|
|
37
|
+
DATABASE_URL,
|
|
38
|
+
echo=os.getenv("SQLALCHEMY_ECHO", "false").lower() == "true",
|
|
39
|
+
**engine_args,
|
|
40
|
+
**_pool_args,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if DATABASE_URL.startswith("sqlite"):
|
|
44
|
+
@event.listens_for(engine, "connect")
|
|
45
|
+
def _set_sqlite_pragma(dbapi_conn, connection_record):
|
|
46
|
+
"""SQLite 并发写优化:WAL + busy_timeout + NORMAL 同步。
|
|
47
|
+
|
|
48
|
+
否则两个线程并发写会互锁抛 OperationalError: database is locked;
|
|
49
|
+
WAL 让读写不互斥,busy_timeout 让短竞争自旋而非立刻失败。
|
|
50
|
+
"""
|
|
51
|
+
cur = dbapi_conn.cursor()
|
|
52
|
+
cur.execute("PRAGMA journal_mode=WAL")
|
|
53
|
+
cur.execute("PRAGMA busy_timeout=30000")
|
|
54
|
+
cur.execute("PRAGMA synchronous=NORMAL")
|
|
55
|
+
cur.close()
|
|
56
|
+
|
|
57
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
58
|
+
|
|
59
|
+
Base = declarative_base()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_engine():
|
|
63
|
+
return engine
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_db():
|
|
67
|
+
db = SessionLocal()
|
|
68
|
+
try:
|
|
69
|
+
yield db
|
|
70
|
+
finally:
|
|
71
|
+
db.close()
|
app/db/init_db.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from app.db.engine import Base, get_engine
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
# video_tasks 表在数据层重构中新增的列(SQLite ALTER 幂等迁移用)
|
|
8
|
+
_VIDEO_TASK_MIGRATIONS = [
|
|
9
|
+
("title", "VARCHAR"),
|
|
10
|
+
("status", "VARCHAR"),
|
|
11
|
+
("summary", "VARCHAR"),
|
|
12
|
+
("note_dir", "VARCHAR"),
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _migrate_video_tasks(engine):
|
|
17
|
+
"""给已存在的 video_tasks 表补新增列(create_all 不会改已有表)。
|
|
18
|
+
|
|
19
|
+
SQLite 的 ALTER TABLE ADD COLUMN 是幂等的;用 PRAGMA table_info 检查列,
|
|
20
|
+
缺失才补,避免重复 ADD 报错。
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
with engine.connect() as conn:
|
|
24
|
+
cols = {row[1] for row in conn.exec_driver_sql("PRAGMA table_info(video_tasks)")}
|
|
25
|
+
for name, typ in _VIDEO_TASK_MIGRATIONS:
|
|
26
|
+
if name not in cols:
|
|
27
|
+
conn.exec_driver_sql(
|
|
28
|
+
f"ALTER TABLE video_tasks ADD COLUMN {name} {typ}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def init_db():
|
|
33
|
+
engine = get_engine()
|
|
34
|
+
|
|
35
|
+
Base.metadata.create_all(bind=engine)
|
|
36
|
+
try:
|
|
37
|
+
_migrate_video_tasks(engine)
|
|
38
|
+
except Exception:
|
|
39
|
+
# 迁移失败不致命(表可能不存在 / 已是最新),保底 create_all 已建表
|
|
40
|
+
pass
|
|
41
|
+
_create_indexes(engine)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# 常用查询列索引(docs/05 第 16 轮 C8):任务量上万后 get_task_by_video /
|
|
45
|
+
# get_provider_by_name / get_models_by_provider 从全表扫描降为索引查找。
|
|
46
|
+
_INDEXES = [
|
|
47
|
+
"CREATE INDEX IF NOT EXISTS ix_video_tasks_video ON video_tasks(video_id, platform)",
|
|
48
|
+
"CREATE INDEX IF NOT EXISTS ix_providers_name ON providers(name)",
|
|
49
|
+
"CREATE INDEX IF NOT EXISTS ix_models_provider ON models(provider_id)",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _create_indexes(engine) -> None:
|
|
54
|
+
with engine.connect() as conn:
|
|
55
|
+
for ddl in _INDEXES:
|
|
56
|
+
try:
|
|
57
|
+
conn.exec_driver_sql(ddl)
|
|
58
|
+
except Exception: # noqa: BLE001 —— 表尚未创建(时序/旧库)幂等跳过,下次 init_db 补建
|
|
59
|
+
logger.warning("建索引失败(表可能尚未创建,跳过): %s", ddl)
|
|
60
|
+
conn.commit()
|
app/db/model_dao.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from app.db.engine import get_db
|
|
2
|
+
from app.db.models.models import Model
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_model_by_provider_and_name(provider_id: int, model_name: str):
|
|
6
|
+
db = next(get_db())
|
|
7
|
+
try:
|
|
8
|
+
model = db.query(Model).filter_by(provider_id=provider_id, model_name=model_name).first()
|
|
9
|
+
if model:
|
|
10
|
+
return {
|
|
11
|
+
"id": model.id,
|
|
12
|
+
"provider_id": model.provider_id,
|
|
13
|
+
"model_name": model.model_name,
|
|
14
|
+
"created_at": model.created_at,
|
|
15
|
+
}
|
|
16
|
+
return None
|
|
17
|
+
finally:
|
|
18
|
+
db.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def insert_model(provider_id: int, model_name: str):
|
|
22
|
+
db = next(get_db())
|
|
23
|
+
try:
|
|
24
|
+
model = Model(provider_id=provider_id, model_name=model_name)
|
|
25
|
+
db.add(model)
|
|
26
|
+
db.commit()
|
|
27
|
+
db.refresh(model)
|
|
28
|
+
return {
|
|
29
|
+
"id": model.id,
|
|
30
|
+
"provider_id": model.provider_id,
|
|
31
|
+
"model_name": model.model_name,
|
|
32
|
+
"created_at": model.created_at,
|
|
33
|
+
}
|
|
34
|
+
finally:
|
|
35
|
+
db.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_models_by_provider(provider_id: int):
|
|
39
|
+
db = next(get_db())
|
|
40
|
+
try:
|
|
41
|
+
models = db.query(Model).filter_by(provider_id=provider_id).all()
|
|
42
|
+
return [{"id": m.id, "model_name": m.model_name} for m in models]
|
|
43
|
+
finally:
|
|
44
|
+
db.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delete_model(model_id: int):
|
|
48
|
+
db = next(get_db())
|
|
49
|
+
try:
|
|
50
|
+
model = db.query(Model).filter_by(id=model_id).first()
|
|
51
|
+
if model:
|
|
52
|
+
db.delete(model)
|
|
53
|
+
db.commit()
|
|
54
|
+
finally:
|
|
55
|
+
db.close()
|
|
File without changes
|
app/db/models/models.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from sqlalchemy import Column, DateTime, Integer, String, func
|
|
2
|
+
|
|
3
|
+
from app.db.engine import Base
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Model(Base):
|
|
7
|
+
__tablename__ = "models"
|
|
8
|
+
|
|
9
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
10
|
+
provider_id = Column(Integer, nullable=False)
|
|
11
|
+
model_name = Column(String, nullable=False)
|
|
12
|
+
created_at = Column(DateTime, server_default=func.now())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy import Column, DateTime, Integer, String, func
|
|
2
|
+
|
|
3
|
+
from app.db.engine import Base
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Provider(Base):
|
|
7
|
+
__tablename__ = "providers"
|
|
8
|
+
|
|
9
|
+
id = Column(String, primary_key=True)
|
|
10
|
+
name = Column(String, nullable=False)
|
|
11
|
+
logo = Column(String, nullable=False)
|
|
12
|
+
type = Column(String, nullable=False)
|
|
13
|
+
api_key = Column(String, nullable=False)
|
|
14
|
+
base_url = Column(String, nullable=False)
|
|
15
|
+
enabled = Column(Integer, default=1)
|
|
16
|
+
created_at = Column(DateTime, server_default=func.now())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from sqlalchemy import Column, DateTime, Integer, String, func
|
|
2
|
+
|
|
3
|
+
from app.db.engine import Base
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class VideoTask(Base):
|
|
7
|
+
__tablename__ = "video_tasks"
|
|
8
|
+
|
|
9
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
10
|
+
video_id = Column(String, nullable=False)
|
|
11
|
+
platform = Column(String, nullable=False)
|
|
12
|
+
task_id = Column(String, unique=True, nullable=False)
|
|
13
|
+
created_at = Column(DateTime, server_default=func.now())
|
|
14
|
+
# —— 数据层重构新增:全局任务索引的语义元数据 ——
|
|
15
|
+
title = Column(String, default="", nullable=True) # 语义标题(视频标题 / LLM 标题)
|
|
16
|
+
status = Column(String, default="", nullable=True) # 最新状态(SUCCESS/FAILED/CANCELLED…)
|
|
17
|
+
summary = Column(String, default="", nullable=True) # 语义简介(转写前若干字)
|
|
18
|
+
note_dir = Column(String, nullable=True) # 任务文件夹路径(note_results/{task_id})
|
app/db/provider_dao.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from app.db.engine import get_db
|
|
6
|
+
from app.db.models.providers import Provider
|
|
7
|
+
from app.utils.logger import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_builtin_providers_path():
|
|
13
|
+
if getattr(sys, 'frozen', False):
|
|
14
|
+
base_path = sys._MEIPASS
|
|
15
|
+
else:
|
|
16
|
+
base_path = os.path.dirname(__file__)
|
|
17
|
+
return os.path.join(base_path, 'builtin_providers.json')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def seed_default_providers():
|
|
21
|
+
db = next(get_db())
|
|
22
|
+
try:
|
|
23
|
+
if db.query(Provider).count() > 0:
|
|
24
|
+
logger.info("Providers already exist, skipping seed.")
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
json_path = get_builtin_providers_path()
|
|
28
|
+
try:
|
|
29
|
+
with open(json_path, 'r', encoding='utf-8') as f:
|
|
30
|
+
providers = json.load(f)
|
|
31
|
+
except Exception as e:
|
|
32
|
+
logger.error(f"Failed to read builtin_providers.json: {e}")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
for p in providers:
|
|
36
|
+
db.add(Provider(
|
|
37
|
+
id=p['id'],
|
|
38
|
+
name=p['name'],
|
|
39
|
+
api_key=p['api_key'],
|
|
40
|
+
base_url=p['base_url'],
|
|
41
|
+
logo=p['logo'],
|
|
42
|
+
type=p['type'],
|
|
43
|
+
enabled=p.get('enabled', 1)
|
|
44
|
+
))
|
|
45
|
+
db.commit()
|
|
46
|
+
logger.info("Default providers seeded successfully.")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.error(f"Failed to seed default providers: {e}")
|
|
49
|
+
finally:
|
|
50
|
+
db.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def insert_provider(id: str, name: str, api_key: str, base_url: str, logo: str, type_: str, enabled: int = 1):
|
|
54
|
+
db = next(get_db())
|
|
55
|
+
try:
|
|
56
|
+
# 落盘加密(docs/05 #29);明文兼容:加密失败自动回退明文写入。
|
|
57
|
+
# 惰性 import:app/ 是 vendored 层,不强制依赖 videonote_mcp(上游可独立跑)
|
|
58
|
+
from videonote_mcp.crypto import encrypt_value
|
|
59
|
+
|
|
60
|
+
provider = Provider(
|
|
61
|
+
id=id, name=name, api_key=encrypt_value(api_key), base_url=base_url,
|
|
62
|
+
logo=logo, type=type_, enabled=enabled,
|
|
63
|
+
)
|
|
64
|
+
db.add(provider)
|
|
65
|
+
db.commit()
|
|
66
|
+
logger.info(f"Provider inserted successfully. id: {id}, name: {name}, type: {type_}")
|
|
67
|
+
return id
|
|
68
|
+
except Exception as e:
|
|
69
|
+
logger.error(f"Failed to insert provider: {e}")
|
|
70
|
+
finally:
|
|
71
|
+
db.close()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_provider_by_name(name: str):
|
|
75
|
+
db = next(get_db())
|
|
76
|
+
try:
|
|
77
|
+
return db.query(Provider).filter_by(name=name).first()
|
|
78
|
+
finally:
|
|
79
|
+
db.close()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_provider_by_id(id: str):
|
|
83
|
+
db = next(get_db())
|
|
84
|
+
try:
|
|
85
|
+
return db.query(Provider).filter_by(id=id).first()
|
|
86
|
+
finally:
|
|
87
|
+
db.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_all_providers():
|
|
91
|
+
db = next(get_db())
|
|
92
|
+
try:
|
|
93
|
+
return db.query(Provider).all()
|
|
94
|
+
finally:
|
|
95
|
+
db.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def update_provider(id: str, **kwargs):
|
|
99
|
+
db = next(get_db())
|
|
100
|
+
try:
|
|
101
|
+
provider = db.query(Provider).filter_by(id=id).first()
|
|
102
|
+
if not provider:
|
|
103
|
+
logger.warning(f"Provider {id} not found for update.")
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for key, value in kwargs.items():
|
|
107
|
+
if hasattr(provider, key):
|
|
108
|
+
if key == "api_key":
|
|
109
|
+
# 落盘加密(docs/05 #29)。update 通常先读后写(已解密),
|
|
110
|
+
# 但 enc: 前缀值会二次加密——先解密再加密保持幂等。
|
|
111
|
+
# 解密失败(key 缺失/不匹配)时跳过写入而非二次加密:
|
|
112
|
+
# 二次加密会把 enc: 串再包一层,产生永远解不出的数据(docs 审计 G1)
|
|
113
|
+
from videonote_mcp.crypto import decrypt_value, encrypt_value
|
|
114
|
+
|
|
115
|
+
if value and str(value).startswith("enc:"):
|
|
116
|
+
decrypted = decrypt_value(value)
|
|
117
|
+
if decrypted is None:
|
|
118
|
+
logger.warning(
|
|
119
|
+
f"Provider {id} 的 api_key 无法解密(fernet.key 缺失/不匹配),"
|
|
120
|
+
f"跳过该字段更新"
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
value = encrypt_value(decrypted)
|
|
124
|
+
else:
|
|
125
|
+
value = encrypt_value(value)
|
|
126
|
+
setattr(provider, key, value)
|
|
127
|
+
|
|
128
|
+
db.commit()
|
|
129
|
+
logger.info(f"Provider updated successfully. id: {id}, updated_fields: {list(kwargs.keys())}")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Failed to update provider: {e}")
|
|
132
|
+
finally:
|
|
133
|
+
db.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def delete_provider(id: str):
|
|
137
|
+
db = next(get_db())
|
|
138
|
+
try:
|
|
139
|
+
provider = db.query(Provider).filter_by(id=id).first()
|
|
140
|
+
if provider:
|
|
141
|
+
db.delete(provider)
|
|
142
|
+
db.commit()
|
|
143
|
+
logger.info(f"Provider deleted successfully. id: {id}")
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"Failed to delete provider: {e}")
|
|
146
|
+
finally:
|
|
147
|
+
db.close()
|
app/db/video_task_dao.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from app.db.engine import get_db
|
|
4
|
+
from app.db.models.video_tasks import VideoTask
|
|
5
|
+
from app.utils.logger import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 插入/更新任务(task_id 冲突时更新语义元数据,而非抛错)
|
|
11
|
+
def insert_video_task(
|
|
12
|
+
video_id: str,
|
|
13
|
+
platform: str,
|
|
14
|
+
task_id: str,
|
|
15
|
+
title: str = "",
|
|
16
|
+
status: str = "",
|
|
17
|
+
summary: str = "",
|
|
18
|
+
note_dir: str = None,
|
|
19
|
+
):
|
|
20
|
+
db = next(get_db())
|
|
21
|
+
try:
|
|
22
|
+
existing = db.query(VideoTask).filter_by(task_id=task_id).first()
|
|
23
|
+
if existing:
|
|
24
|
+
existing.video_id = video_id
|
|
25
|
+
existing.platform = platform
|
|
26
|
+
if title:
|
|
27
|
+
existing.title = title
|
|
28
|
+
if status:
|
|
29
|
+
existing.status = status
|
|
30
|
+
if summary:
|
|
31
|
+
existing.summary = summary
|
|
32
|
+
if note_dir is not None:
|
|
33
|
+
existing.note_dir = note_dir
|
|
34
|
+
else:
|
|
35
|
+
db.add(
|
|
36
|
+
VideoTask(
|
|
37
|
+
video_id=video_id,
|
|
38
|
+
platform=platform,
|
|
39
|
+
task_id=task_id,
|
|
40
|
+
title=title,
|
|
41
|
+
status=status,
|
|
42
|
+
summary=summary,
|
|
43
|
+
note_dir=note_dir,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
db.commit()
|
|
47
|
+
# title 可能是 None(无标题视频/平台),f-string 里切片会 TypeError——
|
|
48
|
+
# 行已入库却被记成「插入失败」,排查指向错误方向(#126 B8)
|
|
49
|
+
logger.info(f"Video task saved. task_id={task_id}, title={(title or '')[:40]!r}")
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.error(f"Failed to insert video task: {e}")
|
|
52
|
+
finally:
|
|
53
|
+
db.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# 更新任务状态(每次 _update_status 时同步到全局索引)
|
|
57
|
+
def update_task_status(task_id: str, status: str, message: str = ""):
|
|
58
|
+
db = next(get_db())
|
|
59
|
+
try:
|
|
60
|
+
task = db.query(VideoTask).filter_by(task_id=task_id).first()
|
|
61
|
+
if task:
|
|
62
|
+
task.status = status
|
|
63
|
+
# 占位文案("任务排队中")只作状态消息、不落 summary(docs/05 第 16 轮 B7):
|
|
64
|
+
# 步骤任务(transcribe_media 等)成功后 summary 曾恒为"任务排队中"误导 Agent。
|
|
65
|
+
# 语义简介由调用方显式写(_save_metadata / insert_video_task)。
|
|
66
|
+
if message and not task.summary and message != "任务排队中":
|
|
67
|
+
task.summary = message
|
|
68
|
+
if status == "SUCCESS" and task.summary == "任务排队中":
|
|
69
|
+
task.summary = ""
|
|
70
|
+
else:
|
|
71
|
+
logger.warning(f"update_task_status: task {task_id} 不在全局索引,跳过")
|
|
72
|
+
db.commit()
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to update task status: {e}")
|
|
75
|
+
finally:
|
|
76
|
+
db.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# 列出全部任务(全局索引),供 list_tasks 工具 / setup 数据管理
|
|
80
|
+
def list_tasks(limit: Optional[int] = None, offset: int = 0) -> list:
|
|
81
|
+
db = next(get_db())
|
|
82
|
+
try:
|
|
83
|
+
# 分页下推到 SQL(LIMIT/OFFSET),任务量大的会话不再全表拉回 Python 层切片
|
|
84
|
+
query = db.query(VideoTask).order_by(VideoTask.created_at.desc()).offset(max(0, int(offset or 0)))
|
|
85
|
+
if limit is not None:
|
|
86
|
+
query = query.limit(max(1, int(limit)))
|
|
87
|
+
rows = query.all()
|
|
88
|
+
return [
|
|
89
|
+
{
|
|
90
|
+
"task_id": r.task_id,
|
|
91
|
+
"video_id": r.video_id,
|
|
92
|
+
"platform": r.platform,
|
|
93
|
+
"title": r.title or "",
|
|
94
|
+
"status": r.status or "",
|
|
95
|
+
"summary": r.summary or "",
|
|
96
|
+
"note_dir": r.note_dir,
|
|
97
|
+
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
98
|
+
}
|
|
99
|
+
for r in rows
|
|
100
|
+
]
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Failed to list tasks: {e}")
|
|
103
|
+
return []
|
|
104
|
+
finally:
|
|
105
|
+
db.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# 清空全局索引(cleanup_all 用):单条 DELETE 替代 list + N 条单删(#125 B12)。
|
|
109
|
+
# 返回受影响行数;失败时抛给调用方显式处理(空表不是失败)。调用方保证只在
|
|
110
|
+
# 显式 cleanup_all 语义下调用,绝不因卸载/升级触发。
|
|
111
|
+
def delete_all_tasks() -> int:
|
|
112
|
+
db = next(get_db())
|
|
113
|
+
try:
|
|
114
|
+
deleted = db.query(VideoTask).delete(synchronize_session=False)
|
|
115
|
+
db.commit()
|
|
116
|
+
logger.info(f"Cleared task index: {deleted} rows")
|
|
117
|
+
return deleted
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Failed to clear task index: {e}")
|
|
120
|
+
raise
|
|
121
|
+
finally:
|
|
122
|
+
db.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# 按 task_id 删一条全局索引(清理任务时调用)
|
|
126
|
+
def delete_task(task_id: str):
|
|
127
|
+
db = next(get_db())
|
|
128
|
+
try:
|
|
129
|
+
task = db.query(VideoTask).filter_by(task_id=task_id).first()
|
|
130
|
+
if task:
|
|
131
|
+
db.delete(task)
|
|
132
|
+
db.commit()
|
|
133
|
+
logger.info(f"Task deleted from index: {task_id}")
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logger.error(f"Failed to delete task: {e}")
|
|
136
|
+
finally:
|
|
137
|
+
db.close()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# 查询任务(最新一条)
|
|
141
|
+
def get_task_by_video(video_id: str, platform: str):
|
|
142
|
+
db = next(get_db())
|
|
143
|
+
try:
|
|
144
|
+
task = (
|
|
145
|
+
db.query(VideoTask)
|
|
146
|
+
.filter_by(video_id=video_id, platform=platform)
|
|
147
|
+
.order_by(VideoTask.created_at.desc())
|
|
148
|
+
.first()
|
|
149
|
+
)
|
|
150
|
+
if task:
|
|
151
|
+
logger.info(f"Task found for video_id: {video_id} and platform: {platform}")
|
|
152
|
+
return task.task_id
|
|
153
|
+
else:
|
|
154
|
+
logger.info(f"No task found for video_id: {video_id} and platform: {platform}")
|
|
155
|
+
return None
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Failed to get task by video: {e}")
|
|
158
|
+
finally:
|
|
159
|
+
db.close()
|
|
File without changes
|
app/decorators/timeit.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from app.utils.logger import get_logger
|
|
5
|
+
|
|
6
|
+
logger = get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def timeit(func):
|
|
10
|
+
@functools.wraps(func)
|
|
11
|
+
def wrapper(*args, **kwargs):
|
|
12
|
+
start = time.perf_counter()
|
|
13
|
+
result = func(*args, **kwargs)
|
|
14
|
+
end = time.perf_counter()
|
|
15
|
+
duration = end - start
|
|
16
|
+
# 度量走 logger 而非 print:print 在 MCP 下靠 stderr 重定向兜底,
|
|
17
|
+
# 裸脚本 / pytest 直跑会打进 stdout 污染输出(#127 B7)
|
|
18
|
+
logger.info(f"{func.__name__} executed in {duration:.4f} seconds")
|
|
19
|
+
return result
|
|
20
|
+
return wrapper
|
|
File without changes
|