From 81e0271b699d5db147bfd632dcce2a142210065b Mon Sep 17 00:00:00 2001 From: EJClaw Date: Sat, 26 Sep 2026 00:57:04 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EB=A5=BC=20=EC=B2=AB=20=EC=8B=A4=ED=96=89=20=EB=95=8C=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B3=A0=20=EA=B8=B0=EC=96=B5=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모델이 티어에 따라 2~17GB 를 차지하는데 %APPDATA% 에 박혀 있어서 C 드라이브가 좁은 사람은 손쓸 방법이 없었다. 처음 켤 때 한 번 묻고, 그 선택을 기억하고, 설정에서 바꿀 수 있게 한다. 닭과 달걀 문제가 하나 있다 — "어디에 둘지" 는 사용자가 정하는데 그 선택 자체는 고정된 곳에 적어둬야 다음 실행에 찾을 수 있다. 그래서 OS 표준 폴더에는 위치를 가리키는 쪽지(location.json) 한 장만 두고, 내용물은 전부 고른 폴더에 둔다. - storage.py 신설: 쪽지 읽기/쓰기, 경로 검증, 여유 공간, 데이터 이동 - config.py 의 CONFIG_PATH 를 config_path() 로 바꾼다. import 시점 상수라 위치를 바꿔도 옛 경로를 계속 가리켰다 - 위치가 정해지기 전에는 파일 로그를 열지 않는다. 열면 고르지도 않은 기본 폴더가 만들어지고 거기에 로그가 남는다 - 이전 버전 사용자에게는 묻지 않는다. 기본 위치에 쓰던 흔적이 있으면 그대로 쓴다 — 같은 자리를 다시 고르는 헛수고를 시킬 이유가 없다 - 고른 디스크가 빠져도 죽지 않고 기본 위치로 물러난다 - 위치를 바꿀 때 받아둔 모델을 옮길지 물어본다. 수 GB 라 창이 얼어붙지 않도록 별도 스레드에서 옮긴다 - windows_smoke 에 이 화면 렌더링 점검을 추가하고, 스크린샷을 호스트로 회수한다. 경로 표기와 디스크 조회는 OS 마다 다르게 도는 부분이다 --- README.md | 25 ++- docs/FINETUNING.md | 2 +- packaging/windows_smoke.py | 33 ++++ scripts/win-ci/serve/stage2.ps1 | 12 ++ src/livesub/app.py | 27 +++- src/livesub/config.py | 13 +- src/livesub/constants.py | 20 +-- src/livesub/storage.py | 213 ++++++++++++++++++++++++++ src/livesub/ui/data_location.py | 211 +++++++++++++++++++++++++ src/livesub/ui/pages/settings_page.py | 66 ++++++-- tests/test_data_location.py | 61 ++++++++ tests/test_storage.py | 124 +++++++++++++++ 12 files changed, 772 insertions(+), 35 deletions(-) create mode 100644 src/livesub/storage.py create mode 100644 src/livesub/ui/data_location.py create mode 100644 tests/test_data_location.py create mode 100644 tests/test_storage.py diff --git a/README.md b/README.md index 016ada2..6fb8e26 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,8 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다 powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1 ``` -`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때 -`%APPDATA%\LiveSub\models`로 내려받습니다. +`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때 +고른 폴더**로 내려받습니다. --- @@ -169,6 +169,25 @@ livesub ## 처음 쓸 때 +### 먼저 저장 위치를 고릅니다 + +모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니 +**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이 +전부 들어갑니다. + +![저장 위치 선택](docs/images/first-run.png) + +한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더 +(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는 +"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다. + +나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로 +**옮길지 물어보므로** 다시 받을 필요는 없습니다. + +![설정의 저장 위치](docs/images/settings-storage.png) + +### 그다음 + 1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다 2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다 3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다) @@ -179,7 +198,7 @@ livesub VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다. 모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름). -저장 위치는 `%APPDATA%\LiveSub\models` 입니다. +저장 위치는 처음 켤 때 고른 그 폴더입니다. ![모델 화면](docs/images/models.png) diff --git a/docs/FINETUNING.md b/docs/FINETUNING.md index 7de304f..9b71a11 100644 --- a/docs/FINETUNING.md +++ b/docs/FINETUNING.md @@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好, 1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다. 2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬. 3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면 - `%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. + 저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. **틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다. 4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로. diff --git a/packaging/windows_smoke.py b/packaging/windows_smoke.py index debfb74..55f457f 100644 --- a/packaging/windows_smoke.py +++ b/packaging/windows_smoke.py @@ -159,6 +159,39 @@ def run_source_checks() -> None: win.close() return "스크린샷 2장 저장 (artifacts/)" + @check("저장 위치 선택 화면") + def _data_location(): + """첫 실행 때 뜨는 위치 선택 화면이 실제 Windows 에서 제대로 그려지는지. + + 경로 표기(`C:\\...`)와 디스크 여유 공간 조회는 OS 마다 다르게 도는 + 부분이라 리눅스에서 통과해도 여기서 깨질 수 있다. + """ + from livesub.storage import data_dir, default_data_dir, free_space, human_size + from livesub.ui.data_location import DataLocationDialog + from livesub.ui.theme import stylesheet + + app = _qt_app() + ARTIFACTS.mkdir(exist_ok=True) + shots = 0 + for name, start, first in ( + ("windows-first-run.png", default_data_dir(), True), + ("windows-change-location.png", data_dir(), False), + ): + dialog = DataLocationDialog(start, first_run=first) + try: + dialog.setStyleSheet(stylesheet("dark")) + dialog.resize(560, dialog.sizeHint().height()) + dialog.show() + app.processEvents() + if not dialog.ok_button.isEnabled(): + raise RuntimeError(f"{start} 를 못 쓴다고 판정됨: {dialog.status.text()}") + dialog.grab().save(str(ARTIFACTS / name)) + shots += 1 + finally: + dialog.close() + dialog.deleteLater() + return f"{default_data_dir()} · 여유 {human_size(free_space(default_data_dir()))} · 스크린샷 {shots}장" + # --- 전 파이프라인 점검 ----------------------------------------------------- diff --git a/scripts/win-ci/serve/stage2.ps1 b/scripts/win-ci/serve/stage2.ps1 index 9aed73f..045b610 100644 --- a/scripts/win-ci/serve/stage2.ps1 +++ b/scripts/win-ci/serve/stage2.ps1 @@ -367,6 +367,18 @@ try { $r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } + # 점검이 찍은 스크린샷을 묶어서 호스트로 가져온다. 화면 관련 회귀는 로그로는 + # 안 보이고, 문서에 넣을 그림도 실제 Windows 에서 찍은 것이어야 한다. + try { + $png = Join-Path $Repo 'artifacts' + if (Test-Path $png) { + $zip = Join-Path $OutDir 'screenshots.zip' + Remove-Item $zip -Force -ErrorAction SilentlyContinue + Compress-Archive -Path (Join-Path $png '*.png') -DestinationPath $zip -Force + Send-Artifact 'screenshots.zip' $zip + } + } catch { Log "스크린샷 회수 실패: $($_.Exception.Message)" } + $r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet # 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다. diff --git a/src/livesub/app.py b/src/livesub/app.py index 5456003..15f2003 100644 --- a/src/livesub/app.py +++ b/src/livesub/app.py @@ -11,11 +11,19 @@ from .config import AppConfig from .constants import APP_NAME, ORG_NAME, user_data_dir -def setup_logging(verbose: bool = False) -> None: - log_file = user_data_dir() / "livesub.log" +def setup_logging(verbose: bool = False, to_file: bool = True) -> None: + """로그 설정. + + `to_file=False` 는 저장 위치를 아직 고르기 전에 쓴다. 그 시점에 파일 + 핸들러를 열면 사용자가 고르지도 않은 기본 폴더가 만들어지고 로그가 + 거기에 남는다. + """ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] - with contextlib.suppress(OSError): - handlers.append(logging.FileHandler(log_file, encoding="utf-8")) + if to_file: + with contextlib.suppress(OSError): + handlers.append( + logging.FileHandler(user_data_dir() / "livesub.log", encoding="utf-8") + ) logging.basicConfig( level=logging.DEBUG if verbose else logging.INFO, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", @@ -30,7 +38,7 @@ def setup_logging(verbose: bool = False) -> None: def main(argv: list[str] | None = None) -> int: argv = list(sys.argv if argv is None else argv) verbose = "--verbose" in argv or "-v" in argv - setup_logging(verbose) + setup_logging(verbose, to_file=False) from PySide6.QtCore import Qt from PySide6.QtGui import QIcon @@ -48,6 +56,15 @@ def main(argv: list[str] | None = None) -> int: if icon_path.is_file(): app.setWindowIcon(QIcon(str(icon_path))) + # 설정을 읽기 전에 어디서 읽을지부터 정해야 한다. 한 번 고르면 다음 실행부터 + # 이 화면은 건너뛴다. + from .storage import is_configured + from .ui.data_location import ask_initial_location + + if not is_configured() and not ask_initial_location(): + return 0 + setup_logging(verbose) + config = AppConfig.load() window = MainWindow(config) if not config.start_minimized: diff --git a/src/livesub/config.py b/src/livesub/config.py index 2c46bf2..50ad19f 100644 --- a/src/livesub/config.py +++ b/src/livesub/config.py @@ -12,7 +12,14 @@ from .constants import DEFAULT_TIER, user_data_dir log = logging.getLogger(__name__) -CONFIG_PATH = user_data_dir() / "config.json" + +def config_path() -> Path: + """설정 파일 경로. + + import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직 + 고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다. + """ + return user_data_dir() / "config.json" @dataclass @@ -149,7 +156,7 @@ class AppConfig: return asdict(self) def save(self, path: Path | None = None) -> Path: - target = path or CONFIG_PATH + target = path or config_path() target.parent.mkdir(parents=True, exist_ok=True) tmp = target.with_suffix(".json.tmp") tmp.write_text( @@ -160,7 +167,7 @@ class AppConfig: @classmethod def load(cls, path: Path | None = None) -> AppConfig: - target = path or CONFIG_PATH + target = path or config_path() if not target.exists(): return cls() try: diff --git a/src/livesub/constants.py b/src/livesub/constants.py index 889bfd5..a1cefe7 100644 --- a/src/livesub/constants.py +++ b/src/livesub/constants.py @@ -42,19 +42,15 @@ FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000 def user_data_dir() -> Path: - """설정/로그/모델 캐시를 두는 사용자 디렉터리.""" - import os - import sys + """설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다. - if sys.platform == "win32": - base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) - elif sys.platform == "darwin": - base = Path.home() / "Library" / "Application Support" - else: - base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - path = base / APP_NAME - path.mkdir(parents=True, exist_ok=True) - return path + 실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로 + 함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** — + 설정에서 위치를 바꿨을 때 따라오지 않는다. + """ + from .storage import data_dir + + return data_dir() def models_dir() -> Path: diff --git a/src/livesub/storage.py b/src/livesub/storage.py new file mode 100644 index 0000000..69a5880 --- /dev/null +++ b/src/livesub/storage.py @@ -0,0 +1,213 @@ +"""데이터를 어디에 둘지 — 사용자가 고르고, 그 선택을 기억한다. + +닭과 달걀 문제가 하나 있다. 모델·설정·기록을 어디에 둘지는 사용자가 정하는데, +그 "정한 위치" 자체는 어딘가 **고정된 곳**에 적어둬야 다음 실행 때 찾을 수 있다. +그래서 OS 표준 설정 폴더에는 위치를 가리키는 쪽지 한 장(`location.json`)만 두고, +실제 내용물(config.json · models/ · glossary.json · transcripts/ · livesub.log)은 +전부 사용자가 고른 폴더에 둔다. + +왜 고르게 하나 — 모델이 품질 티어에 따라 2~16GB 를 차지한다. C 드라이브가 좁은 +사람은 다른 디스크에 둬야 하는데, `%APPDATA%` 에 박아두면 방법이 없다. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import sys +from pathlib import Path + +log = logging.getLogger(__name__) + +POINTER_NAME = "location.json" + +#: 저장 위치를 옮길 때 따라가는 것들. 여기 없는 파일은 건드리지 않는다. +#: +#: livesub.log 는 일부러 뺐다. 실행 중에는 로그 핸들러가 파일을 잡고 있어서 +#: Windows 에서 이동이 실패한다. 로그는 다음 실행부터 새 위치에 쌓인다. +DATA_ENTRIES = ("config.json", "glossary.json", "models", "adapters", "transcripts") + + +def _os_config_dir() -> Path: + """OS 표준 설정 폴더. 여기에는 쪽지 한 장만 둔다.""" + from .constants import APP_NAME + + if sys.platform == "win32": + base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + elif sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + return base / APP_NAME + + +def pointer_path() -> Path: + return _os_config_dir() / POINTER_NAME + + +def default_data_dir() -> Path: + """아무것도 고르지 않았을 때 쓰는 위치. + + OS 표준 폴더와 같은 곳이다. 위치 선택이 없던 이전 버전이 쓰던 자리라, + 기본값을 여기로 두면 이미 모델을 받아둔 사람이 다시 받지 않아도 된다. + """ + return _os_config_dir() + + +def _read_pointer() -> Path | None: + try: + raw = json.loads(pointer_path().read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + value = raw.get("data_dir") if isinstance(raw, dict) else None + if not isinstance(value, str) or not value.strip(): + return None + return Path(value) + + +def stored_data_dir() -> Path | None: + """적어둔 위치. 아직 안 골랐거나 쪽지가 깨졌으면 None.""" + return _read_pointer() + + +def is_configured() -> bool: + """위치 선택 화면을 건너뛰어도 되는지. + + 쪽지가 있으면 당연히 건너뛴다. 쪽지가 없어도 기본 위치에 이미 쓰던 흔적이 + 있으면 이전 버전 사용자이므로 묻지 않는다 — 잘 쓰고 있던 사람에게 갑자기 + 위치를 고르라고 하면 같은 자리를 다시 고르는 헛수고만 시킨다. + """ + if _read_pointer() is not None: + return True + base = default_data_dir() + return any((base / name).exists() for name in DATA_ENTRIES) + + +def data_dir() -> Path: + """실제로 쓰는 데이터 폴더. 없으면 만든다.""" + target = _read_pointer() or default_data_dir() + try: + target.mkdir(parents=True, exist_ok=True) + except OSError as exc: + # 고른 디스크가 빠졌거나 권한이 사라진 경우. 조용히 죽는 것보다는 + # 기본 위치로 물러나 뜨는 편이 낫다. + log.warning("저장 위치(%s)를 쓸 수 없어 기본 위치를 씁니다: %s", target, exc) + target = default_data_dir() + target.mkdir(parents=True, exist_ok=True) + return target + + +def set_data_dir(path: Path | str) -> Path: + """이 위치를 쓰겠다고 적어둔다.""" + target = Path(path).expanduser() + target.mkdir(parents=True, exist_ok=True) + target = target.resolve() + + pointer = pointer_path() + pointer.parent.mkdir(parents=True, exist_ok=True) + tmp = pointer.with_suffix(".json.tmp") + tmp.write_text( + json.dumps({"data_dir": str(target)}, ensure_ascii=False, indent=2), encoding="utf-8" + ) + tmp.replace(pointer) + return target + + +def _existing_ancestor(path: Path) -> Path | None: + """실제로 존재하는 가장 가까운 상위 폴더. 없으면 None.""" + probe = path + while not probe.exists(): + if probe.parent == probe: + return None + probe = probe.parent + return probe + + +def check_target(path: Path | str, current: Path | None = None) -> str | None: + """고른 폴더를 쓸 수 있는지 본다. + + 문제가 없으면 None, 있으면 사용자에게 그대로 보여줄 한 줄을 돌려준다. + """ + text = str(path).strip() + if not text: + return "폴더를 골라주세요." + try: + candidate = Path(text).expanduser().resolve() + except (OSError, ValueError): + return "경로를 이해할 수 없습니다." + + if current is not None: + try: + cur = Path(current).expanduser().resolve() + except (OSError, ValueError): + cur = None + if cur is not None and cur != candidate and cur in candidate.parents: + # 지금 위치 안쪽으로 옮기면 옮기는 도중에 자기 자신을 삼킨다. + return "지금 쓰는 폴더의 하위 폴더는 고를 수 없습니다." + + anchor = _existing_ancestor(candidate) + if anchor is None: + return "드라이브를 찾을 수 없습니다." + if not anchor.is_dir(): + return "폴더가 아니라 파일을 고르셨습니다." + if not os.access(anchor, os.W_OK): + return "이 폴더에 쓸 권한이 없습니다." + return None + + +def free_space(path: Path | str) -> int: + """그 경로가 놓일 디스크의 남은 바이트. 알 수 없으면 0.""" + anchor = _existing_ancestor(Path(str(path)).expanduser()) + if anchor is None: + return 0 + try: + return shutil.disk_usage(anchor).free + except OSError: + return 0 + + +def human_size(num: float) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if num < 1024 or unit == "TB": + return f"{num:,.1f} {unit}" if unit != "B" else f"{num:,.0f} B" + num /= 1024 + return f"{num:,.1f} TB" + + +def used_space(path: Path | str) -> int: + """그 폴더가 실제로 쓰고 있는 바이트. 읽을 수 없는 항목은 건너뛴다.""" + total = 0 + for root, _dirs, files in os.walk(str(path), onerror=lambda _e: None): + for name in files: + try: + total += os.path.getsize(os.path.join(root, name)) + except OSError: + continue + return total + + +def move_data(old: Path | str, new: Path | str) -> list[str]: + """기존 데이터를 새 위치로 옮기고, 옮긴 항목 이름을 돌려준다.""" + src_root = Path(old).expanduser().resolve() + dst_root = Path(new).expanduser() + dst_root.mkdir(parents=True, exist_ok=True) + dst_root = dst_root.resolve() + if src_root == dst_root: + return [] + + moved: list[str] = [] + for name in DATA_ENTRIES: + src = src_root / name + if not src.exists(): + continue + dst = dst_root / name + if dst.exists(): + # 새 위치에 이미 같은 이름이 있으면 덮어쓰지 않는다. 예전에 쓰던 + # 폴더를 다시 고른 경우일 수 있고, 그때 지우면 복구할 방법이 없다. + log.info("이미 있어서 건너뜀: %s", dst) + continue + shutil.move(str(src), str(dst)) + moved.append(name) + return moved diff --git a/src/livesub/ui/data_location.py b/src/livesub/ui/data_location.py new file mode 100644 index 0000000..09f6f25 --- /dev/null +++ b/src/livesub/ui/data_location.py @@ -0,0 +1,211 @@ +"""저장 위치를 고르는 화면. + +첫 실행 때 한 번 묻고, 그 뒤로는 설정에서 바꿀 수 있다. 두 경우가 묻는 내용이 +같으므로 같은 대화상자를 쓰고 문구만 바꾼다. +""" + +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import Qt, QThread, Signal +from PySide6.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QProgressDialog, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ..constants import APP_NAME +from ..storage import ( + DATA_ENTRIES, + check_target, + data_dir, + default_data_dir, + free_space, + human_size, + move_data, + set_data_dir, +) + +#: 가장 무거운 티어까지 받았을 때를 기준으로 한 권장 여유 공간. +RECOMMENDED_FREE = 20 * 1024**3 + + +class DataLocationDialog(QDialog): + """폴더 하나를 고르고, 쓸 수 있는 곳인지 즉시 알려준다.""" + + def __init__(self, start: Path, *, first_run: bool, parent: QWidget | None = None): + super().__init__(parent) + self.setWindowTitle(f"{APP_NAME} 저장 위치") + self.setMinimumWidth(520) + self._first_run = first_run + self._current = None if first_run else start + + root = QVBoxLayout(self) + root.setSpacing(12) + + title = QLabel("어디에 저장할까요?" if first_run else "저장 위치 바꾸기") + title.setStyleSheet("font-size: 16px; font-weight: 700;") + root.addWidget(title) + + why = QLabel( + "음성인식·번역 모델과 설정, 번역 기록이 이 폴더에 저장됩니다.\n" + "모델은 품질 티어에 따라 2~16GB 를 차지하니 여유 있는 디스크를 고르세요.\n" + "나중에 설정에서 언제든 바꿀 수 있습니다." + ) + why.setObjectName("Hint") + why.setWordWrap(True) + root.addWidget(why) + + row = QWidget() + row.setObjectName("Row") + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + self.path_edit = QLineEdit(str(start)) + self.path_edit.textChanged.connect(self._revalidate) + browse = QPushButton("찾아보기…") + browse.clicked.connect(self._browse) + row_layout.addWidget(self.path_edit, 1) + row_layout.addWidget(browse) + root.addWidget(row) + + self.status = QLabel() + self.status.setWordWrap(True) + root.addWidget(self.status) + + self.buttons = QDialogButtonBox() + ok_text = "이 위치로 시작" if first_run else "이 위치로 바꾸기" + self.ok_button = self.buttons.addButton(ok_text, QDialogButtonBox.ButtonRole.AcceptRole) + cancel_text = "종료" if first_run else "취소" + self.buttons.addButton(cancel_text, QDialogButtonBox.ButtonRole.RejectRole) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) + root.addWidget(self.buttons) + + self._revalidate() + + def selected(self) -> Path: + return Path(self.path_edit.text().strip()).expanduser() + + def _browse(self) -> None: + start = self.path_edit.text().strip() or str(default_data_dir()) + picked = QFileDialog.getExistingDirectory(self, "저장할 폴더 선택", start) + if picked: + self.path_edit.setText(picked) + + def _revalidate(self) -> None: + text = self.path_edit.text().strip() + problem = check_target(text, self._current) + self.ok_button.setEnabled(problem is None) + if problem is not None: + self.status.setText(f"⚠ {problem}") + self.status.setStyleSheet("color: #E5484D;") + return + + free = free_space(text) + line = f"이 디스크의 남은 공간 {human_size(free)}" + if free and free < RECOMMENDED_FREE: + self.status.setText( + f"{line} — 높은 티어 모델까지 받기에는 빠듯합니다. 그래도 진행할 수 있습니다." + ) + self.status.setStyleSheet("color: #F5A623;") + else: + self.status.setText(line) + self.status.setStyleSheet("") + + +def ask_initial_location(parent: QWidget | None = None) -> bool: + """첫 실행 — 위치를 고르게 한다. 사용자가 종료를 누르면 False.""" + dialog = DataLocationDialog(default_data_dir(), first_run=True, parent=parent) + if dialog.exec() != QDialog.DialogCode.Accepted: + return False + set_data_dir(dialog.selected()) + return True + + +def ask_change_location(parent: QWidget | None = None) -> Path | None: + """설정 — 위치를 바꾼다. 바꿨으면 새 경로, 취소했으면 None.""" + current = data_dir() + dialog = DataLocationDialog(current, first_run=False, parent=parent) + if dialog.exec() != QDialog.DialogCode.Accepted: + return None + + target = dialog.selected() + if target.expanduser().resolve() == current.resolve(): + return None + + if _has_data(current) and not _move_with_progress(current, target, parent): + return None + + return set_data_dir(target) + + +def _has_data(path: Path) -> bool: + return any((path / name).exists() for name in DATA_ENTRIES) + + +def _move_with_progress(old: Path, new: Path, parent: QWidget | None) -> bool: + """기존 데이터를 옮길지 묻고, 옮기기로 했으면 옮긴다. 취소면 False.""" + answer = QMessageBox.question( + parent, + "기존 데이터", + f"지금 위치에 받아둔 모델과 설정이 있습니다.\n\n" + f"{old}\n\n" + "새 위치로 옮길까요? 옮기지 않으면 모델을 다시 받아야 합니다.", + QMessageBox.StandardButton.Yes + | QMessageBox.StandardButton.No + | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Yes, + ) + if answer == QMessageBox.StandardButton.Cancel: + return False + if answer == QMessageBox.StandardButton.No: + return True + + # 수 GB 를 옮기는 동안 창이 얼어붙으면 Windows 가 "응답 없음"을 띄운다. + # 별도 스레드로 옮기고 여기서는 진행 표시만 돌린다. + worker = _MoveWorker(old, new) + progress = QProgressDialog("기존 데이터를 옮기는 중입니다…", "", 0, 0, parent) + progress.setWindowTitle("저장 위치 변경") + progress.setCancelButton(None) # 옮기는 도중에 끊으면 데이터가 갈라진다 + progress.setWindowModality(Qt.WindowModality.WindowModal) + worker.finished.connect(progress.close) + worker.start() + progress.exec() + worker.wait() + + if worker.error: + QMessageBox.warning( + parent, + "옮기지 못했습니다", + f"기존 데이터를 옮기는 중 문제가 생겼습니다.\n\n{worker.error}\n\n" + "저장 위치는 바꾸지 않았습니다.", + ) + return False + return True + + +class _MoveWorker(QThread): + done = Signal() + + def __init__(self, old: Path, new: Path): + super().__init__() + self._old = old + self._new = new + self.moved: list[str] = [] + self.error = "" + + def run(self) -> None: + try: + self.moved = move_data(self._old, self._new) + except OSError as exc: + self.error = str(exc) + self.done.emit() diff --git a/src/livesub/ui/pages/settings_page.py b/src/livesub/ui/pages/settings_page.py index e31e450..55ed2ff 100644 --- a/src/livesub/ui/pages/settings_page.py +++ b/src/livesub/ui/pages/settings_page.py @@ -9,6 +9,7 @@ from PySide6.QtWidgets import ( QHBoxLayout, QLabel, QLineEdit, + QMessageBox, QPushButton, QSlider, QSpinBox, @@ -19,6 +20,7 @@ from PySide6.QtWidgets import ( from ...config import AppConfig from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir from ...models import detect_gpu +from ...storage import free_space, human_size from ..theme import SPACING, palette from ..widgets import Card, PageHeader @@ -45,6 +47,7 @@ class SettingsPage(QWidget): root.addWidget(self._build_hotkey_card()) root.addWidget(self._build_audio_card()) root.addWidget(self._build_behavior_card()) + root.addWidget(self._build_storage_card()) root.addWidget(self._build_about_card()) root.addStretch(1) @@ -258,20 +261,61 @@ class SettingsPage(QWidget): self.log_check.setChecked(self.config.log_transcripts) self.log_check.toggled.connect(self._on_behavior_changed) form.addRow("", self.log_check) - - open_folder = QPushButton("설정 폴더 열기") - open_folder.clicked.connect(self._open_folder) - form.addRow("", open_folder) return card + # --- 저장 위치 ------------------------------------------------------- + def _build_storage_card(self) -> Card: + card = Card( + "저장 위치", + "모델과 설정, 번역 기록이 여기에 저장됩니다. 모델은 품질 티어에 따라 " + "2~16GB 를 차지하므로 여유 있는 디스크가 좋습니다.", + ) + self.storage_label = QLabel() + self.storage_label.setObjectName("Hint") + self.storage_label.setWordWrap(True) + self.storage_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + card.add(self.storage_label) + + row = QWidget() + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + change = QPushButton("위치 변경…") + change.clicked.connect(self._change_storage) + open_folder = QPushButton("폴더 열기") + open_folder.clicked.connect(self._open_folder) + row_layout.addWidget(change) + row_layout.addWidget(open_folder) + row_layout.addStretch(1) + card.add(row) + + self._refresh_storage_label() + return card + + def _refresh_storage_label(self) -> None: + path = user_data_dir() + self.storage_label.setText(f"{path}\n이 디스크의 남은 공간 {human_size(free_space(path))}") + + def _change_storage(self) -> None: + from ..data_location import ask_change_location + + target = ask_change_location(self) + if target is None: + return + # 옮기지 않기를 골랐다면 새 위치에 설정 파일이 없다. 지금 상태를 써둔다. + self.config.save() + self._refresh_storage_label() + QMessageBox.information( + self, + "저장 위치를 바꿨습니다", + f"이제 여기를 씁니다.\n\n{target}\n\n" + "이미 메모리에 올라가 있는 모델은 그대로 쓰고, 다음에 받는 것부터 " + "새 위치에 저장됩니다. 로그 파일은 다음 실행부터 옮겨집니다.", + ) + def _build_about_card(self) -> Card: - card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN) - path = QLabel(f"설정 위치: {user_data_dir()}") - path.setObjectName("Hint") - path.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - path.setWordWrap(True) - card.add(path) - return card + return Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN) # --- 동작 ----------------------------------------------------------- def _on_audio_changed(self, *_) -> None: diff --git a/tests/test_data_location.py b/tests/test_data_location.py new file mode 100644 index 0000000..ad22415 --- /dev/null +++ b/tests/test_data_location.py @@ -0,0 +1,61 @@ +"""저장 위치 대화상자 — 못 쓰는 폴더를 고르면 진행을 막는지.""" + +from __future__ import annotations + +import os + +import pytest + +pytest.importorskip("PySide6") +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from livesub import storage # noqa: E402 +from livesub.ui.data_location import DataLocationDialog # noqa: E402 + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def os_dir(tmp_path, monkeypatch): + base = tmp_path / "appdata" / "LiveSub" + monkeypatch.setattr(storage, "_os_config_dir", lambda: base) + return base + + +def test_first_run_dialog_starts_on_the_default_folder(qapp, os_dir): + dialog = DataLocationDialog(storage.default_data_dir(), first_run=True) + try: + assert dialog.selected() == os_dir + assert dialog.ok_button.isEnabled() + finally: + dialog.deleteLater() + + +def test_empty_path_blocks_confirmation(qapp, os_dir): + dialog = DataLocationDialog(storage.default_data_dir(), first_run=True) + try: + dialog.path_edit.setText(" ") + assert not dialog.ok_button.isEnabled() + dialog.path_edit.setText(str(os_dir.parent / "elsewhere")) + assert dialog.ok_button.isEnabled() + finally: + dialog.deleteLater() + + +def test_change_dialog_blocks_a_subfolder_of_the_current_one(qapp, tmp_path): + current = tmp_path / "current" + current.mkdir() + dialog = DataLocationDialog(current, first_run=False) + try: + dialog.path_edit.setText(str(current / "inside")) + assert not dialog.ok_button.isEnabled() + dialog.path_edit.setText(str(tmp_path / "other")) + assert dialog.ok_button.isEnabled() + finally: + dialog.deleteLater() diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..f54870d --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,124 @@ +"""저장 위치 선택 — 고른 곳에 실제로 쌓이고, 다음 실행 때 그대로 따라가는지.""" + +from __future__ import annotations + +import json + +import pytest + +from livesub import storage +from livesub.config import AppConfig, config_path +from livesub.constants import models_dir, user_data_dir + + +@pytest.fixture +def os_dir(tmp_path, monkeypatch): + """OS 표준 설정 폴더(쪽지가 놓이는 곳)를 임시 폴더로 바꾼다.""" + base = tmp_path / "appdata" / "LiveSub" + monkeypatch.setattr(storage, "_os_config_dir", lambda: base) + return base + + +def test_first_run_is_not_configured(os_dir): + assert storage.is_configured() is False + assert storage.stored_data_dir() is None + + +def test_choice_is_remembered(os_dir, tmp_path): + chosen = tmp_path / "D" / "LiveSub" + storage.set_data_dir(chosen) + + assert storage.is_configured() is True + assert storage.stored_data_dir() == chosen.resolve() + assert storage.data_dir() == chosen.resolve() + + +def test_everything_lands_in_the_chosen_folder(os_dir, tmp_path): + """쪽지만 OS 폴더에 남고 내용물은 고른 곳으로 가야 한다.""" + chosen = tmp_path / "games" / "LiveSub" + storage.set_data_dir(chosen) + + assert user_data_dir() == chosen.resolve() + assert config_path().parent == chosen.resolve() + assert models_dir() == chosen.resolve() / "models" + + AppConfig().save() + assert (chosen / "config.json").is_file() + assert not (os_dir / "config.json").exists() + assert list(os_dir.iterdir()) == [os_dir / storage.POINTER_NAME] + + +def test_existing_install_is_not_asked_again(os_dir): + """위치 선택이 없던 버전에서 올라온 사람에게 다시 묻지 않는다.""" + os_dir.mkdir(parents=True) + (os_dir / "models").mkdir() + + assert storage.is_configured() is True + assert storage.stored_data_dir() is None # 쪽지는 없지만 + assert storage.data_dir() == os_dir # 쓰던 자리를 그대로 쓴다 + + +def test_broken_pointer_falls_back_to_default(os_dir): + os_dir.mkdir(parents=True) + (os_dir / storage.POINTER_NAME).write_text("{ 깨진 json", encoding="utf-8") + + assert storage.stored_data_dir() is None + assert storage.data_dir() == os_dir + + +def test_unusable_location_falls_back_instead_of_crashing(os_dir, tmp_path): + """고른 디스크가 빠졌거나 경로가 막혔을 때 죽지 않고 기본 위치로 물러난다.""" + blocker = tmp_path / "blocker" + blocker.write_text("파일이라 하위 폴더를 만들 수 없다", encoding="utf-8") + os_dir.mkdir(parents=True) + (os_dir / storage.POINTER_NAME).write_text( + json.dumps({"data_dir": str(blocker / "LiveSub")}), encoding="utf-8" + ) + + assert storage.data_dir() == os_dir + + +def test_check_target_rejects_unusable_choices(tmp_path): + current = tmp_path / "current" + current.mkdir() + + assert storage.check_target("") is not None + assert storage.check_target(current / "inside", current) is not None # 자기 하위 + assert storage.check_target(current, current) is None # 같은 곳은 문제 아님 + assert storage.check_target(tmp_path / "new") is None # 아직 없어도 만들 수 있으면 OK + + +def test_move_data_carries_models_and_config(tmp_path): + old, new = tmp_path / "old", tmp_path / "new" + (old / "models" / "whisper").mkdir(parents=True) + (old / "models" / "whisper" / "model.bin").write_bytes(b"x" * 16) + (old / "config.json").write_text("{}", encoding="utf-8") + (old / "livesub.log").write_text("로그는 안 옮긴다", encoding="utf-8") + + moved = storage.move_data(old, new) + + assert sorted(moved) == ["config.json", "models"] + assert (new / "models" / "whisper" / "model.bin").read_bytes() == b"x" * 16 + assert not (old / "models").exists() + assert (old / "livesub.log").is_file() # 실행 중 잠겨 있으므로 제외 대상 + + +def test_move_data_never_overwrites_existing(tmp_path): + old, new = tmp_path / "old", tmp_path / "new" + old.mkdir() + new.mkdir() + (old / "config.json").write_text("옛날 것", encoding="utf-8") + (new / "config.json").write_text("새 위치에 이미 있던 것", encoding="utf-8") + + assert storage.move_data(old, new) == [] + assert (new / "config.json").read_text(encoding="utf-8") == "새 위치에 이미 있던 것" + assert (old / "config.json").is_file() + + +def test_move_data_to_same_place_does_nothing(tmp_path): + same = tmp_path / "same" + same.mkdir() + (same / "config.json").write_text("{}", encoding="utf-8") + + assert storage.move_data(same, same) == [] + assert (same / "config.json").is_file()