diff --git a/sentence_api/main.py b/sentence_api/main.py index 9fe5ea0..aa4dd95 100644 --- a/sentence_api/main.py +++ b/sentence_api/main.py @@ -39,6 +39,7 @@ from .models import ( AuthRequest, AuthResponse, CourseSummary, + CourseTitleUpdate, SentenceBoundaryAdjust, SentenceBoundary, SentenceBoundaryDocument, @@ -624,6 +625,20 @@ def create_app( background_tasks.add_task(_process_safely, processor, video_hash) return {"video_hash": video_hash.lower(), "status": "processing_queued"} + @application.put( + "/api/v1/admin/videos/{video_hash}/title", + response_model=VideoSummary, + dependencies=[Depends(require_admin)], + ) + def update_course_title( + payload: CourseTitleUpdate, + video_hash: str = SHA256_PATH, + ) -> VideoSummary: + video = video_repository.update_title(video_hash, payload.title) + if video is None: + raise HTTPException(status_code=404, detail="Video was not found.") + return _video_summary(video, service_settings) + @application.put( "/api/v1/admin/videos/{video_hash}/sentences/{sentence_index}", response_model=SentenceBoundary, diff --git a/sentence_api/models.py b/sentence_api/models.py index 331b4b9..311bd95 100644 --- a/sentence_api/models.py +++ b/sentence_api/models.py @@ -92,6 +92,20 @@ class SentenceTextUpdate(BaseModel): language: Optional[str] = Field(default=None, max_length=32) +class CourseTitleUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + title: str = Field(max_length=200) + + @field_validator("title") + @classmethod + def validate_title(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("title cannot be empty") + return cleaned + + class PasswordResetRequest(BaseModel): model_config = ConfigDict(extra="forbid") diff --git a/sentence_api/repository.py b/sentence_api/repository.py index 6f9fb04..9cc92d1 100644 --- a/sentence_api/repository.py +++ b/sentence_api/repository.py @@ -349,6 +349,22 @@ class VideoRepository: (remote_url, utc_now(), normalize_video_hash(video_hash)), ) + def update_title(self, video_hash: str, title: str) -> Optional[Dict[str, Any]]: + normalized_hash = normalize_video_hash(video_hash) + cleaned_title = title.strip()[:200] + if not cleaned_title: + raise ValueError("Title cannot be empty.") + + with self._connect() as connection: + cursor = connection.execute( + "UPDATE videos SET title = ?, updated_at = ? WHERE video_hash = ?", + (cleaned_title, utc_now(), normalized_hash), + ) + if cursor.rowcount == 0: + return None + + return self.get_video(normalized_hash) + def list_videos(self) -> List[Dict[str, Any]]: with self._connect() as connection: rows = connection.execute( diff --git a/sentence_api/static/admin.html b/sentence_api/static/admin.html index 25cd807..3a98581 100644 --- a/sentence_api/static/admin.html +++ b/sentence_api/static/admin.html @@ -114,6 +114,6 @@ - + diff --git a/sentence_api/static/admin.js b/sentence_api/static/admin.js index cc9676c..f05e97a 100644 --- a/sentence_api/static/admin.js +++ b/sentence_api/static/admin.js @@ -181,6 +181,7 @@ function actionCell(video) { play.rel = "noopener"; play.textContent = "播放"; actions.append(play); + actions.append(button("改标题", () => renameVideo(video))); actions.append(button("句子", () => openSentences(video))); actions.append(button("重新处理", () => reprocess(video.video_hash))); const remove = button("删除", () => deleteVideo(video)); @@ -198,6 +199,28 @@ function button(label, handler) { return result; } +async function renameVideo(video) { + const title = window.prompt("请输入新的课程标题", video.title || ""); + if (title === null) return; + const cleanedTitle = title.trim(); + if (!cleanedTitle) { + elements.serviceState.textContent = "课程标题不能为空"; + return; + } + if (cleanedTitle === video.title) return; + try { + await api(`/api/v1/admin/videos/${video.video_hash}/title`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: cleanedTitle }), + }); + elements.serviceState.textContent = "课程标题已更新"; + await loadVideos(); + } catch (error) { + elements.serviceState.textContent = `标题修改失败: ${error.message}`; + } +} + function uploadVideo(event) { event.preventDefault(); const file = document.querySelector("#video-file").files[0]; diff --git a/sentence_api/tests/test_api.py b/sentence_api/tests/test_api.py index 55e30c3..ecc9740 100644 --- a/sentence_api/tests/test_api.py +++ b/sentence_api/tests/test_api.py @@ -562,3 +562,40 @@ def test_admin_boundary_adjustment_updates_linked_next_start(tmp_path): document = repository.get_document(VIDEO_HASH) assert [item.end_ms for item in document.sentences] == [4250, 9000] assert [item.start_ms for item in document.sentences] == [0, 4250] + + +def test_admin_can_update_course_title(tmp_path): + settings = replace(Settings.from_env(), data_dir=tmp_path / "data") + settings.ensure_directories() + repository = VideoRepository(settings.database_path) + repository.upsert_upload( + video_hash=VIDEO_HASH, + title="Old course title", + filename="lesson.mp4", + stored_filename=f"{VIDEO_HASH}.mp4", + content_type="video/mp4", + size_bytes=5, + language="en", + ) + client = TestClient( + create_app( + BoundaryStore(tmp_path / "boundaries.json"), + settings=settings, + repository=repository, + ) + ) + + response = client.put( + f"/api/v1/admin/videos/{VIDEO_HASH}/title", + json={"title": " New course title "}, + ) + + assert response.status_code == 200 + assert response.json()["title"] == "New course title" + assert repository.get_video(VIDEO_HASH)["title"] == "New course title" + + missing_response = client.put( + f"/api/v1/admin/videos/{'b' * 64}/title", + json={"title": "Missing course"}, + ) + assert missing_response.status_code == 404