diff --git a/engine_manifest.json b/engine_manifest.json index 73782a833..2ceaaabea 100644 --- a/engine_manifest.json +++ b/engine_manifest.json @@ -12,6 +12,7 @@ "terms_of_service": "engine_manifest_assets/terms_of_service.md", "update_infos": "engine_manifest_assets/update_infos.json", "dependency_licenses": "engine_manifest_assets/dependency_licenses.json", + "supported_vvlib_manifest_version": "0.15.0", "supported_features": { "adjust_mora_pitch": { "type": "bool", diff --git a/run.py b/run.py index 6c715fdc0..62c178bdd 100644 --- a/run.py +++ b/run.py @@ -178,10 +178,16 @@ async def block_origin_middleware(request: Request, call_next): preset_manager = PresetManager( preset_path=root_dir / "presets.yaml", ) - engine_manifest_loader = EngineManifestLoader( + engine_manifest_data = EngineManifestLoader( root_dir / "engine_manifest.json", root_dir + ).load_manifest() + library_manager = LibraryManager( + get_save_dir() / "installed_libraries", + engine_manifest_data.supported_vvlib_manifest_version, + engine_manifest_data.brand_name, + engine_manifest_data.name, + engine_manifest_data.uuid, ) - library_manager = LibraryManager(get_save_dir() / "installed_libraries") metas_store = MetasStore(root_dir / "speaker_info") @@ -812,8 +818,7 @@ def downloadable_libraries(): ------- ret_data: List[DownloadableLibrary] """ - manifest = engine_manifest_loader.load_manifest() - if not manifest.supported_features.manage_library: + if not engine_manifest_data.supported_features.manage_library: raise HTTPException(status_code=404, detail="この機能は実装されていません") return library_manager.downloadable_libraries() @@ -830,8 +835,7 @@ def installed_libraries(): ------- ret_data: List[DownloadableLibrary] """ - manifest = engine_manifest_loader.load_manifest() - if not manifest.supported_features.manage_library: + if not engine_manifest_data.supported_features.manage_library: raise HTTPException(status_code=404, detail="この機能は実装されていません") return library_manager.installed_libraries() @@ -850,8 +854,7 @@ async def install_library(library_uuid: str, request: Request): library_uuid: str 音声ライブラリのID """ - manifest = engine_manifest_loader.load_manifest() - if not manifest.supported_features.manage_library: + if not engine_manifest_data.supported_features.manage_library: raise HTTPException(status_code=404, detail="この機能は実装されていません") archive = BytesIO(await request.body()) loop = asyncio.get_event_loop() @@ -874,8 +877,7 @@ def uninstall_library(library_uuid: str): library_uuid: str 音声ライブラリのID """ - manifest = engine_manifest_loader.load_manifest() - if not manifest.supported_features.manage_library: + if not engine_manifest_data.supported_features.manage_library: raise HTTPException(status_code=404, detail="この機能は実装されていません") library_manager.uninstall_library(library_uuid) return Response(status_code=204) @@ -1063,7 +1065,7 @@ def supported_devices( @app.get("/engine_manifest", response_model=EngineManifest, tags=["その他"]) def engine_manifest(): - return engine_manifest_loader.load_manifest() + return engine_manifest_data @app.post( "/validate_kana", diff --git a/test/test_downloadable_library.py b/test/test_downloadable_library.py new file mode 100644 index 000000000..c47788812 --- /dev/null +++ b/test/test_downloadable_library.py @@ -0,0 +1,180 @@ +import copy +import glob +import json +import os +from io import BytesIO +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import TestCase +from zipfile import ZipFile + +from fastapi import HTTPException + +from voicevox_engine.downloadable_library import LibraryManager + +vvlib_manifest_name = "vvlib_manifest.json" + + +class TestLibraryManager(TestCase): + def setUp(self): + super().setUp() + self.tmp_dir = TemporaryDirectory() + self.tmp_dir_path = Path(self.tmp_dir.name) + self.engine_name = "Test Engine" + self.library_manger = LibraryManager( + self.tmp_dir_path, + "0.15.0", + "Test", + self.engine_name, + "c7b58856-bd56-4aa1-afb7-b8415f824b06", + ) + self.library_filename = Path("test/test.vvlib") + with open("test/vvlib_manifest.json") as f: + self.vvlib_manifest = json.loads(f.read()) + self.library_uuid = self.vvlib_manifest["uuid"] + with ZipFile(self.library_filename, "w") as zf: + speaker_infos = glob.glob("speaker_info/**", recursive=True) + for info in speaker_infos: + zf.write(info) + zf.writestr(vvlib_manifest_name, json.dumps(self.vvlib_manifest)) + self.library_file = open(self.library_filename, "br") + + def tearDown(self): + self.tmp_dir.cleanup() + self.library_file.close() + self.library_filename.unlink() + + def create_vvlib_without_manifest(self, filename: str): + with ZipFile(filename, "w") as zf_out, ZipFile( + self.library_filename, "r" + ) as zf_in: + for file in zf_in.infolist(): + buffer = zf_in.read(file.filename) + if file.filename != vvlib_manifest_name: + zf_out.writestr(file, buffer) + + def create_vvlib_manifest(self, **kwargs): + vvlib_manifest = copy.deepcopy(self.vvlib_manifest) + return {**vvlib_manifest, **kwargs} + + def test_installed_libraries(self): + self.assertEqual(self.library_manger.installed_libraries(), {}) + + self.library_manger.install_library( + self.library_uuid, + self.library_file, + ) + # 内容はdownloadable_library.jsonを元に生成されるので、内容は確認しない + self.assertEqual( + list(self.library_manger.installed_libraries().keys())[0], self.library_uuid + ) + + self.library_manger.uninstall_library(self.library_uuid) + self.assertEqual(self.library_manger.installed_libraries(), {}) + + def test_install_library(self): + # エンジンが把握していないライブラリのテスト + with self.assertRaises(HTTPException) as e: + self.library_manger.install_library( + "52398bd5-3cc3-406c-a159-dfec5ace4bab", self.library_file + ) + self.assertEqual(e.exception.detail, "指定された音声ライブラリが見つかりません。") + + # 不正なZIPファイルのテスト + with self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, BytesIO()) + self.assertEqual(e.exception.detail, "不正なZIPファイルです。") + + # vvlib_manifestの存在確認のテスト + invalid_vvlib_name = "test/invalid.vvlib" + self.create_vvlib_without_manifest(invalid_vvlib_name) + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual(e.exception.detail, "指定された音声ライブラリにvvlib_manifest.jsonが存在しません。") + + # vvlib_manifestのパースのテスト + # Duplicate name: 'vvlib_manifest.json'とWarningを吐かれるので、毎回作り直す + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, "test") + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual(e.exception.detail, "指定された音声ライブラリのvvlib_manifest.jsonは不正です。") + + # vvlib_manifestのパースのテスト + invalid_vvlib_manifest = self.create_vvlib_manifest(version=10) + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest)) + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual( + e.exception.detail, "指定された音声ライブラリのvvlib_manifest.jsonに不正なデータが含まれています。" + ) + + # vvlib_manifestの不正なversionのテスト + invalid_vvlib_manifest = self.create_vvlib_manifest(version="10") + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest)) + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual(e.exception.detail, "指定された音声ライブラリのversionが不正です。") + + # vvlib_manifestの不正なmanifest_versionのテスト + invalid_vvlib_manifest = self.create_vvlib_manifest(manifest_version="10") + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest)) + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual(e.exception.detail, "指定された音声ライブラリのmanifest_versionが不正です。") + + # vvlib_manifestの未対応のmanifest_versionのテスト + invalid_vvlib_manifest = self.create_vvlib_manifest( + manifest_version="999.999.999" + ) + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest)) + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual(e.exception.detail, "指定された音声ライブラリは未対応です。") + + # vvlib_manifestのインストール先エンジンの検証のテスト + invalid_vvlib_manifest = self.create_vvlib_manifest( + engine_uuid="26f7823b-20c6-40c5-bf86-6dd5d9d45c18" + ) + self.create_vvlib_without_manifest(invalid_vvlib_name) + with ZipFile(invalid_vvlib_name, "a") as zf: + zf.writestr(vvlib_manifest_name, json.dumps(invalid_vvlib_manifest)) + + with open(invalid_vvlib_name, "br") as f, self.assertRaises(HTTPException) as e: + self.library_manger.install_library(self.library_uuid, f) + self.assertEqual( + e.exception.detail, f"指定された音声ライブラリは{self.engine_name}向けではありません。" + ) + + # 正しいライブラリをインストールして問題が起きないか + library_path = self.library_manger.install_library( + self.library_uuid, self.library_file + ) + self.assertEqual(self.tmp_dir_path / self.library_uuid, library_path) + + self.library_manger.uninstall_library(self.library_uuid) + + os.remove(invalid_vvlib_name) + + def test_uninstall_library(self): + # TODO: アンインストール出来ないライブラリをテストできるようにしたい + with self.assertRaises(HTTPException) as e: + self.library_manger.uninstall_library(self.library_uuid) + self.assertEqual(e.exception.detail, "指定された音声ライブラリはインストールされていません。") + + self.library_manger.install_library(self.library_uuid, self.library_file) + self.library_manger.uninstall_library(self.library_uuid) diff --git a/test/vvlib_manifest.json b/test/vvlib_manifest.json new file mode 100644 index 000000000..f12d59eeb --- /dev/null +++ b/test/vvlib_manifest.json @@ -0,0 +1,9 @@ +{ + "manifest_version": "0.15.0", + "name": "Test vvlib", + "version": "0.0.1", + "uuid": "2bb8bccf-1c3f-4bc9-959a-f388e37af3ad", + "engine_name": "Test Engine", + "brand_name": "Test", + "engine_uuid": "c7b58856-bd56-4aa1-afb7-b8415f824b06" +} \ No newline at end of file diff --git a/voicevox_engine/downloadable_library.py b/voicevox_engine/downloadable_library.py index faf2efa79..a80d701fa 100644 --- a/voicevox_engine/downloadable_library.py +++ b/voicevox_engine/downloadable_library.py @@ -1,5 +1,6 @@ import base64 import json +import os import shutil import zipfile from io import BytesIO @@ -7,8 +8,10 @@ from typing import Dict from fastapi import HTTPException +from pydantic import ValidationError +from semver.version import Version -from voicevox_engine.model import DownloadableLibrary, InstalledLibrary +from voicevox_engine.model import DownloadableLibrary, InstalledLibrary, VvlibManifest __all__ = ["LibraryManager"] @@ -16,9 +19,20 @@ class LibraryManager: - def __init__(self, library_root_dir: Path): + def __init__( + self, + library_root_dir: Path, + supported_vvlib_version: str, + brand_name: str, + engine_name: str, + engine_uuid: str, + ): self.library_root_dir = library_root_dir self.library_root_dir.mkdir(exist_ok=True) + self.supported_vvlib_version = Version.parse(supported_vvlib_version) + self.engine_brand_name = brand_name + self.engine_name = engine_name + self.engine_uuid = engine_uuid def downloadable_libraries(self): # == ダウンロード情報をネットワーク上から取得する場合 @@ -64,10 +78,11 @@ def installed_libraries(self) -> Dict[str, InstalledLibrary]: library = {} for library_dir in self.library_root_dir.iterdir(): if library_dir.is_dir(): + library_uuid = os.path.basename(library_dir) with open(library_dir / INFO_FILE, encoding="utf-8") as f: - library[library_dir] = json.load(f) + library[library_uuid] = json.load(f) # アンインストール出来ないライブラリを作る場合、何かしらの条件でFalseを設定する - library[library_dir]["uninstallable"] = True + library[library_uuid]["uninstallable"] = True return library def install_library(self, library_id: str, file: BytesIO): @@ -81,10 +96,59 @@ def install_library(self, library_id: str, file: BytesIO): library_dir.mkdir(exist_ok=True) with open(library_dir / INFO_FILE, "w", encoding="utf-8") as f: json.dump(library_info, f, indent=4, ensure_ascii=False) + if not zipfile.is_zipfile(file): + raise HTTPException(status_code=422, detail="不正なZIPファイルです。") + with zipfile.ZipFile(file) as zf: if zf.testzip() is not None: raise HTTPException(status_code=422, detail="不正なZIPファイルです。") + # validate manifest version + vvlib_manifest = None + try: + vvlib_manifest = json.loads( + zf.read("vvlib_manifest.json").decode("utf-8") + ) + except KeyError: + raise HTTPException( + status_code=422, detail="指定された音声ライブラリにvvlib_manifest.jsonが存在しません。" + ) + except Exception: + raise HTTPException( + status_code=422, detail="指定された音声ライブラリのvvlib_manifest.jsonは不正です。" + ) + + try: + VvlibManifest.validate(vvlib_manifest) + except ValidationError: + raise HTTPException( + status_code=422, + detail="指定された音声ライブラリのvvlib_manifest.jsonに不正なデータが含まれています。", + ) + + if not Version.is_valid(vvlib_manifest["version"]): + raise HTTPException( + status_code=422, detail="指定された音声ライブラリのversionが不正です。" + ) + + try: + vvlib_manifest_version = Version.parse( + vvlib_manifest["manifest_version"] + ) + except ValueError: + raise HTTPException( + status_code=422, + detail="指定された音声ライブラリのmanifest_versionが不正です。", + ) + + if vvlib_manifest_version > self.supported_vvlib_version: + raise HTTPException(status_code=422, detail="指定された音声ライブラリは未対応です。") + + if vvlib_manifest["engine_uuid"] != self.engine_uuid: + raise HTTPException( + status_code=422, detail=f"指定された音声ライブラリは{self.engine_name}向けではありません。" + ) + zf.extractall(library_dir) return library_dir diff --git a/voicevox_engine/engine_manifest/EngineManifest.py b/voicevox_engine/engine_manifest/EngineManifest.py index 44a9329b4..e344e191a 100644 --- a/voicevox_engine/engine_manifest/EngineManifest.py +++ b/voicevox_engine/engine_manifest/EngineManifest.py @@ -55,4 +55,5 @@ class EngineManifest(BaseModel): terms_of_service: str = Field(title="エンジンの利用規約") update_infos: List[UpdateInfo] = Field(title="エンジンのアップデート情報") dependency_licenses: List[LicenseInfo] = Field(title="依存関係のライセンス情報") + supported_vvlib_manifest_version: str = Field(title="エンジンが対応するvvlibのバージョン") supported_features: SupportedFeatures = Field(title="エンジンが持つ機能") diff --git a/voicevox_engine/engine_manifest/EngineManifestLoader.py b/voicevox_engine/engine_manifest/EngineManifestLoader.py index bec6a2a7b..6acad0b92 100644 --- a/voicevox_engine/engine_manifest/EngineManifestLoader.py +++ b/voicevox_engine/engine_manifest/EngineManifestLoader.py @@ -32,6 +32,9 @@ def load_manifest(self) -> EngineManifest: (self.root_dir / manifest["update_infos"]).read_text("utf-8") ) ], + supported_vvlib_manifest_version=manifest[ + "supported_vvlib_manifest_version" + ], dependency_licenses=[ LicenseInfo(**license_info) for license_info in json.loads( diff --git a/voicevox_engine/model.py b/voicevox_engine/model.py index 9799b2ee6..89d9e0011 100644 --- a/voicevox_engine/model.py +++ b/voicevox_engine/model.py @@ -2,7 +2,7 @@ from re import findall, fullmatch from typing import Dict, List, Optional -from pydantic import BaseModel, Field, conint, validator +from pydantic import BaseModel, Field, StrictStr, conint, validator from .metas.Metas import Speaker, SpeakerInfo @@ -287,3 +287,17 @@ class SupportedFeaturesInfo(BaseModel): support_adjusting_silence_scale: bool = Field(title="前後の無音時間が調節可能かどうか") support_interrogative_upspeak: bool = Field(title="疑似疑問文に対応しているかどうか") support_switching_device: bool = Field(title="CPU/GPUの切り替えが可能かどうか") + + +class VvlibManifest(BaseModel): + """ + vvlib(VOICEVOX Library)に関する情報 + """ + + manifest_version: StrictStr = Field(title="マニフェストバージョン") + name: StrictStr = Field(title="音声ライブラリ名") + version: StrictStr = Field(title="音声ライブラリバージョン") + uuid: StrictStr = Field(title="音声ライブラリのUUID") + brand_name: StrictStr = Field(title="エンジンのブランド名") + engine_name: StrictStr = Field(title="エンジン名") + engine_uuid: StrictStr = Field(title="エンジンのUUID")