feat: 저장 위치를 첫 실행 때 고르고 기억한다
Some checks failed
Windows / verify (push) Has been cancelled

모델이 티어에 따라 2~17GB 를 차지하는데 %APPDATA% 에 박혀 있어서 C 드라이브가
좁은 사람은 손쓸 방법이 없었다. 처음 켤 때 한 번 묻고, 그 선택을 기억하고,
설정에서 바꿀 수 있게 한다.

닭과 달걀 문제가 하나 있다 — "어디에 둘지" 는 사용자가 정하는데 그 선택 자체는
고정된 곳에 적어둬야 다음 실행에 찾을 수 있다. 그래서 OS 표준 폴더에는 위치를
가리키는 쪽지(location.json) 한 장만 두고, 내용물은 전부 고른 폴더에 둔다.

- storage.py 신설: 쪽지 읽기/쓰기, 경로 검증, 여유 공간, 데이터 이동
- config.py 의 CONFIG_PATH 를 config_path() 로 바꾼다. import 시점 상수라
  위치를 바꿔도 옛 경로를 계속 가리켰다
- 위치가 정해지기 전에는 파일 로그를 열지 않는다. 열면 고르지도 않은 기본
  폴더가 만들어지고 거기에 로그가 남는다
- 이전 버전 사용자에게는 묻지 않는다. 기본 위치에 쓰던 흔적이 있으면 그대로
  쓴다 — 같은 자리를 다시 고르는 헛수고를 시킬 이유가 없다
- 고른 디스크가 빠져도 죽지 않고 기본 위치로 물러난다
- 위치를 바꿀 때 받아둔 모델을 옮길지 물어본다. 수 GB 라 창이 얼어붙지
  않도록 별도 스레드에서 옮긴다
- windows_smoke 에 이 화면 렌더링 점검을 추가하고, 스크린샷을 호스트로
  회수한다. 경로 표기와 디스크 조회는 OS 마다 다르게 도는 부분이다
This commit is contained in:
EJClaw
2026-09-26 00:57:04 +09:00
parent f79293d893
commit 81e0271b69
12 changed files with 772 additions and 35 deletions

View File

@@ -113,8 +113,8 @@ Enemy coming from the left 존댓말 → 적이 왼쪽에서 옵니다
powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1 powershell -ExecutionPolicy Bypass -File packaging\build-portable.ps1
``` ```
`dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고 첫 실행 때 `dist\LiveSub.exe` 하나만 복사하면 끝입니다. 모델은 exe에 없고, **첫 실행 때
`%APPDATA%\LiveSub\models`로 내려받습니다. 고른 폴더**로 내려받습니다.
--- ---
@@ -169,6 +169,25 @@ livesub
## 처음 쓸 때 ## 처음 쓸 때
### 먼저 저장 위치를 고릅니다
모델이 티어에 따라 2~17GB를 차지합니다. C 드라이브가 좁으면 곤란하니
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
전부 들어갑니다.
![저장 위치 선택](docs/images/first-run.png)
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
![설정의 저장 위치](docs/images/settings-storage.png)
### 그다음
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다 1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다 2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다) 3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
@@ -179,7 +198,7 @@ livesub
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다. VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름). 모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
저장 위치는 `%APPDATA%\LiveSub\models` 입니다. 저장 위치는 처음 켤 때 고른 그 폴더입니다.
![모델 화면](docs/images/models.png) ![모델 화면](docs/images/models.png)

View File

@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다. 1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬. 2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면 3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
`%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다. 저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다. **틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로. 4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.

View File

@@ -159,6 +159,39 @@ def run_source_checks() -> None:
win.close() win.close()
return "스크린샷 2장 저장 (artifacts/)" 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}장"
# --- 전 파이프라인 점검 ----------------------------------------------------- # --- 전 파이프라인 점검 -----------------------------------------------------

View File

@@ -367,6 +367,18 @@ try {
$r['windows경로'] = Step 'windows_smoke' { & $py packaging\windows_smoke.py; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } } $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' { $r['exe빌드'] = Step 'pyinstaller' {
& $py -m pip install pyinstaller --quiet & $py -m pip install pyinstaller --quiet
# 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다. # 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다.

View File

@@ -11,11 +11,19 @@ from .config import AppConfig
from .constants import APP_NAME, ORG_NAME, user_data_dir from .constants import APP_NAME, ORG_NAME, user_data_dir
def setup_logging(verbose: bool = False) -> None: def setup_logging(verbose: bool = False, to_file: bool = True) -> None:
log_file = user_data_dir() / "livesub.log" """로그 설정.
`to_file=False` 는 저장 위치를 아직 고르기 전에 쓴다. 그 시점에 파일
핸들러를 열면 사용자가 고르지도 않은 기본 폴더가 만들어지고 로그가
거기에 남는다.
"""
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)] handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
if to_file:
with contextlib.suppress(OSError): with contextlib.suppress(OSError):
handlers.append(logging.FileHandler(log_file, encoding="utf-8")) handlers.append(
logging.FileHandler(user_data_dir() / "livesub.log", encoding="utf-8")
)
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO, level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", 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: def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv) argv = list(sys.argv if argv is None else argv)
verbose = "--verbose" in argv or "-v" in 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.QtCore import Qt
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
@@ -48,6 +56,15 @@ def main(argv: list[str] | None = None) -> int:
if icon_path.is_file(): if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path))) 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() config = AppConfig.load()
window = MainWindow(config) window = MainWindow(config)
if not config.start_minimized: if not config.start_minimized:

View File

@@ -12,7 +12,14 @@ from .constants import DEFAULT_TIER, user_data_dir
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
CONFIG_PATH = user_data_dir() / "config.json"
def config_path() -> Path:
"""설정 파일 경로.
import 시점에 상수로 계산하면 안 된다. 그때는 사용자가 저장 위치를 아직
고르지 않았고, 나중에 설정에서 위치를 바꿔도 옛 경로를 계속 가리킨다.
"""
return user_data_dir() / "config.json"
@dataclass @dataclass
@@ -149,7 +156,7 @@ class AppConfig:
return asdict(self) return asdict(self)
def save(self, path: Path | None = None) -> Path: 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) target.parent.mkdir(parents=True, exist_ok=True)
tmp = target.with_suffix(".json.tmp") tmp = target.with_suffix(".json.tmp")
tmp.write_text( tmp.write_text(
@@ -160,7 +167,7 @@ class AppConfig:
@classmethod @classmethod
def load(cls, path: Path | None = None) -> AppConfig: def load(cls, path: Path | None = None) -> AppConfig:
target = path or CONFIG_PATH target = path or config_path()
if not target.exists(): if not target.exists():
return cls() return cls()
try: try:

View File

@@ -42,19 +42,15 @@ FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS // 1000
def user_data_dir() -> Path: def user_data_dir() -> Path:
"""설정/로그/모델 캐시를 두는 사용자 디렉터리.""" """설정/로그/모델 캐시를 두는 폴더 — 사용자가 첫 실행 때 고른 위치다.
import os
import sys
if sys.platform == "win32": 실제 결정은 storage 모듈이 한다. 모듈 최상단에서 import 하면 순환이 되므로
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) 함수 안에서 가져온다. 그리고 **미리 계산해 상수로 박아두면 안 된다** —
elif sys.platform == "darwin": 설정에서 위치를 바꿨을 때 따라오지 않는다.
base = Path.home() / "Library" / "Application Support" """
else: from .storage import data_dir
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
path = base / APP_NAME return data_dir()
path.mkdir(parents=True, exist_ok=True)
return path
def models_dir() -> Path: def models_dir() -> Path:

213
src/livesub/storage.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -9,6 +9,7 @@ from PySide6.QtWidgets import (
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QLineEdit, QLineEdit,
QMessageBox,
QPushButton, QPushButton,
QSlider, QSlider,
QSpinBox, QSpinBox,
@@ -19,6 +20,7 @@ from PySide6.QtWidgets import (
from ...config import AppConfig from ...config import AppConfig
from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir from ...constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
from ...models import detect_gpu from ...models import detect_gpu
from ...storage import free_space, human_size
from ..theme import SPACING, palette from ..theme import SPACING, palette
from ..widgets import Card, PageHeader from ..widgets import Card, PageHeader
@@ -45,6 +47,7 @@ class SettingsPage(QWidget):
root.addWidget(self._build_hotkey_card()) root.addWidget(self._build_hotkey_card())
root.addWidget(self._build_audio_card()) root.addWidget(self._build_audio_card())
root.addWidget(self._build_behavior_card()) root.addWidget(self._build_behavior_card())
root.addWidget(self._build_storage_card())
root.addWidget(self._build_about_card()) root.addWidget(self._build_about_card())
root.addStretch(1) root.addStretch(1)
@@ -258,20 +261,61 @@ class SettingsPage(QWidget):
self.log_check.setChecked(self.config.log_transcripts) self.log_check.setChecked(self.config.log_transcripts)
self.log_check.toggled.connect(self._on_behavior_changed) self.log_check.toggled.connect(self._on_behavior_changed)
form.addRow("", self.log_check) form.addRow("", self.log_check)
open_folder = QPushButton("설정 폴더 열기")
open_folder.clicked.connect(self._open_folder)
form.addRow("", open_folder)
return card 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: def _build_about_card(self) -> Card:
card = Card(f"{APP_NAME} v{APP_VERSION}", APP_SLOGAN) return 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
# --- 동작 ----------------------------------------------------------- # --- 동작 -----------------------------------------------------------
def _on_audio_changed(self, *_) -> None: def _on_audio_changed(self, *_) -> None:

View File

@@ -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()

124
tests/test_storage.py Normal file
View File

@@ -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()