fix: unify task workspace directory resolution

This commit is contained in:
theshy
2026-04-14 16:44:31 +08:00
parent d5d9693581
commit 055474360e
11 changed files with 192 additions and 56 deletions

View File

@ -4,6 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from biliup_next.app.retry_meta import retry_meta_for_step from biliup_next.app.retry_meta import retry_meta_for_step
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class ControlPlaneSerializer: class ControlPlaneSerializer:
@ -78,7 +79,7 @@ class ControlPlaneSerializer:
task = task or self.state["repo"].get_task(task_id) task = task or self.state["repo"].get_task(task_id)
if task is None: if task is None:
return {} return {}
session_dir = Path(str(self.state["settings"]["paths"]["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
source_path = Path(task.source_path) source_path = Path(task.source_path)
split_dir = session_dir / "split_video" split_dir = session_dir / "split_video"
@ -246,7 +247,7 @@ class ControlPlaneSerializer:
task = task or self.state["repo"].get_task(task_id) task = task or self.state["repo"].get_task(task_id)
if task is None: if task is None:
return None return None
session_dir = Path(str(self.state["settings"]["paths"]["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
path = session_dir / filename path = session_dir / filename
if not path.exists(): if not path.exists():
return None return None

View File

@ -5,6 +5,7 @@ from pathlib import Path
import re import re
from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso from biliup_next.core.models import ActionRecord, SessionBinding, TaskContext, utc_now_iso
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class SessionDeliveryService: class SessionDeliveryService:
@ -222,10 +223,10 @@ class SessionDeliveryService:
return None return None
return bvid return bvid
def _full_video_bvid_path(self, task_title: str) -> Path: def _full_video_bvid_path(self, task) -> Path: # type: ignore[no-untyped-def]
session_dir = Path(str(self.settings["paths"]["session_dir"])) / task_title work_dir = resolve_task_work_dir(task)
session_dir.mkdir(parents=True, exist_ok=True) work_dir.mkdir(parents=True, exist_ok=True)
return session_dir / "full_video_bvid.txt" return work_dir / "full_video_bvid.txt"
def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None: def _upsert_session_binding_for_context(self, context: TaskContext, full_video_bvid: str, now: str) -> None:
self.repo.upsert_session_binding( self.repo.upsert_session_binding(
@ -253,6 +254,6 @@ class SessionDeliveryService:
context.updated_at = now context.updated_at = now
self.repo.upsert_task_context(context) self.repo.upsert_task_context(context)
self._upsert_session_binding_for_context(context, full_video_bvid, now) self._upsert_session_binding_for_context(context, full_video_bvid, now)
path = self._full_video_bvid_path(task.title) path = self._full_video_bvid_path(task)
path.write_text(full_video_bvid, encoding="utf-8") path.write_text(full_video_bvid, encoding="utf-8")
return path return path

View File

@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
from pathlib import Path
from biliup_next.infra.task_repository import TaskRepository from biliup_next.infra.task_repository import TaskRepository
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class WorkspaceCleanupService: class WorkspaceCleanupService:
@ -15,7 +15,7 @@ class WorkspaceCleanupService:
if task is None: if task is None:
raise RuntimeError(f"task not found: {task_id}") raise RuntimeError(f"task not found: {task_id}")
session_dir = Path(str(settings["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
removed: list[str] = [] removed: list[str] = []
skipped: list[str] = [] skipped: list[str] = []

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from pathlib import Path
def resolve_task_work_dir(task) -> Path: # type: ignore[no-untyped-def]
source = Path(task.source_path).resolve()
if source.is_file() or source.suffix:
return source.parent
return source

View File

@ -11,6 +11,7 @@ from biliup_next.core.models import Task
from biliup_next.core.providers import ProviderManifest from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class BilibiliCollectionProvider: class BilibiliCollectionProvider:
@ -29,7 +30,7 @@ class BilibiliCollectionProvider:
) )
def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]: def sync(self, task: Task, target: str, settings: dict[str, Any]) -> dict[str, object]:
session_dir = Path(str(settings["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
cookies = self.bilibili_api.load_cookies(Path(str(settings["cookies_file"]))) cookies = self.bilibili_api.load_cookies(Path(str(settings["cookies_file"])))
csrf = cookies.get("bili_jct") csrf = cookies.get("bili_jct")
if not csrf: if not csrf:

View File

@ -11,6 +11,7 @@ from biliup_next.core.models import Task
from biliup_next.core.providers import ProviderManifest from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter from biliup_next.infra.adapters.bilibili_api import BilibiliApiAdapter
from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid from biliup_next.infra.adapters.full_video_locator import resolve_full_video_bvid
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class BilibiliTopCommentProvider: class BilibiliTopCommentProvider:
@ -28,7 +29,7 @@ class BilibiliTopCommentProvider:
) )
def comment(self, task: Task, settings: dict[str, Any]) -> dict[str, object]: def comment(self, task: Task, settings: dict[str, Any]) -> dict[str, object]:
session_dir = Path(str(settings["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
songs_path = session_dir / "songs.txt" songs_path = session_dir / "songs.txt"
songs_json_path = session_dir / "songs.json" songs_json_path = session_dir / "songs.json"
bvid_path = session_dir / "bvid.txt" bvid_path = session_dir / "bvid.txt"
@ -164,14 +165,13 @@ class BilibiliTopCommentProvider:
def _build_split_comment(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]: def _build_split_comment(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]:
repo = settings.get("__repo") repo = settings.get("__repo")
session_dir_root = Path(str(settings["session_dir"]))
if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"): if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"):
session_dir = session_dir_root / task.title session_dir = resolve_task_work_dir(task)
return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None
context = repo.get_task_context(task.id) context = repo.get_task_context(task.id)
if context is None or not context.session_key or context.session_key.startswith("task:"): if context is None or not context.session_key or context.session_key.startswith("task:"):
session_dir = session_dir_root / task.title session_dir = resolve_task_work_dir(task)
return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None return self._build_split_comment_content(session_dir / "songs.json", session_dir / "songs.txt"), None
ordered_contexts = self._ordered_session_contexts(repo, context.session_key) ordered_contexts = self._ordered_session_contexts(repo, context.session_key)
@ -183,7 +183,10 @@ class BilibiliTopCommentProvider:
blocks: list[str] = [] blocks: list[str] = []
for index, session_context in enumerate(ordered_contexts, start=1): for index, session_context in enumerate(ordered_contexts, start=1):
task_dir = session_dir_root / session_context.task_id session_task = repo.get_task(session_context.task_id)
if session_task is None:
continue
task_dir = resolve_task_work_dir(session_task)
content = self._build_split_comment_content(task_dir / "songs.json", task_dir / "songs.txt") content = self._build_split_comment_content(task_dir / "songs.json", task_dir / "songs.txt")
if not content: if not content:
continue continue
@ -195,13 +198,13 @@ class BilibiliTopCommentProvider:
def _build_full_comment_content(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]: def _build_full_comment_content(self, task: Task, settings: dict[str, Any]) -> tuple[str, str | None]:
repo = settings.get("__repo") repo = settings.get("__repo")
if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"): if repo is None or not hasattr(repo, "get_task_context") or not hasattr(repo, "list_task_contexts_by_session_key"):
session_dir = Path(str(settings["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip() content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip()
return content, None if content else "timeline_comment_empty" return content, None if content else "timeline_comment_empty"
context = repo.get_task_context(task.id) context = repo.get_task_context(task.id)
if context is None or not context.session_key or context.session_key.startswith("task:"): if context is None or not context.session_key or context.session_key.startswith("task:"):
session_dir = Path(str(settings["session_dir"])) / task.title session_dir = resolve_task_work_dir(task)
content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip() content = session_dir.joinpath("songs.txt").read_text(encoding="utf-8").strip()
return content, None if content else "timeline_comment_empty" return content, None if content else "timeline_comment_empty"
@ -214,7 +217,10 @@ class BilibiliTopCommentProvider:
blocks: list[str] = [] blocks: list[str] = []
for index, session_context in enumerate(ordered_contexts, start=1): for index, session_context in enumerate(ordered_contexts, start=1):
task_dir = Path(str(settings["session_dir"])) / session_context.task_id session_task = repo.get_task(session_context.task_id)
if session_task is None:
continue
task_dir = resolve_task_work_dir(session_task)
songs_path = task_dir / "songs.txt" songs_path = task_dir / "songs.txt"
if not songs_path.exists(): if not songs_path.exists():
continue continue

View File

@ -11,6 +11,7 @@ from biliup_next.core.errors import ModuleError
from biliup_next.core.models import PublishRecord, Task, utc_now_iso from biliup_next.core.models import PublishRecord, Task, utc_now_iso
from biliup_next.core.providers import ProviderManifest from biliup_next.core.providers import ProviderManifest
from biliup_next.infra.adapters.biliup_cli import BiliupCliAdapter from biliup_next.infra.adapters.biliup_cli import BiliupCliAdapter
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class BiliupCliPublishProvider: class BiliupCliPublishProvider:
@ -28,7 +29,7 @@ class BiliupCliPublishProvider:
) )
def publish(self, task: Task, clip_videos: list, settings: dict[str, Any]) -> PublishRecord: def publish(self, task: Task, clip_videos: list, settings: dict[str, Any]) -> PublishRecord:
work_dir = Path(str(settings["session_dir"])) / task.title work_dir = resolve_task_work_dir(task)
bvid_file = work_dir / "bvid.txt" bvid_file = work_dir / "bvid.txt"
upload_done = work_dir / "upload_done.flag" upload_done = work_dir / "upload_done.flag"
publish_log = work_dir / "publish.log" publish_log = work_dir / "publish.log"

View File

@ -8,6 +8,7 @@ from typing import Any
from biliup_next.core.models import Artifact, PublishRecord, TaskContext, utc_now_iso from biliup_next.core.models import Artifact, PublishRecord, TaskContext, utc_now_iso
from biliup_next.core.registry import Registry from biliup_next.core.registry import Registry
from biliup_next.infra.task_repository import TaskRepository from biliup_next.infra.task_repository import TaskRepository
from biliup_next.infra.workspace_paths import resolve_task_work_dir
class PublishService: class PublishService:
@ -26,7 +27,7 @@ class PublishService:
if len(session_contexts) <= 1: if len(session_contexts) <= 1:
clip_videos = self._clip_videos_for_task(task_id) clip_videos = self._clip_videos_for_task(task_id)
record = provider.publish(task, clip_videos, settings) record = provider.publish(task, clip_videos, settings)
self._persist_publish_success(task_id, task.title, record, settings) self._persist_publish_success(task, record)
return record return record
anchor_context = session_contexts[0] anchor_context = session_contexts[0]
@ -41,7 +42,7 @@ class PublishService:
title=task.title, title=task.title,
published_at=utc_now_iso(), published_at=utc_now_iso(),
) )
self._persist_publish_success(task_id, task.title, record, settings) self._persist_publish_success(task, record)
return record return record
clip_videos = self._session_clip_videos(session_contexts) clip_videos = self._session_clip_videos(session_contexts)
@ -64,7 +65,7 @@ class PublishService:
title=record.title, title=record.title,
published_at=record.published_at, published_at=record.published_at,
) )
self._persist_publish_success(context.task_id, session_task.title, session_record, settings) self._persist_publish_success(session_task, session_record)
return PublishRecord( return PublishRecord(
id=None, id=None,
task_id=task_id, task_id=task_id,
@ -75,12 +76,13 @@ class PublishService:
published_at=record.published_at, published_at=record.published_at,
) )
def _persist_publish_success(self, task_id: str, task_title: str, record: PublishRecord, settings: dict[str, object]) -> None: def _persist_publish_success(self, task, record: PublishRecord) -> None: # type: ignore[no-untyped-def]
task_id = task.id
self.repo.add_publish_record(record) self.repo.add_publish_record(record)
if record.bvid: if record.bvid:
session_dir = Path(str(settings.get("session_dir", "session"))) / task_title work_dir = resolve_task_work_dir(task)
session_dir.mkdir(parents=True, exist_ok=True) work_dir.mkdir(parents=True, exist_ok=True)
bvid_path_obj = session_dir / "bvid.txt" bvid_path_obj = work_dir / "bvid.txt"
bvid_path_obj.write_text(record.bvid, encoding="utf-8") bvid_path_obj.write_text(record.bvid, encoding="utf-8")
self.repo.add_artifact( self.repo.add_artifact(
Artifact( Artifact(
@ -120,12 +122,11 @@ class PublishService:
return aggregated return aggregated
def _shared_session_bvid(self, contexts: list[TaskContext], settings: dict[str, object]) -> str | None: def _shared_session_bvid(self, contexts: list[TaskContext], settings: dict[str, object]) -> str | None:
session_dir_root = Path(str(settings.get("session_dir", "session")))
for context in contexts: for context in contexts:
task = self.repo.get_task(context.task_id) task = self.repo.get_task(context.task_id)
if task is None: if task is None:
continue continue
bvid_path = session_dir_root / task.title / "bvid.txt" bvid_path = resolve_task_work_dir(task) / "bvid.txt"
if bvid_path.exists(): if bvid_path.exists():
bvid = bvid_path.read_text(encoding="utf-8").strip() bvid = bvid_path.read_text(encoding="utf-8").strip()
if bvid.startswith("BV"): if bvid.startswith("BV"):
@ -138,8 +139,7 @@ class PublishService:
contexts: list[TaskContext], contexts: list[TaskContext],
settings: dict[str, object], settings: dict[str, object],
) -> dict[str, Any]: # type: ignore[no-untyped-def] ) -> dict[str, Any]: # type: ignore[no-untyped-def]
session_dir_root = Path(str(settings.get("session_dir", "session"))) anchor_work_dir = resolve_task_work_dir(anchor_task)
anchor_work_dir = (session_dir_root / anchor_task.title).resolve()
anchor_work_dir.mkdir(parents=True, exist_ok=True) anchor_work_dir.mkdir(parents=True, exist_ok=True)
aggregate_txt_lines: list[str] = [] aggregate_txt_lines: list[str] = []
aggregate_songs: list[dict[str, object]] = [] aggregate_songs: list[dict[str, object]] = []
@ -148,7 +148,7 @@ class PublishService:
task = self.repo.get_task(context.task_id) task = self.repo.get_task(context.task_id)
if task is None: if task is None:
continue continue
task_work_dir = (session_dir_root / task.title).resolve() task_work_dir = resolve_task_work_dir(task)
songs_txt = task_work_dir / "songs.txt" songs_txt = task_work_dir / "songs.txt"
songs_json = task_work_dir / "songs.json" songs_json = task_work_dir / "songs.json"

View File

@ -41,11 +41,11 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
provider = BilibiliTopCommentProvider(bilibili_api=api) provider = BilibiliTopCommentProvider(bilibili_api=api)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
task = Task("task-1", "local_file", str(root / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso())
task_dir_1 = root / "task-1" task_dir_1 = root / "task-1"
task_dir_2 = root / "task-2" task_dir_2 = root / "task-2"
task_dir_1.mkdir(parents=True, exist_ok=True) task_dir_1.mkdir(parents=True, exist_ok=True)
task_dir_2.mkdir(parents=True, exist_ok=True) task_dir_2.mkdir(parents=True, exist_ok=True)
task = Task("task-1", "local_file", str(task_dir_1 / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso())
(task_dir_1 / "songs.txt").write_text("00:00:00 Song A — Artist A\n", encoding="utf-8") (task_dir_1 / "songs.txt").write_text("00:00:00 Song A — Artist A\n", encoding="utf-8")
(task_dir_1 / "songs.json").write_text(json.dumps({"songs": [{"title": "Song A", "artist": "Artist A"}]}), encoding="utf-8") (task_dir_1 / "songs.json").write_text(json.dumps({"songs": [{"title": "Song A", "artist": "Artist A"}]}), encoding="utf-8")
(task_dir_1 / "bvid.txt").write_text("BV1SPLIT111", encoding="utf-8") (task_dir_1 / "bvid.txt").write_text("BV1SPLIT111", encoding="utf-8")
@ -65,6 +65,13 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001
return [self.get_task_context("task-1"), self.get_task_context("task-2")] return [self.get_task_context("task-1"), self.get_task_context("task-2")]
def get_task(self, task_id): # noqa: ANN001
mapping = {
"task-1": Task("task-1", "local_file", str(task_dir_1 / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso()),
"task-2": Task("task-2", "local_file", str(task_dir_2 / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso()),
}
return mapping[task_id]
result = provider.comment( result = provider.comment(
task, task,
{ {
@ -88,9 +95,9 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
provider = BilibiliTopCommentProvider(bilibili_api=api) provider = BilibiliTopCommentProvider(bilibili_api=api)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
task = Task("task-2", "local_file", str(root / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso())
task_dir = root / "task-2" task_dir = root / "task-2"
task_dir.mkdir(parents=True, exist_ok=True) task_dir.mkdir(parents=True, exist_ok=True)
task = Task("task-2", "local_file", str(task_dir / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso())
(task_dir / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8") (task_dir / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8")
(task_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Song B", "artist": "Artist B"}]}), encoding="utf-8") (task_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Song B", "artist": "Artist B"}]}), encoding="utf-8")
(task_dir / "bvid.txt").write_text("BV1SPLIT222", encoding="utf-8") (task_dir / "bvid.txt").write_text("BV1SPLIT222", encoding="utf-8")
@ -108,6 +115,13 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001
return [self.get_task_context("task-1"), self.get_task_context("task-2")] return [self.get_task_context("task-1"), self.get_task_context("task-2")]
def get_task(self, task_id): # noqa: ANN001
mapping = {
"task-1": Task("task-1", "local_file", str(root / "task-1" / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso()),
"task-2": Task("task-2", "local_file", str(task_dir / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso()),
}
return mapping[task_id]
result = provider.comment( result = provider.comment(
task, task,
{ {
@ -128,17 +142,17 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
provider = BilibiliTopCommentProvider(bilibili_api=_FakeBilibiliApi()) provider = BilibiliTopCommentProvider(bilibili_api=_FakeBilibiliApi())
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
work_dir = root / "task-1"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task( task = Task(
id="task-1", id="task-1",
source_type="local_file", source_type="local_file",
source_path=str(root / "source.mp4"), source_path=str(work_dir / "source.mp4"),
title="task-1", title="task-1",
status="published", status="published",
created_at=utc_now_iso(), created_at=utc_now_iso(),
updated_at=utc_now_iso(), updated_at=utc_now_iso(),
) )
work_dir = root / task.title
work_dir.mkdir(parents=True, exist_ok=True)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song", "artist": "Tester"}]}), encoding="utf-8") (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song", "artist": "Tester"}]}), encoding="utf-8")
(work_dir / "bvid.txt").write_text("BV1COMMENT123", encoding="utf-8") (work_dir / "bvid.txt").write_text("BV1COMMENT123", encoding="utf-8")
@ -162,16 +176,52 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
self.assertTrue((work_dir / "comment_full_done.flag").exists()) self.assertTrue((work_dir / "comment_full_done.flag").exists())
self.assertTrue((work_dir / "comment_done.flag").exists()) self.assertTrue((work_dir / "comment_done.flag").exists())
def test_comment_uses_source_path_parent_when_task_title_differs(self) -> None:
provider = BilibiliTopCommentProvider(bilibili_api=_FakeBilibiliApi())
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
work_dir = root / "task-id-dir"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task(
id="task-id",
source_type="bilibili_url",
source_path=str(work_dir / "task-id.mp4"),
title="display-title",
status="published",
created_at=utc_now_iso(),
updated_at=utc_now_iso(),
)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song", "artist": "Tester"}]}), encoding="utf-8")
(work_dir / "bvid.txt").write_text("BV1COMMENT123", encoding="utf-8")
cookies_file = root / "cookies.json"
cookies_file.write_text("{}", encoding="utf-8")
result = provider.comment(
task,
{
"session_dir": str(root),
"cookies_file": str(cookies_file),
"post_split_comment": True,
"post_full_video_timeline_comment": False,
},
)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["split"]["status"], "skipped")
self.assertEqual(result["split"]["reason"], "comment_disabled")
self.assertTrue((work_dir / "comment_done.flag").exists())
def test_full_comment_aggregates_session_parts_on_anchor_task(self) -> None: def test_full_comment_aggregates_session_parts_on_anchor_task(self) -> None:
api = _FakeBilibiliApi() api = _FakeBilibiliApi()
provider = BilibiliTopCommentProvider(bilibili_api=api) provider = BilibiliTopCommentProvider(bilibili_api=api)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
task = Task("task-1", "local_file", str(root / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso())
task_dir_1 = root / "task-1" task_dir_1 = root / "task-1"
task_dir_2 = root / "task-2" task_dir_2 = root / "task-2"
task_dir_1.mkdir(parents=True, exist_ok=True) task_dir_1.mkdir(parents=True, exist_ok=True)
task_dir_2.mkdir(parents=True, exist_ok=True) task_dir_2.mkdir(parents=True, exist_ok=True)
task = Task("task-1", "local_file", str(task_dir_1 / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso())
(task_dir_1 / "songs.txt").write_text("00:00:01 Song A\n00:02:00 Song B\n", encoding="utf-8") (task_dir_1 / "songs.txt").write_text("00:00:01 Song A\n00:02:00 Song B\n", encoding="utf-8")
(task_dir_1 / "songs.json").write_text(json.dumps({"songs": [{"title": "Song A"}]}), encoding="utf-8") (task_dir_1 / "songs.json").write_text(json.dumps({"songs": [{"title": "Song A"}]}), encoding="utf-8")
(task_dir_1 / "bvid.txt").write_text("BV1SPLIT111", encoding="utf-8") (task_dir_1 / "bvid.txt").write_text("BV1SPLIT111", encoding="utf-8")
@ -191,6 +241,13 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001
return [self.get_task_context("task-1"), self.get_task_context("task-2")] return [self.get_task_context("task-1"), self.get_task_context("task-2")]
def get_task(self, task_id): # noqa: ANN001
mapping = {
"task-1": Task("task-1", "local_file", str(task_dir_1 / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso()),
"task-2": Task("task-2", "local_file", str(task_dir_2 / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso()),
}
return mapping[task_id]
result = provider.comment( result = provider.comment(
task, task,
{ {
@ -214,9 +271,9 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
provider = BilibiliTopCommentProvider(bilibili_api=api) provider = BilibiliTopCommentProvider(bilibili_api=api)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
task = Task("task-2", "local_file", str(root / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso())
task_dir = root / "task-2" task_dir = root / "task-2"
task_dir.mkdir(parents=True, exist_ok=True) task_dir.mkdir(parents=True, exist_ok=True)
task = Task("task-2", "local_file", str(task_dir / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso())
(task_dir / "songs.txt").write_text("00:00:03 Song C\n", encoding="utf-8") (task_dir / "songs.txt").write_text("00:00:03 Song C\n", encoding="utf-8")
(task_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Song C"}]}), encoding="utf-8") (task_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Song C"}]}), encoding="utf-8")
(task_dir / "bvid.txt").write_text("BV1SPLIT222", encoding="utf-8") (task_dir / "bvid.txt").write_text("BV1SPLIT222", encoding="utf-8")
@ -235,6 +292,13 @@ class BilibiliTopCommentProviderTests(unittest.TestCase):
def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001 def list_task_contexts_by_session_key(self, session_key): # noqa: ANN001
return [self.get_task_context("task-1"), self.get_task_context("task-2")] return [self.get_task_context("task-1"), self.get_task_context("task-2")]
def get_task(self, task_id): # noqa: ANN001
mapping = {
"task-1": Task("task-1", "local_file", str(root / "task-1" / "source-1.mp4"), "task-1", "published", utc_now_iso(), utc_now_iso()),
"task-2": Task("task-2", "local_file", str(task_dir / "source-2.mp4"), "task-2", "published", utc_now_iso(), utc_now_iso()),
}
return mapping[task_id]
result = provider.comment( result = provider.comment(
task, task,
{ {

View File

@ -50,22 +50,71 @@ class BiliupCliAdapterTests(unittest.TestCase):
class BiliupCliPublishProviderTests(unittest.TestCase): class BiliupCliPublishProviderTests(unittest.TestCase):
def test_publish_uses_source_path_parent_when_task_title_differs(self) -> None:
adapter = _FakeBiliupAdapter()
provider = BiliupCliPublishProvider(adapter=adapter)
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
work_dir = root / "task-id-dir"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task(
id="task-id",
source_type="bilibili_url",
source_path=str(work_dir / "task-id.mp4"),
title="display-title",
status="split_done",
created_at=utc_now_iso(),
updated_at=utc_now_iso(),
)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8")
upload_config = root / "upload_config.json"
upload_config.write_text("{}", encoding="utf-8")
clip_path = work_dir / "clip-1.mp4"
clip_path.write_text("fake", encoding="utf-8")
clip = Artifact(
id=None,
task_id=task.id,
artifact_type="clip_video",
path=str(clip_path),
metadata_json="{}",
created_at=utc_now_iso(),
)
record = provider.publish(
task,
[clip],
{
"session_dir": str(root),
"upload_config_file": str(upload_config),
"biliup_path": "runtime/biliup",
"cookie_file": "runtime/cookies.json",
"retry_count": 1,
"command_timeout_seconds": 123,
},
)
self.assertEqual(record.bvid, "BV1TEST12345")
self.assertEqual(adapter.optional_calls[0]["log_path"], work_dir / "publish.log")
self.assertTrue((work_dir / "bvid.txt").exists())
self.assertTrue((work_dir / "upload_done.flag").exists())
def test_publish_passes_timeout_and_log_path(self) -> None: def test_publish_passes_timeout_and_log_path(self) -> None:
adapter = _FakeBiliupAdapter() adapter = _FakeBiliupAdapter()
provider = BiliupCliPublishProvider(adapter=adapter) provider = BiliupCliPublishProvider(adapter=adapter)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
work_dir = root / "task-1"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task( task = Task(
id="task-1", id="task-1",
source_type="local_file", source_type="local_file",
source_path=str(root / "source.mp4"), source_path=str(work_dir / "source.mp4"),
title="task-1", title="task-1",
status="split_done", status="split_done",
created_at=utc_now_iso(), created_at=utc_now_iso(),
updated_at=utc_now_iso(), updated_at=utc_now_iso(),
) )
work_dir = root / task.title
work_dir.mkdir(parents=True, exist_ok=True)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8")
upload_config = root / "upload_config.json" upload_config = root / "upload_config.json"
@ -117,17 +166,17 @@ class BiliupCliPublishProviderTests(unittest.TestCase):
provider = BiliupCliPublishProvider(adapter=adapter) provider = BiliupCliPublishProvider(adapter=adapter)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
work_dir = root / "task-1"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task( task = Task(
id="task-1", id="task-1",
source_type="local_file", source_type="local_file",
source_path=str(root / "source.mp4"), source_path=str(work_dir / "source.mp4"),
title="task-1", title="task-1",
status="split_done", status="split_done",
created_at=utc_now_iso(), created_at=utc_now_iso(),
updated_at=utc_now_iso(), updated_at=utc_now_iso(),
) )
work_dir = root / task.title
work_dir.mkdir(parents=True, exist_ok=True)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8")
(work_dir / "bvid.txt").write_text("BVOLD1234567", encoding="utf-8") (work_dir / "bvid.txt").write_text("BVOLD1234567", encoding="utf-8")
@ -165,17 +214,17 @@ class BiliupCliPublishProviderTests(unittest.TestCase):
provider = BiliupCliPublishProvider(adapter=adapter) provider = BiliupCliPublishProvider(adapter=adapter)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
work_dir = root / "task-1"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task( task = Task(
id="task-1", id="task-1",
source_type="local_file", source_type="local_file",
source_path=str(root / "source.mp4"), source_path=str(work_dir / "source.mp4"),
title="task-1", title="task-1",
status="split_done", status="split_done",
created_at=utc_now_iso(), created_at=utc_now_iso(),
updated_at=utc_now_iso(), updated_at=utc_now_iso(),
) )
work_dir = root / task.title
work_dir.mkdir(parents=True, exist_ok=True)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8")
(work_dir / "bvid.txt").write_text("BV1RESUME1234", encoding="utf-8") (work_dir / "bvid.txt").write_text("BV1RESUME1234", encoding="utf-8")
@ -225,17 +274,17 @@ class BiliupCliPublishProviderTests(unittest.TestCase):
provider = BiliupCliPublishProvider(adapter=adapter) provider = BiliupCliPublishProvider(adapter=adapter)
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
work_dir = root / "task-1"
work_dir.mkdir(parents=True, exist_ok=True)
task = Task( task = Task(
id="task-1", id="task-1",
source_type="local_file", source_type="local_file",
source_path=str(root / "source.mp4"), source_path=str(work_dir / "source.mp4"),
title="task-1", title="task-1",
status="split_done", status="split_done",
created_at=utc_now_iso(), created_at=utc_now_iso(),
updated_at=utc_now_iso(), updated_at=utc_now_iso(),
) )
work_dir = root / task.title
work_dir.mkdir(parents=True, exist_ok=True)
(work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8") (work_dir / "songs.txt").write_text("00:00:00 Test Song - Tester\n", encoding="utf-8")
(work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8") (work_dir / "songs.json").write_text(json.dumps({"songs": [{"title": "Test Song"}]}), encoding="utf-8")
(work_dir / "bvid.txt").write_text("BV1RESUME1234", encoding="utf-8") (work_dir / "bvid.txt").write_text("BV1RESUME1234", encoding="utf-8")

View File

@ -75,8 +75,10 @@ class PublishServiceTests(unittest.TestCase):
provider = _FakePublishProvider() provider = _FakePublishProvider()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir) root = Path(tmpdir)
task1 = Task("task-1", "local_file", "/tmp/a.mp4", "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") (root / "task-1").mkdir(parents=True, exist_ok=True)
task2 = Task("task-2", "local_file", "/tmp/b.mp4", "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") (root / "task-2").mkdir(parents=True, exist_ok=True)
task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at)
ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at)
artifacts = { artifacts = {
@ -108,8 +110,9 @@ class PublishServiceTests(unittest.TestCase):
root = Path(tmpdir) root = Path(tmpdir)
(root / "task-1").mkdir(parents=True, exist_ok=True) (root / "task-1").mkdir(parents=True, exist_ok=True)
(root / "task-1" / "bvid.txt").write_text("BV1SESSION123", encoding="utf-8") (root / "task-1" / "bvid.txt").write_text("BV1SESSION123", encoding="utf-8")
task1 = Task("task-1", "local_file", "/tmp/a.mp4", "task-1", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") (root / "task-2").mkdir(parents=True, exist_ok=True)
task2 = Task("task-2", "local_file", "/tmp/b.mp4", "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "published", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at)
ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at)
repo = _FakeRepo([task1, task2], [ctx1, ctx2], {"task-2": []}) repo = _FakeRepo([task1, task2], [ctx1, ctx2], {"task-2": []})
@ -132,8 +135,8 @@ class PublishServiceTests(unittest.TestCase):
(root / "task-2" / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8") (root / "task-2" / "songs.txt").write_text("00:00:00 Song B — Artist B\n", encoding="utf-8")
(root / "task-1" / "songs.json").write_text('{"songs":[{"title":"Song A"},{"title":"Song A2"}]}\n', encoding="utf-8") (root / "task-1" / "songs.json").write_text('{"songs":[{"title":"Song A"},{"title":"Song A2"}]}\n', encoding="utf-8")
(root / "task-2" / "songs.json").write_text('{"songs":[{"title":"Song B"}]}\n', encoding="utf-8") (root / "task-2" / "songs.json").write_text('{"songs":[{"title":"Song B"}]}\n', encoding="utf-8")
task1 = Task("task-1", "local_file", "/tmp/a.mp4", "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") task1 = Task("task-1", "local_file", str(root / "task-1" / "source.mp4"), "task-1", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
task2 = Task("task-2", "local_file", "/tmp/b.mp4", "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00") task2 = Task("task-2", "local_file", str(root / "task-2" / "source.mp4"), "task-2", "split_done", "2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00")
ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at) ctx1 = TaskContext(None, "task-1", "session-1", "s", None, "part-1", "2026-04-04T09:23:00+08:00", None, None, task1.created_at, task1.updated_at)
ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at) ctx2 = TaskContext(None, "task-2", "session-1", "s", None, "part-2", "2026-04-04T09:25:00+08:00", None, None, task2.created_at, task2.updated_at)
artifacts = { artifacts = {