feat: add session-level publish and comment flow
This commit is contained in:
@ -8,51 +8,7 @@ from biliup_next.core.errors import ModuleError
|
||||
from biliup_next.core.models import Artifact, Task, utc_now_iso
|
||||
from biliup_next.core.providers import ProviderManifest
|
||||
from biliup_next.infra.adapters.codex_cli import CodexCliAdapter
|
||||
|
||||
SONG_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string"},
|
||||
"end": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"artist": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"evidence": {"type": "string"},
|
||||
},
|
||||
"required": ["start", "end", "title", "artist", "confidence", "evidence"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["songs"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件。
|
||||
任务:
|
||||
1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。
|
||||
2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则:
|
||||
- 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。
|
||||
- 这样可以尽量保留歌曲可能存在的前奏。
|
||||
3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌.
|
||||
4. 忽略纯聊天片段。
|
||||
5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。
|
||||
6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。
|
||||
7. 仔细分析每一句歌词,识别出相关歌曲后, 使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间
|
||||
8.歌曲标注规则:
|
||||
- 可以在歌曲名称后使用括号 () 添加补充说明。
|
||||
- 常见标注示例:
|
||||
- (片段):歌曲演唱时间较短,例如 < 60 秒
|
||||
- (清唱):无伴奏演唱
|
||||
- (副歌):只演唱副歌部分
|
||||
- 标注应简洁,仅在确有必要时使用。
|
||||
9. 通过歌曲起始和结束时间自检, 一般歌曲长度在5分钟以内, 1分钟以上, 可疑片段重新联网搜索检查.
|
||||
最后请严格按照 Schema 生成 JSON 数据。"""
|
||||
from biliup_next.modules.song_detect.providers.common import TASK_PROMPT, ensure_song_outputs, write_song_schema
|
||||
|
||||
|
||||
class CodexSongDetector:
|
||||
@ -71,10 +27,9 @@ class CodexSongDetector:
|
||||
|
||||
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
|
||||
work_dir = Path(subtitle_srt.path).resolve().parent
|
||||
schema_path = work_dir / "song_schema.json"
|
||||
write_song_schema(work_dir)
|
||||
songs_json_path = work_dir / "songs.json"
|
||||
songs_txt_path = work_dir / "songs.txt"
|
||||
schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
codex_cmd = str(settings.get("codex_cmd", "codex"))
|
||||
result = self.adapter.run_song_detect(
|
||||
@ -91,16 +46,13 @@ class CodexSongDetector:
|
||||
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||
)
|
||||
|
||||
if songs_json_path.exists() and not songs_txt_path.exists():
|
||||
self._generate_txt_fallback(songs_json_path, songs_txt_path)
|
||||
|
||||
if not songs_json_path.exists() or not songs_txt_path.exists():
|
||||
raise ModuleError(
|
||||
code="SONG_DETECT_OUTPUT_MISSING",
|
||||
message=f"未生成 songs.json/songs.txt: {work_dir}",
|
||||
retryable=True,
|
||||
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||
)
|
||||
ensure_song_outputs(
|
||||
songs_json_path=songs_json_path,
|
||||
songs_txt_path=songs_txt_path,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
provider_name="codex",
|
||||
)
|
||||
|
||||
return (
|
||||
Artifact(
|
||||
@ -120,19 +72,3 @@ class CodexSongDetector:
|
||||
created_at=utc_now_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
def _generate_txt_fallback(self, songs_json_path: Path, songs_txt_path: Path) -> None:
|
||||
try:
|
||||
data = json.loads(songs_json_path.read_text(encoding="utf-8"))
|
||||
songs = data.get("songs", [])
|
||||
with songs_txt_path.open("w", encoding="utf-8") as file_handle:
|
||||
for song in songs:
|
||||
start_time = str(song["start"]).split(",")[0].split(".")[0]
|
||||
file_handle.write(f"{start_time} {song['title']} — {song['artist']}\n")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ModuleError(
|
||||
code="SONGS_TXT_GENERATE_FAILED",
|
||||
message=f"生成 songs.txt 失败: {songs_txt_path}",
|
||||
retryable=False,
|
||||
details={"error": str(exc)},
|
||||
) from exc
|
||||
|
||||
122
src/biliup_next/modules/song_detect/providers/common.py
Normal file
122
src/biliup_next/modules/song_detect/providers/common.py
Normal file
@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from biliup_next.core.errors import ModuleError
|
||||
|
||||
SONG_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"start": {"type": "string"},
|
||||
"end": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
"artist": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"evidence": {"type": "string"},
|
||||
},
|
||||
"required": ["start", "end", "title", "artist", "confidence", "evidence"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["songs"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件。
|
||||
任务:
|
||||
1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。
|
||||
2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则:
|
||||
- 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。
|
||||
- 这样可以尽量保留歌曲可能存在的前奏。
|
||||
3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌.
|
||||
4. 忽略纯聊天片段。
|
||||
5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。
|
||||
6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。
|
||||
7. 仔细分析每一句歌词,识别出相关歌曲后, 使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间
|
||||
8.歌曲标注规则:
|
||||
- 可以在歌曲名称后使用括号 () 添加补充说明。
|
||||
- 常见标注示例:
|
||||
- (片段):歌曲演唱时间较短,例如 < 60 秒
|
||||
- (清唱):无伴奏演唱
|
||||
- (副歌):只演唱副歌部分
|
||||
- 标注应简洁,仅在确有必要时使用。
|
||||
9. 通过歌曲起始和结束时间自检, 一般歌曲长度在5分钟以内, 1分钟以上, 可疑片段重新联网搜索检查.
|
||||
最后请严格按照 Schema 生成 JSON 数据。"""
|
||||
|
||||
QWEN_TASK_PROMPT = """你是音乐片段识别助手。当前目录下有一个字幕文件 `subtitle.srt` 和一个 JSON schema 文件 `song_schema.json`。
|
||||
任务:
|
||||
1. 结合字幕内容并允许联网搜索进行纠错(识别同音字、唱错等)。
|
||||
2. 识别出直播中唱过的所有歌曲,给出精确的开始和结束时间。歌曲开始时间规则:
|
||||
- 歌曲开始时间应使用“上一句字幕的结束时间”作为 start_time。
|
||||
- 这样可以尽量保留歌曲可能存在的前奏。
|
||||
3. 同一首歌间隔 ≤160s 合并,>160s 分开。若连续识别出相同歌曲,且中间只有短暂对白、空白、转场或无歌词段,应合并为同一首歌。
|
||||
4. 忽略纯聊天片段。
|
||||
5. 无法确认的歌曲丢弃,宁缺毋滥:你的输出将直接面向最终用户。
|
||||
6. 忽略短片段:如果一段演唱持续时间总和少于 15 秒,视为随口哼唱,请直接忽略,不计入列表。
|
||||
7. 仔细分析每一句歌词,识别出相关歌曲后,使用该歌曲歌词上下文对比字幕上下文,确定歌曲起始与停止时间。
|
||||
8. 歌曲名称后可以按需补充 `(片段)`、`(清唱)`、`(副歌)` 等简短标注。
|
||||
9. 通过歌曲起始和结束时间自检,一般歌曲长度在 5 分钟以内、1 分钟以上,可疑片段重新联网搜索检查。
|
||||
|
||||
输出要求:
|
||||
1. 读取 `song_schema.json`,生成严格符合 schema 的 JSON。
|
||||
2. 把 JSON 保存到当前目录的 `songs.json`。
|
||||
3. 再生成一个 `songs.txt`,每行格式为 `HH:MM:SS 歌曲名 — 歌手`,其中时间取每首歌的开始时间,忽略毫秒。
|
||||
4. 不要修改其他文件。
|
||||
5. 完成后只输出简短结果说明。
|
||||
"""
|
||||
|
||||
|
||||
def write_song_schema(work_dir: Path) -> Path:
|
||||
schema_path = work_dir / "song_schema.json"
|
||||
schema_path.write_text(json.dumps(SONG_SCHEMA, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return schema_path
|
||||
|
||||
|
||||
def ensure_song_outputs(
|
||||
*,
|
||||
songs_json_path: Path,
|
||||
songs_txt_path: Path,
|
||||
stdout: str,
|
||||
stderr: str,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
if songs_json_path.exists() and not songs_txt_path.exists():
|
||||
generate_txt_fallback(songs_json_path, songs_txt_path)
|
||||
|
||||
if songs_json_path.exists() and songs_txt_path.exists():
|
||||
return
|
||||
|
||||
raise ModuleError(
|
||||
code="SONG_DETECT_OUTPUT_MISSING",
|
||||
message=f"未生成 songs.json/songs.txt: {songs_json_path.parent}",
|
||||
retryable=True,
|
||||
details={
|
||||
"provider": provider_name,
|
||||
"stdout": stdout[-2000:],
|
||||
"stderr": stderr[-2000:],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def generate_txt_fallback(songs_json_path: Path, songs_txt_path: Path) -> None:
|
||||
try:
|
||||
data = json.loads(songs_json_path.read_text(encoding="utf-8"))
|
||||
songs = data.get("songs", [])
|
||||
with songs_txt_path.open("w", encoding="utf-8") as file_handle:
|
||||
for song in songs:
|
||||
start_time = str(song["start"]).split(",")[0].split(".")[0]
|
||||
file_handle.write(f"{start_time} {song['title']} — {song['artist']}\n")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ModuleError(
|
||||
code="SONGS_TXT_GENERATE_FAILED",
|
||||
message=f"生成 songs.txt 失败: {songs_txt_path}",
|
||||
retryable=False,
|
||||
details={"error": str(exc)},
|
||||
) from exc
|
||||
78
src/biliup_next/modules/song_detect/providers/qwen_cli.py
Normal file
78
src/biliup_next/modules/song_detect/providers/qwen_cli.py
Normal file
@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from biliup_next.core.errors import ModuleError
|
||||
from biliup_next.core.models import Artifact, Task, utc_now_iso
|
||||
from biliup_next.core.providers import ProviderManifest
|
||||
from biliup_next.infra.adapters.qwen_cli import QwenCliAdapter
|
||||
from biliup_next.modules.song_detect.providers.common import (
|
||||
QWEN_TASK_PROMPT,
|
||||
ensure_song_outputs,
|
||||
write_song_schema,
|
||||
)
|
||||
|
||||
|
||||
class QwenCliSongDetector:
|
||||
def __init__(self, adapter: QwenCliAdapter | None = None) -> None:
|
||||
self.adapter = adapter or QwenCliAdapter()
|
||||
|
||||
manifest = ProviderManifest(
|
||||
id="qwen_cli",
|
||||
name="Qwen CLI Song Detector",
|
||||
version="0.1.0",
|
||||
provider_type="song_detector",
|
||||
entrypoint="biliup_next.modules.song_detect.providers.qwen_cli:QwenCliSongDetector",
|
||||
capabilities=["song_detect"],
|
||||
enabled_by_default=True,
|
||||
)
|
||||
|
||||
def detect(self, task: Task, subtitle_srt: Artifact, settings: dict[str, Any]) -> tuple[Artifact, Artifact]:
|
||||
work_dir = Path(subtitle_srt.path).resolve().parent
|
||||
write_song_schema(work_dir)
|
||||
songs_json_path = work_dir / "songs.json"
|
||||
songs_txt_path = work_dir / "songs.txt"
|
||||
|
||||
qwen_cmd = str(settings.get("qwen_cmd", "qwen"))
|
||||
result = self.adapter.run_song_detect(
|
||||
qwen_cmd=qwen_cmd,
|
||||
work_dir=work_dir,
|
||||
prompt=QWEN_TASK_PROMPT,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ModuleError(
|
||||
code="SONG_DETECT_FAILED",
|
||||
message="qwen -p 执行失败",
|
||||
retryable=True,
|
||||
details={"stdout": result.stdout[-2000:], "stderr": result.stderr[-2000:]},
|
||||
)
|
||||
|
||||
ensure_song_outputs(
|
||||
songs_json_path=songs_json_path,
|
||||
songs_txt_path=songs_txt_path,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
provider_name="qwen_cli",
|
||||
)
|
||||
|
||||
return (
|
||||
Artifact(
|
||||
id=None,
|
||||
task_id=task.id,
|
||||
artifact_type="songs_json",
|
||||
path=str(songs_json_path.resolve()),
|
||||
metadata_json=json.dumps({"provider": "qwen_cli"}),
|
||||
created_at=utc_now_iso(),
|
||||
),
|
||||
Artifact(
|
||||
id=None,
|
||||
task_id=task.id,
|
||||
artifact_type="songs_txt",
|
||||
path=str(songs_txt_path.resolve()),
|
||||
metadata_json=json.dumps({"provider": "qwen_cli"}),
|
||||
created_at=utc_now_iso(),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user