Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: CI

on:
pull_request:
branches: [main, develop, "release/*"]
push:
branches: [main]

permissions:
contents: read

jobs:
lint:
name: Lint
runs-on: ${{ vars.RUNNER_LABEL || 'blacksmith-2vcpu-ubuntu-2404' }}
steps:
Comment on lines +15 to +16
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

runs-on defaults to blacksmith-2vcpu-ubuntu-2404, which is not a standard GitHub-hosted runner label. Unless this repo is guaranteed to have that runner available, the workflow will fail to schedule jobs. Consider defaulting to ubuntu-latest (or make the runner label an explicit required variable without a nonstandard fallback).

Copilot uses AI. Check for mistakes.
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install ruff
- run: ruff check .
- run: ruff format --check .

typecheck:
name: Type Check
runs-on: ${{ vars.RUNNER_LABEL || 'blacksmith-2vcpu-ubuntu-2404' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]" mypy types-PyYAML
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This install step redundantly installs mypy and types-PyYAML even though they’re already included in the .[dev] extra added in pyproject.toml. Dropping the extra packages (or alternatively not using .[dev] here) will make the job faster and avoid divergent tool versions between local dev and CI.

Suggested change
- run: pip install -e ".[dev]" mypy types-PyYAML
- run: pip install -e ".[dev]"

Copilot uses AI. Check for mistakes.
env:
PIP_INDEX_URL: https://pypi.org/simple
- run: mypy src/narrator_ai --ignore-missing-imports

test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ${{ vars.RUNNER_LABEL || 'blacksmith-2vcpu-ubuntu-2404' }}
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]" pytest-cov
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This step redundantly installs pytest-cov even though it’s already included in the .[dev] extra. Installing only -e ".[dev]" reduces CI time and keeps the dependency set consistent.

Suggested change
- run: pip install -e ".[dev]" pytest-cov
- run: pip install -e ".[dev]"

Copilot uses AI. Check for mistakes.
env:
PIP_INDEX_URL: https://pypi.org/simple
- run: pytest -v --tb=short --cov=narrator_ai --cov-report=xml

security:
name: Security Scan
runs-on: ${{ vars.RUNNER_LABEL || 'blacksmith-2vcpu-ubuntu-2404' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pip-audit bandit
- name: Install project
run: pip install -e ".[dev]"
env:
PIP_INDEX_URL: https://pypi.org/simple
- name: Audit dependencies
run: pip-audit --desc --skip-editable
Comment on lines +63 to +68
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The security job installs -e ".[dev]" before running pip-audit, which means the audit includes dev-only tools (pytest/ruff/mypy/etc.) and can fail due to vulnerabilities unrelated to production/runtime dependencies. If the intent is to gate runtime deps, install only the project/runtime deps for this job (e.g., pip install -e .) or otherwise scope what pip-audit checks.

Copilot uses AI. Check for mistakes.
- run: bandit -r src/narrator_ai -c pyproject.toml
Comment on lines +67 to +69
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pip-audit exits non-zero when vulnerabilities are found, which will fail the entire workflow. The PR description notes the scan currently finds real CVEs tracked separately; if those CVEs aren't being fixed in this PR, consider making the audit step non-blocking for now (e.g., continue-on-error on the audit step/job or temporarily ignoring specific vulnerability IDs) so PRs can still merge while remediation is tracked.

Copilot uses AI. Check for mistakes.
40 changes: 40 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,28 @@ dependencies = [
[project.scripts]
narrator-ai-cli = "narrator_ai.cli:app"

[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=5.0",
"ruff>=0.4",
"mypy>=1.10",
"types-PyYAML>=6.0",
"bandit>=1.7",
"pip-audit>=2.7",
]

[dependency-groups]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=5.0",
"ruff>=0.4",
"mypy>=1.10",
"types-PyYAML>=6.0",
"bandit>=1.7",
"pip-audit>=2.7",
]
Comment on lines +18 to 40
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[project.optional-dependencies].dev and [dependency-groups].dev contain the same list. If both are required (pip extras vs uv groups), this duplication is easy to let drift. Consider documenting that they must stay in sync, or consolidating (e.g., rely on one mechanism in CI/local dev) to avoid future inconsistencies.

Copilot uses AI. Check for mistakes.

[build-system]
Expand All @@ -31,3 +49,25 @@ packages = ["src/narrator_ai"]
[[tool.uv.index]]
url = "https://mirrors.aliyun.com/pypi/simple"
default = true

[tool.ruff]
target-version = "py310"
line-length = 120
extend-exclude = ["scripts", "install.py"]

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "S"]
ignore = ["S101", "S108", "S603", "S607", "B904", "E501", "UP036"]

[tool.mypy]
python_version = "3.10"
warn_return_any = false
warn_unused_configs = true
disallow_untyped_defs = false

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"

[tool.bandit]
exclude_dirs = ["tests"]
6 changes: 5 additions & 1 deletion src/narrator_ai/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ def version_callback(value: bool):
@app.callback()
def main(
version: bool = typer.Option(
False, "--version", "-v", callback=version_callback, is_eager=True,
False,
"--version",
"-v",
callback=version_callback,
is_eager=True,
help="Show version and exit.",
),
):
Expand Down
70 changes: 36 additions & 34 deletions src/narrator_ai/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"""

import json as _json
from typing import Any, Optional
from typing import Any

import httpx
from httpx_sse import connect_sse
Expand All @@ -23,14 +23,14 @@ def __init__(self, code: int, message: str):
class NarratorClient:
def __init__(
self,
server: Optional[str] = None,
app_key: Optional[str] = None,
timeout: Optional[int] = None,
server: str | None = None,
app_key: str | None = None,
timeout: int | None = None,
):
self.server = server if server is not None else get_server()
self.app_key = app_key if app_key is not None else get_app_key()
self.timeout = timeout if timeout is not None else get_timeout()
self._client: Optional[httpx.Client] = None
self._client: httpx.Client | None = None

def _get_client(self, **kwargs) -> httpx.Client:
"""Get or create a reusable httpx.Client."""
Expand Down Expand Up @@ -58,55 +58,57 @@ def _handle_response(self, resp: httpx.Response) -> dict:
raise NarratorAPIError(code, data.get("message", "Unknown error"))
return data.get("data")

def get(self, path: str, params: Optional[dict] = None) -> Any:
def get(self, path: str, params: dict | None = None) -> Any:
client = self._get_client()
resp = client.get(self._url(path), params=params)
return self._handle_response(resp)

def post(
self,
path: str,
json: Optional[dict] = None,
params: Optional[dict] = None,
json: dict | None = None,
params: dict | None = None,
) -> Any:
client = self._get_client()
resp = client.post(self._url(path), json=json, params=params)
return self._handle_response(resp)

def post_sse(self, path: str, json: Optional[dict] = None):
def post_sse(self, path: str, json: dict | None = None):
"""POST with SSE streaming. Yields (event_type, data_dict) tuples."""
headers = {**self._headers(), "Accept": "text/event-stream"}
with httpx.Client(timeout=httpx.Timeout(self.timeout, read=300.0)) as c:
with connect_sse(
c, "POST", self._url(path), headers=headers, json=json
) as sse:
for event in sse.iter_sse():
event_type = event.event or "message"
try:
event_data = _json.loads(event.data)
except (ValueError, TypeError):
event_data = {"raw": event.data}
yield event_type, event_data

def delete(self, path: str, params: Optional[dict] = None) -> Any:
with (
httpx.Client(timeout=httpx.Timeout(self.timeout, read=300.0)) as c,
connect_sse(c, "POST", self._url(path), headers=headers, json=json) as sse,
):
for event in sse.iter_sse():
event_type = event.event or "message"
try:
event_data = _json.loads(event.data)
except (ValueError, TypeError):
event_data = {"raw": event.data}
yield event_type, event_data

def delete(self, path: str, params: dict | None = None) -> Any:
client = self._get_client()
resp = client.delete(self._url(path), params=params)
return self._handle_response(resp)

def upload_file(self, upload_url: str, file_path: str, content_type: str = "application/octet-stream"):
"""Upload file to presigned URL."""
with open(file_path, "rb") as f:
with httpx.Client(timeout=httpx.Timeout(connect=self.timeout, read=600.0, write=None, pool=self.timeout)) as c:
resp = c.put(
upload_url,
content=f,
headers={"Content-Type": content_type},
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
raise NarratorAPIError(resp.status_code, f"Upload failed: HTTP {resp.status_code}") from e
return resp
with (
open(file_path, "rb") as f,
httpx.Client(timeout=httpx.Timeout(connect=self.timeout, read=600.0, write=None, pool=self.timeout)) as c,
):
resp = c.put(
upload_url,
content=f,
headers={"Content-Type": content_type},
)
try:
resp.raise_for_status()
except httpx.HTTPStatusError as e:
raise NarratorAPIError(resp.status_code, f"Upload failed: HTTP {resp.status_code}") from e
return resp

def close(self):
if self._client and not self._client.is_closed:
Expand Down
6 changes: 2 additions & 4 deletions src/narrator_ai/commands/bgm.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""Pre-built BGM (background music) resources for task creation."""

from typing import Optional

import typer

from narrator_ai.output import console, print_error, print_json, print_table
from narrator_ai import DOCS_URL
from narrator_ai.output import console, print_error, print_json, print_table

app = typer.Typer(
help=(
Expand Down Expand Up @@ -168,7 +166,7 @@

@app.command("list")
def list_bgm(
search: Optional[str] = typer.Option(None, "--search", "-s", help="Search by BGM name"),
search: str | None = typer.Option(None, "--search", "-s", help="Search by BGM name"),
json_mode: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List pre-built BGM tracks. Use the ID as 'bgm' parameter in task creation.
Expand Down
49 changes: 33 additions & 16 deletions src/narrator_ai/commands/dubbing.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
"""Pre-built dubbing (voice) resources for task creation."""

from typing import Optional

import typer

from narrator_ai.output import console, print_error, print_json, print_table
from narrator_ai import DOCS_URL
from narrator_ai.output import console, print_error, print_json, print_table

app = typer.Typer(
help=(
Expand Down Expand Up @@ -42,21 +40,36 @@
{"name": "严肃青年解说-适合动作、冒险类", "id": "mercury_yunxi_24k@serious", "type": "普通话", "tag": "动作冒险"},
{"name": "气泡音男声-适合动作、冒险类", "id": "momoyuan_meet_24k", "type": "普通话", "tag": "动作冒险"},
{"name": "慵懒调侃男声-适合动作、冒险类", "id": "jupiter_BV107DialogMale", "type": "普通话", "tag": "动作冒险"},
{"name": "神秘女声-适合动作、冒险、恐怖、惊悚类", "id": "galaxy_fastv7_moyingxi@angry", "type": "普通话", "tag": "动作冒险"},
{
"name": "神秘女声-适合动作、冒险、恐怖、惊悚类",
"id": "galaxy_fastv7_moyingxi@angry",
"type": "普通话",
"tag": "动作冒险",
},
{"name": "东北老妹儿-适合喜剧", "id": "mercury_ln-xiaobei_24k", "type": "普通话", "tag": "喜剧"},
{"name": "幽默闲聊女声-适合喜剧", "id": "galaxy_fastv8_moxueqin", "type": "普通话", "tag": "喜剧"},
{"name": "犀利青年音-适合喜剧", "id": "galaxy_fastv8_mowasi", "type": "普通话", "tag": "喜剧"},
{"name": "恐惧感大叔音-适合恐怖、惊悚类", "id": "mercury_yunye_24k@fearful", "type": "普通话", "tag": "恐怖惊悚"},
{"name": "恐惧低沉大叔音-适合恐怖、惊悚类", "id": "mercury_yunze_24k@fearful", "type": "普通话", "tag": "恐怖惊悚"},
{"name": "不安男声-适合恐怖、惊悚类、科幻", "id": "mercury_yunxi_48k@embarrassed", "type": "普通话", "tag": "恐怖惊悚"},
{
"name": "不安男声-适合恐怖、惊悚类、科幻",
"id": "mercury_yunxi_48k@embarrassed",
"type": "普通话",
"tag": "恐怖惊悚",
},
{"name": "松弛大叔音-适合爱情、剧情类", "id": "mercury_yunyang_24k@newscast", "type": "普通话", "tag": "爱情剧情"},
{"name": "元气少女音-适合爱情、剧情类", "id": "mercury_xiaochen_48k", "type": "普通话", "tag": "爱情剧情"},
{"name": "磁性御姐音-适合爱情、剧情类", "id": "yangjingv_meet_24k", "type": "普通话", "tag": "爱情剧情"},
{"name": "燃爆男声解说-适合爱情、剧情类", "id": "mercury_yunxi_24k", "type": "普通话", "tag": "爱情剧情"},
{"name": "快嘴直爽青年-适合科幻类", "id": "moxidu_meet_24k@kehuan", "type": "普通话", "tag": "科幻"},
{"name": "磁性悬疑男声-适合科幻类", "id": "manchaozn_meet_24k@boya", "type": "普通话", "tag": "科幻"},
{"name": "冷静青年解说-适合历史、战争类", "id": "mercury_yunxi_48k@calm", "type": "普通话", "tag": "历史战争"},
{"name": "沉稳大叔音-适合历史、战争类", "id": "mercury_yunze_24k@documentary-narration", "type": "普通话", "tag": "历史战争"},
{
"name": "沉稳大叔音-适合历史、战争类",
"id": "mercury_yunze_24k@documentary-narration",
"type": "普通话",
"tag": "历史战争",
},
{"name": "纪实磁性男声-适合历史、战争类", "id": "manchaozn_meet_24k@jilupian", "type": "普通话", "tag": "历史战争"},
{"name": "沉稳御姐音-适合历史、战争类", "id": "liyuansong_meet_24k@tale", "type": "普通话", "tag": "历史战争"},
# 英语
Expand Down Expand Up @@ -98,9 +111,11 @@

@app.command("list")
def list_dubbing(
lang: Optional[str] = typer.Option(None, "--lang", "-l", help="Filter by language/dubbing_type (e.g. 普通话, 英语, 日语)"),
tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by tag (e.g. 喜剧, 恐怖惊悚, 角色, 通用男声)"),
search: Optional[str] = typer.Option(None, "--search", "-s", help="Search by voice name"),
lang: str | None = typer.Option(
None, "--lang", "-l", help="Filter by language/dubbing_type (e.g. 普通话, 英语, 日语)"
),
tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by tag (e.g. 喜剧, 恐怖惊悚, 角色, 通用男声)"),
search: str | None = typer.Option(None, "--search", "-s", help="Search by voice name"),
json_mode: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List pre-built dubbing voices.
Expand Down Expand Up @@ -140,30 +155,32 @@ def list_languages(
json_mode: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List available dubbing languages (dubbing_type values) with counts."""
lang_counts = {}
lang_counts: dict[str, int] = {}
for d in DUBBING_LIST:
lang_counts[d["type"]] = lang_counts.get(d["type"], 0) + 1
items = [{"language": l, "count": c} for l, c in sorted(lang_counts.items())]
items = [{"language": lang, "count": cnt} for lang, cnt in sorted(lang_counts.items())]

if json_mode:
print_json(items)
else:
print_table(items, [("language", "Language (dubbing_type)"), ("count", "Count")],
title=f"Dubbing Languages ({len(DUBBING_LIST)} voices)")
print_table(
items,
[("language", "Language (dubbing_type)"), ("count", "Count")],
title=f"Dubbing Languages ({len(DUBBING_LIST)} voices)",
)


@app.command("tags")
def list_tags(
json_mode: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List available voice tags (genre recommendations) with counts."""
tag_counts = {}
tag_counts: dict[str, int] = {}
for d in DUBBING_LIST:
tag_counts[d["tag"]] = tag_counts.get(d["tag"], 0) + 1
items = [{"tag": t, "count": c} for t, c in sorted(tag_counts.items())]

if json_mode:
print_json(items)
else:
print_table(items, [("tag", "Tag"), ("count", "Count")],
title=f"Voice Tags ({len(DUBBING_LIST)} voices)")
print_table(items, [("tag", "Tag"), ("count", "Count")], title=f"Voice Tags ({len(DUBBING_LIST)} voices)")
Loading
Loading