Compare commits
7 Commits
a66eedee8b
...
v0.3.0-tes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88effe0e58 | ||
|
|
81e0271b69 | ||
|
|
f79293d893 | ||
|
|
2028b0a651 | ||
|
|
4fd8fabcb8 | ||
|
|
ca096a69c3 | ||
|
|
0a1389a1d9 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,9 +10,12 @@ venv/
|
|||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
# 네이티브 빌드 산출물
|
# 네이티브 빌드 산출물
|
||||||
|
#
|
||||||
|
# ⚠️ 패키지 이름은 livesub 다. 예전 이름(hearo)이 그대로 남아 있어서 빌드한
|
||||||
|
# livesub_capture.exe 가 무시 대상에서 빠져 있었다.
|
||||||
native/process_loopback/build/
|
native/process_loopback/build/
|
||||||
src/hearo/resources/bin/*.exe
|
src/livesub/resources/bin/*.exe
|
||||||
src/hearo/resources/bin/*.pdb
|
src/livesub/resources/bin/*.pdb
|
||||||
|
|
||||||
# 모델 캐시 / 학습 산출물 / 사용자 데이터
|
# 모델 캐시 / 학습 산출물 / 사용자 데이터
|
||||||
#
|
#
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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 드라이브가 좁으면 곤란하니
|
||||||
|
**처음 켜면 어디에 저장할지 물어봅니다.** 고른 폴더에 모델·설정·번역 기록이
|
||||||
|
전부 들어갑니다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
한 번 고르면 기억하므로 다음부터는 바로 뜹니다. Windows 표준 폴더
|
||||||
|
(`%APPDATA%\LiveSub`)가 기본 제안이고 그대로 두셔도 됩니다 — 거기에는
|
||||||
|
"어느 폴더를 쓰는지" 적은 쪽지 한 장만 남습니다.
|
||||||
|
|
||||||
|
나중에 바꾸려면 `설정 > 저장 위치 > 위치 변경`입니다. 받아둔 모델을 새 위치로
|
||||||
|
**옮길지 물어보므로** 다시 받을 필요는 없습니다.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 그다음
|
||||||
|
|
||||||
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
|
1. **홈** 화면에서 소리를 받아올 프로그램을 고릅니다
|
||||||
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
|
2. 원본 언어(자동 감지 권장)와 번역할 언어를 고릅니다
|
||||||
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
|
3. **용어집** 화면에서 하는 게임을 체크합니다 (고유명사 오역이 확 줄어듭니다)
|
||||||
@@ -179,7 +198,7 @@ livesub
|
|||||||
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
|
VRAM이 넉넉하면 **모델** 화면에서 올리면 됩니다.
|
||||||
|
|
||||||
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
|
모델은 처음 한 번만 자동으로 내려받습니다 (2~17GB, 티어에 따라 다름).
|
||||||
저장 위치는 `%APPDATA%\LiveSub\models` 입니다.
|
저장 위치는 처음 켤 때 고른 그 폴더입니다.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ gg,잘 싸웠다,gg,gg,打得好,
|
|||||||
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
|
1. **게임 공식 현지화 자산** — 한국어판이 있는 게임의 자막/UI 텍스트. 품질이 가장 좋습니다.
|
||||||
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
|
2. **자막 파일 쌍** — 같은 영상의 영어 자막 + 한국어 자막(.srt)을 시간축으로 정렬.
|
||||||
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
|
3. **이 프로그램의 기록** — `설정 > 번역 기록을 파일로 남기기`를 켜두면
|
||||||
`%APPDATA%/LiveSub/transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
|
저장 위치 폴더의 `transcripts/` 에 "원문 / 번역" 쌍이 쌓입니다.
|
||||||
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
|
**틀린 번역만 손으로 고쳐서** 학습 데이터로 쓰는 게 가장 현실적인 경로입니다.
|
||||||
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
|
4. 위키·커뮤니티 용어 사전 — 용어집으로 쓰는 게 더 낫습니다. 문장이 아니므로.
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,59 @@ scripts/win-ci/build.sh # ISO 받기 + 무인설치 ISO 굽기 + 디스
|
|||||||
| 방향 | 통로 | 왜 |
|
| 방향 | 통로 | 왜 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
|
| 호스트 → 게스트 | HTTP `10.0.2.2:8099` (QEMU user networking) | 스크립트·러너 토큰 전달 |
|
||||||
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
|
| 게스트 → 호스트 | COM1 → `serial.log`, HTTP POST `/log` → `guest.log` | 네트워크가 **없는** 드라이버 설치 단계도 봐야 하므로 시리얼이 필수 |
|
||||||
|
| 게스트 → 호스트 | HTTP POST `/artifact/<이름>` → `results/<이름>.log` | 검증 출력 **전문**. 바로 아래 참고 |
|
||||||
|
|
||||||
|
##### 검증 결과는 `results/` 에 파일로 남습니다 — 그게 기준선입니다
|
||||||
|
|
||||||
|
`stage2.ps1` 의 `Step` 이 각 단계 출력을 게스트 `C:\win-ci\out\` 에 한 줄씩
|
||||||
|
기록하면서 호스트 `/home/claude/win-ci/results/` 로 올립니다. 판정 요약은
|
||||||
|
`results/summary.log` 이고 **검증한 리비전이 박혀** 있습니다.
|
||||||
|
|
||||||
|
처음에는 로그 채널로 마지막 몇 줄만 흘렸는데, `serial.log` 는 VM 이 뜰 때마다
|
||||||
|
truncate 되고 `guest.log` 는 `stage2` 가 다시 돌 때만 채워집니다. 그래서 pytest
|
||||||
|
실패 트레이스백이 호스트에 한 줄도 남지 않아, 화면을 읽고 옮겨 적는 수밖에
|
||||||
|
없었습니다. 고친 뒤에 고쳐졌다고 말할 근거가 없는 상태였습니다.
|
||||||
|
|
||||||
|
재검증은 리비전으로 요청합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts/win-ci && ./sync.sh <sha> # stage2 의 $TargetRev 를 그 커밋으로 박아 배포
|
||||||
|
tail -f /home/claude/win-ci/guest.log # 약 10분, 끝나면 results/summary.log 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
게스트 agent 는 `stage2.ps1` **내용 해시가 바뀔 때만** 다시 돕니다. 커밋만
|
||||||
|
푸시하면 재검증이 걸리지 않으니 sha 를 넘기세요. `build.sh` 를 다시 돌리면
|
||||||
|
안 됩니다 — 지워둔 Windows ISO 4.8GB 를 다시 내려받습니다.
|
||||||
|
|
||||||
|
배포할 포터블 exe 도 같은 통로로 돌아옵니다. 빌드와 기동이 **둘 다** 통과했을
|
||||||
|
때만 `results/LiveSub.exe` 로 올라오므로, 거기 파일이 있다는 것 자체가
|
||||||
|
"검증을 통과한 산출물" 이라는 뜻입니다. 그대로 릴리스에 올리면 됩니다.
|
||||||
|
|
||||||
|
##### exe 빌드 단계가 거짓 PASS 를 내던 함정
|
||||||
|
|
||||||
|
앞 회차의 `exe기동` 점검이 띄운 `LiveSub.exe` 가 파일을 잡고 있으면 PyInstaller
|
||||||
|
가 `PermissionError` 로 죽습니다. 그런데 **옛 exe 는 그대로 남아 있어서**
|
||||||
|
`Test-Path dist\LiveSub.exe` 검사는 통과했고, 결국 빌드가 실패한 회차가 PASS 로
|
||||||
|
보고됐습니다. 지금은 빌드 전에 프로세스를 죽이고 `build`·`dist` 를 지운 뒤
|
||||||
|
PyInstaller 종료코드까지 확인합니다. **산출물의 존재를 성공의 근거로 쓰면
|
||||||
|
안 됩니다** — 이전 성공이 남긴 것일 수 있습니다.
|
||||||
|
|
||||||
|
##### MSVC 가 왜 VM 에 깔려 있나
|
||||||
|
|
||||||
|
`native\process_loopback` 의 `livesub_capture.exe` 는 WASAPI 프로세스 루프백을
|
||||||
|
씁니다. 그 헤더(`audioclientactivationparams.h`)와 WRL 은 Windows SDK + MSVC
|
||||||
|
에만 있어서 cmake 만으로는 못 만듭니다. 그래서 `stage2.ps1` 이 VS 2022 Build
|
||||||
|
Tools(C++ 워크로드)를 설치합니다 — 첫 설치에 약 9분 걸리고 그 뒤로는 건너뜁니다.
|
||||||
|
|
||||||
|
이게 없으면 **포터블 빌드는 그대로 성공하되** 프로그램별 캡처가 출력 장치
|
||||||
|
전체 캡처로 조용히 폴백됩니다. 기능이 빠진 exe 가 PASS 로 나오는 조합이라,
|
||||||
|
검증 순서에서 `native_capture` 를 `windows_smoke` 와 `pyinstaller` **앞에**
|
||||||
|
둡니다. 뒤에 두면 둘 다 "없음"을 정상으로 보고 지나갑니다.
|
||||||
|
|
||||||
|
실제로 이 경로는 MSVC 를 깔기 전까지 **한 번도 컴파일된 적이 없었고**, 깔자마자
|
||||||
|
`main.cpp` 가 C2275 로 죽었습니다. CI 의 네이티브 빌드 단계가
|
||||||
|
`continue-on-error` 였던 탓에 그때까지 드러나지 않았습니다.
|
||||||
|
|
||||||
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
|
접속 정보: 계정 `ci` / 비밀번호는 `~/.config/ejclaw/secrets.json` 의
|
||||||
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
|
`win-ci.9` 항목. RDP 는 호스트 `127.0.0.1:13389`, VNC 는 `127.0.0.1:5919`.
|
||||||
|
|||||||
BIN
docs/images/change-location.png
Normal file
BIN
docs/images/change-location.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
docs/images/first-run.png
Normal file
BIN
docs/images/first-run.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -49,8 +49,13 @@ BOOL WINAPI ConsoleHandler(DWORD type) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
|
// ActivateAudioInterfaceAsync 완료 콜백. 결과를 이벤트로 넘겨준다.
|
||||||
|
//
|
||||||
|
// FtmBase 는 플래그가 아니라 섞어 넣는 클래스다. RuntimeClassFlags 안에
|
||||||
|
// `ClassicCom | FtmBase` 로 넣으면 MSVC 가 C2275 로 거부한다(타입을 값 자리에
|
||||||
|
// 썼으므로). 인터페이스 목록 쪽에 나열해야 한다 — ApplicationLoopback 샘플과
|
||||||
|
// 같은 형태다.
|
||||||
class ActivationHandler
|
class ActivationHandler
|
||||||
: public RuntimeClass<RuntimeClassFlags<ClassicCom | FtmBase>,
|
: public RuntimeClass<RuntimeClassFlags<ClassicCom>, FtmBase,
|
||||||
IActivateAudioInterfaceCompletionHandler> {
|
IActivateAudioInterfaceCompletionHandler> {
|
||||||
public:
|
public:
|
||||||
explicit ActivationHandler(HANDLE done) : done_(done) {}
|
explicit ActivationHandler(HANDLE done) : done_(done) {}
|
||||||
|
|||||||
@@ -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}장"
|
||||||
|
|
||||||
|
|
||||||
# --- 전 파이프라인 점검 -----------------------------------------------------
|
# --- 전 파이프라인 점검 -----------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ ROOT = "/home/claude/win-ci/serve"
|
|||||||
LOG = "/home/claude/win-ci/guest.log"
|
LOG = "/home/claude/win-ci/guest.log"
|
||||||
RESULTS = "/home/claude/win-ci/results"
|
RESULTS = "/home/claude/win-ci/results"
|
||||||
PORT = 8099
|
PORT = 8099
|
||||||
MAX_ARTIFACT = 32 * 1024 * 1024
|
# 로그 전문뿐 아니라 빌드된 포터블 exe(100MB+)도 이 통로로 회수한다.
|
||||||
|
# 32MB 로 두면 배포할 exe 가 413 으로 튕긴다.
|
||||||
|
MAX_ARTIFACT = 1024 * 1024 * 1024
|
||||||
|
CHUNK = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
@@ -82,11 +85,28 @@ class Handler(http.server.SimpleHTTPRequestHandler):
|
|||||||
if n > MAX_ARTIFACT:
|
if n > MAX_ARTIFACT:
|
||||||
self.send_error(413, "artifact too large")
|
self.send_error(413, "artifact too large")
|
||||||
return
|
return
|
||||||
data = self.rfile.read(n)
|
|
||||||
os.makedirs(RESULTS, exist_ok=True)
|
os.makedirs(RESULTS, exist_ok=True)
|
||||||
with open(os.path.join(RESULTS, name), "wb") as f:
|
# 통째로 read() 하지 않는다. exe 는 100MB 가 넘어서 메모리에 다 올리면
|
||||||
f.write(data)
|
# 봇 cgroup 의 메모리 상한을 건드린다. 조각으로 받아 바로 디스크에 쓴다.
|
||||||
print(f"artifact {name} {len(data)} bytes", flush=True)
|
#
|
||||||
|
# 그리고 임시 이름으로 받아 끝난 뒤에 옮긴다. 전송 중에 끊기면 반쪽짜리
|
||||||
|
# 파일이 정상 결과물처럼 남아 그대로 릴리스에 올라갈 수 있다.
|
||||||
|
final = os.path.join(RESULTS, name)
|
||||||
|
tmp = final + ".part"
|
||||||
|
left = n
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
while left > 0:
|
||||||
|
block = self.rfile.read(min(CHUNK, left))
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
f.write(block)
|
||||||
|
left -= len(block)
|
||||||
|
if left:
|
||||||
|
os.remove(tmp)
|
||||||
|
self.send_error(400, "artifact truncated")
|
||||||
|
return
|
||||||
|
os.replace(tmp, final)
|
||||||
|
print(f"artifact {name} {n} bytes", flush=True)
|
||||||
self.send_response(204)
|
self.send_response(204)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||||
|
|
||||||
|
# 자식 프로세스 stdout 을 UTF-8 로 해독한다.
|
||||||
|
#
|
||||||
|
# 이게 없으면 PowerShell 이 자식의 출력을 콘솔 코드페이지(LTSC en-US = 437)로
|
||||||
|
# 읽는다. python 쪽은 PYTHONUTF8=1 로 UTF-8 을 내보내므로 한글이 전부 깨져서
|
||||||
|
# 호스트로 회수한 증거 파일의 실패 메시지를 읽을 수 없다. 여기서 바꾸는 건
|
||||||
|
# '해독' 쪽이라 파일 저장 인코딩(StreamWriter 는 UTF-8 고정)과는 무관하다.
|
||||||
|
try { [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) } catch { }
|
||||||
|
|
||||||
$Dl = 'C:\win-ci\dl'
|
$Dl = 'C:\win-ci\dl'
|
||||||
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
|
New-Item -ItemType Directory -Force -Path $Dl | Out-Null
|
||||||
|
|
||||||
@@ -105,6 +113,41 @@ if (Have 'cmake') {
|
|||||||
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
|
else { Log "WARN cmake 설치 실패 - 워크플로의 native 빌드 단계는 건너뛰어진다" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------ VS 2022 Build Tools (C++)
|
||||||
|
# livesub_capture.exe (native\process_loopback) 는 WASAPI 프로세스 루프백을 쓴다.
|
||||||
|
# 그 헤더(audioclientactivationparams.h)와 WRL 은 Windows SDK + MSVC 에만 있다.
|
||||||
|
# cmake 만 깔면 "generator 는 있는데 컴파일러가 없다"로 configure 에서 죽는다.
|
||||||
|
#
|
||||||
|
# 왜 빠뜨리면 안 되나: 이 exe 가 없으면 포터블 빌드가 그대로 성공하되
|
||||||
|
# 프로그램별 캡처가 장치 전체 캡처로 조용히 폴백된다. 즉 "게임 소리만 받는다"는
|
||||||
|
# 핵심 기능이 빠진 채로 exe 가 나온다. 실제로 그 상태의 exe 가 한 번 나왔다.
|
||||||
|
$VsWhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
|
||||||
|
function Get-MsvcRoot {
|
||||||
|
if (-not (Test-Path $VsWhere)) { return $null }
|
||||||
|
$p = & $VsWhere -latest -products * `
|
||||||
|
-requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 `
|
||||||
|
-property installationPath 2>$null
|
||||||
|
if ($p) { return ($p | Select-Object -First 1) }
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
$msvc = Get-MsvcRoot
|
||||||
|
if ($msvc) {
|
||||||
|
Log "MSVC 이미 있음: $msvc"
|
||||||
|
} else {
|
||||||
|
$f = Get-File 'https://aka.ms/vs/17/release/vs_BuildTools.exe' 'vs_BuildTools.exe'
|
||||||
|
Log "VS 2022 Build Tools 설치 중 (C++ 워크로드, 수 GB - 20분 이상 걸린다)"
|
||||||
|
# --wait 없이 Start-Process -Wait 만 쓰면 부트스트래퍼가 먼저 반환해 버려
|
||||||
|
# 설치가 끝나기 전에 다음 단계로 넘어간다. 둘 다 있어야 한다.
|
||||||
|
$p = Start-Process $f -Wait -PassThru -ArgumentList `
|
||||||
|
'--quiet', '--wait', '--norestart', '--nocache', `
|
||||||
|
'--add', 'Microsoft.VisualStudio.Workload.VCTools', '--includeRecommended'
|
||||||
|
# 3010 = 설치는 됐고 재부팅만 권고. 실패가 아니다.
|
||||||
|
Log "Build Tools 설치기 종료코드 $($p.ExitCode)"
|
||||||
|
$msvc = Get-MsvcRoot
|
||||||
|
if ($msvc) { Log "MSVC 설치 완료: $msvc" }
|
||||||
|
else { Log "WARN MSVC 설치 실패 - livesub_capture.exe 를 빌드할 수 없다" }
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ act_runner
|
# ------------------------------------------------------------------ act_runner
|
||||||
$RunnerDir = 'C:\gitea-runner'
|
$RunnerDir = 'C:\gitea-runner'
|
||||||
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
|
$RunnerExe = Join-Path $RunnerDir 'act_runner.exe'
|
||||||
@@ -212,6 +255,12 @@ Log "=== stage2 완료 ==="
|
|||||||
# ==================================================================
|
# ==================================================================
|
||||||
$Repo = 'C:\win-ci\live-app-translator'
|
$Repo = 'C:\win-ci\live-app-translator'
|
||||||
$OutDir = 'C:\win-ci\out'
|
$OutDir = 'C:\win-ci\out'
|
||||||
|
|
||||||
|
# 검증 대상 리비전. 'main' 이면 origin/main 최신.
|
||||||
|
# 호스트에서 `./sync.sh <sha>` 를 쓰면 이 한 줄이 그 sha 로 바뀐 사본이 배포되고,
|
||||||
|
# 내용이 바뀌므로 게스트 agent 가 해시 차이를 보고 20초 안에 다시 돈다.
|
||||||
|
# = "이 커밋을 검증해라" 라는 요청이 곧 재실행 트리거다.
|
||||||
|
$TargetRev = 'main'
|
||||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||||
|
|
||||||
# 출력 원본을 호스트 파일로 회수한다.
|
# 출력 원본을 호스트 파일로 회수한다.
|
||||||
@@ -226,7 +275,9 @@ function Send-Artifact($name, $path) {
|
|||||||
$bytes = [IO.File]::ReadAllBytes($path)
|
$bytes = [IO.File]::ReadAllBytes($path)
|
||||||
# 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
|
# 백틱 줄바꿈을 쓰지 않는다. LF 로 저장된 스크립트에서 토크나이저가
|
||||||
# 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
|
# 어긋나 스크립트 전체가 무효가 된 전례가 있다 (NOTES.md 참고).
|
||||||
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 120; Body = $bytes; ContentType = 'application/octet-stream' }
|
# 타임아웃은 넉넉히. 로그는 수십 KB 지만 포터블 exe 는 100MB 를 넘고
|
||||||
|
# slirp(QEMU user networking) 는 그만큼 빠르지 않다.
|
||||||
|
$req = @{ Uri = "http://10.0.2.2:8099/artifact/$name"; Method = 'Post'; TimeoutSec = 1800; Body = $bytes; ContentType = 'application/octet-stream' }
|
||||||
Invoke-RestMethod @req | Out-Null
|
Invoke-RestMethod @req | Out-Null
|
||||||
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
|
Log " -> 호스트로 $name ($($bytes.Length) bytes)"
|
||||||
} catch {
|
} catch {
|
||||||
@@ -267,13 +318,16 @@ function Step($name, $block) {
|
|||||||
$ErrorActionPreference = 'Continue'
|
$ErrorActionPreference = 'Continue'
|
||||||
try {
|
try {
|
||||||
Sync-Path
|
Sync-Path
|
||||||
if (Test-Path $Repo) {
|
if (-not (Test-Path $Repo)) {
|
||||||
Log "저장소 갱신"
|
|
||||||
Push-Location $Repo; & git fetch --all -q; & git reset --hard origin/main -q; Pop-Location
|
|
||||||
} else {
|
|
||||||
Log "저장소 clone"
|
Log "저장소 clone"
|
||||||
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
|
& git clone -q https://git.tkrmagid.kr/tkrmagid/live-app-translator $Repo
|
||||||
}
|
}
|
||||||
|
$ref = if ($TargetRev -eq 'main') { 'origin/main' } else { $TargetRev }
|
||||||
|
Log "저장소 갱신 -> $ref"
|
||||||
|
Push-Location $Repo
|
||||||
|
& git fetch --all -q
|
||||||
|
& git reset --hard $ref -q
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
Push-Location $Repo
|
Push-Location $Repo
|
||||||
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
|
$py = Join-Path $Repo '.venv-ci\Scripts\python.exe'
|
||||||
@@ -297,10 +351,59 @@ try {
|
|||||||
$r = [ordered]@{}
|
$r = [ordered]@{}
|
||||||
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
$r['pytest'] = Step 'pytest' { & $py -m pytest tests -q -rf --tb=long; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||||
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
$r['ruff'] = Step 'ruff' { & $py -m ruff check src tests scripts; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||||
|
|
||||||
|
# 캡처 도우미를 먼저 만든다. windows_smoke 가 이 exe 의 존재를 점검하고,
|
||||||
|
# livesub.spec 도 있을 때만 포터블 exe 안에 담는다. 순서가 바뀌면 둘 다
|
||||||
|
# "없음"으로 통과해서 기능이 빠진 exe 가 나온다.
|
||||||
|
$r['캡처도우미'] = Step 'native_capture' {
|
||||||
|
& cmake -S native\process_loopback -B native\process_loopback\build -A x64
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "cmake configure exit $LASTEXITCODE" }
|
||||||
|
& cmake --build native\process_loopback\build --config Release
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "cmake build exit $LASTEXITCODE" }
|
||||||
|
$cap = 'src\livesub\resources\bin\livesub_capture.exe'
|
||||||
|
if (-not (Test-Path $cap)) { throw "$cap 없음" }
|
||||||
|
"livesub_capture.exe $((Get-Item $cap).Length) bytes"
|
||||||
|
}
|
||||||
|
|
||||||
$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" } }
|
||||||
$r['exe빌드'] = Step 'pyinstaller' { & $py -m pip install pyinstaller --quiet; & $py -m PyInstaller packaging\livesub.spec --noconfirm; if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }; "exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB" }
|
|
||||||
|
# 점검이 찍은 스크린샷을 묶어서 호스트로 가져온다. 화면 관련 회귀는 로그로는
|
||||||
|
# 안 보이고, 문서에 넣을 그림도 실제 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
|
||||||
|
# 이전 산출물을 반드시 지우고 시작한다. 남겨두면 두 가지가 겹쳐 터진다.
|
||||||
|
# 1) 앞 회차의 exe기동 점검이 띄운 LiveSub 가 파일을 잡고 있어
|
||||||
|
# PyInstaller 가 EXE 를 덮어쓰지 못하고 PermissionError 로 죽는다.
|
||||||
|
# 2) 그런데 옛 exe 는 그대로 남아 있으니 Test-Path 는 통과한다.
|
||||||
|
# 즉 빌드가 실패했는데 PASS 로 보고된다 - 실제로 그렇게 오보가 났다.
|
||||||
|
Get-Process LiveSub -ErrorAction SilentlyContinue | Stop-Process -Force
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Remove-Item -Recurse -Force build, dist -ErrorAction SilentlyContinue
|
||||||
|
& $py -m PyInstaller packaging\livesub.spec --noconfirm
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "PyInstaller exit $LASTEXITCODE" }
|
||||||
|
if (-not (Test-Path 'dist\LiveSub.exe')) { throw 'dist\LiveSub.exe 없음' }
|
||||||
|
"exe 크기 $([math]::Round((Get-Item 'dist\LiveSub.exe').Length/1MB,1)) MB"
|
||||||
|
}
|
||||||
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
$r['exe기동'] = Step 'exe_smoke' { & $py packaging\windows_smoke.py --exe dist\LiveSub.exe; if ($LASTEXITCODE -ne 0) { throw "exit $LASTEXITCODE" } }
|
||||||
|
|
||||||
|
# 빌드와 기동이 둘 다 통과했을 때만 배포용으로 회수한다. 실패한 exe 를
|
||||||
|
# 호스트로 올리면 그게 릴리스에 올라갈 위험이 있다.
|
||||||
|
if ($r['exe빌드'] -and $r['exe기동']) {
|
||||||
|
Send-Artifact 'LiveSub.exe' (Join-Path $Repo 'dist\LiveSub.exe')
|
||||||
|
} else {
|
||||||
|
Log "exe 회수 건너뜀 (빌드=$($r['exe빌드']) 기동=$($r['exe기동']))"
|
||||||
|
}
|
||||||
|
|
||||||
# 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
|
# 판정 요약도 파일로 남긴다. 어떤 리비전을 검증한 결과인지 같이 박아야
|
||||||
# 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
|
# 나중에 "언제 통과한 건지" 를 되짚을 수 있다.
|
||||||
$rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
|
$rev = (& git rev-parse --short HEAD 2>&1 | Select-Object -First 1)
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
|
# 저장소의 win-ci 스크립트를 /home/claude/win-ci 로 반영한다.
|
||||||
#
|
#
|
||||||
|
# ./sync.sh stage2.ps1 을 그대로 배포 (origin/main 최신을 검증)
|
||||||
|
# ./sync.sh <sha> 그 커밋을 검증하도록 $TargetRev 를 박아서 배포
|
||||||
|
#
|
||||||
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
|
# build.sh 를 다시 돌리면 안 된다. build.sh 의 fetch 는 지워둔 Windows ISO(4.8GB)를
|
||||||
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
|
# 다시 내려받는다. 설치가 끝난 VM 에 스크립트만 갱신할 때는 이걸 쓴다.
|
||||||
#
|
#
|
||||||
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
|
# stage2.ps1 은 게스트 agent 가 20초마다 해시를 비교해 받아가므로, 복사만 하면
|
||||||
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다.
|
# VM 재시작 없이 다음 폴링에 새 버전이 실행된다. 반대로 내용이 같으면 다시 돌지
|
||||||
|
# 않는다 — 그래서 재검증을 요청할 때는 sha 를 넘겨 내용이 바뀌게 한다.
|
||||||
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
|
# serve-host.py 는 호스트 프로세스라 유닛을 재시작해야 반영된다.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
D="${WIN_CI_DIR:-/home/claude/win-ci}"
|
D="${WIN_CI_DIR:-/home/claude/win-ci}"
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
REV="${1:-}"
|
||||||
|
|
||||||
mkdir -p "$D/serve" "$D/results"
|
mkdir -p "$D/serve" "$D/results"
|
||||||
cp "$HERE/serve/stage2.ps1" "$D/serve/"
|
cp "$HERE/serve/stage2.ps1" "$D/serve/"
|
||||||
|
if [ -n "$REV" ]; then
|
||||||
|
sed -i "s/^\$TargetRev = 'main'$/\$TargetRev = '$REV'/" "$D/serve/stage2.ps1"
|
||||||
|
grep -q "^\$TargetRev = '$REV'$" "$D/serve/stage2.ps1" || { echo "TargetRev 치환 실패"; exit 1; }
|
||||||
|
echo "검증 대상 리비전 = $REV"
|
||||||
|
fi
|
||||||
cp "$HERE"/*.sh "$HERE/serve-host.py" "$D/"
|
cp "$HERE"/*.sh "$HERE/serve-host.py" "$D/"
|
||||||
chmod +x "$D"/*.sh
|
chmod +x "$D"/*.sh
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
with contextlib.suppress(OSError):
|
if to_file:
|
||||||
handlers.append(logging.FileHandler(log_file, encoding="utf-8"))
|
with contextlib.suppress(OSError):
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
213
src/livesub/storage.py
Normal 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
|
||||||
211
src/livesub/ui/data_location.py
Normal file
211
src/livesub/ui/data_location.py
Normal 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()
|
||||||
@@ -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:
|
||||||
|
|||||||
61
tests/test_data_location.py
Normal file
61
tests/test_data_location.py
Normal 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
124
tests/test_storage.py
Normal 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()
|
||||||
Reference in New Issue
Block a user