├── .github └── workflows │ └── main.yml ├── README.md ├── pic_01.png ├── pic_02.png ├── pic_03.png └── src ├── StreamFileAssistant.py ├── StreamFileAssistant.spec └── requirements.txt /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Package Application with Pyinstaller 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # 若要只觸發 v* 標籤,可改成 ['v*'] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Package Application 17 | uses: JackMcKew/pyinstaller-action-windows@main 18 | with: 19 | path: src # 這裡指向程式的資料夾 20 | 21 | - name: Upload Build Artifact 22 | uses: actions/upload-artifact@v4 23 | with: 24 | name: StreamFileAssistant 25 | # 將打包後的檔案 (StreamFileAssistant.exe) 放在 src/dist/windows 26 | path: src/dist/windows 27 | 28 | release: 29 | needs: build 30 | runs-on: ubuntu-latest 31 | # 只有推標籤( refs/tags/ )時才執行 32 | if: startsWith(github.ref, 'refs/tags/') 33 | 34 | steps: 35 | - name: Download Build Artifact 36 | uses: actions/download-artifact@v4 37 | with: 38 | name: StreamFileAssistant 39 | # 指定下載後放回同樣位置,確保後續能在同樣路徑找到檔案 40 | path: src/dist/windows 41 | 42 | - name: Create GitHub Release 43 | id: create_release 44 | uses: actions/create-release@v1 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | with: 48 | # 只會拿到標籤名 (例如 v1.0.3),不含 refs/tags/ 49 | tag_name: ${{ github.ref_name }} 50 | release_name: Release ${{ github.ref_name }} 51 | draft: false 52 | prerelease: false 53 | body: | 54 | ## Version ${{ github.ref_name }} 55 | The auto-generated release contains the latest StreamFileAssistant executable. 56 | 57 | # 非必要,但常見用來確認下載後的檔案結構是否正確 58 | - name: List files 59 | run: ls -R src/dist/windows 60 | 61 | - name: Upload Release Asset 62 | uses: actions/upload-release-asset@v1 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | with: 66 | upload_url: ${{ steps.create_release.outputs.upload_url }} 67 | # 這裡就是 "下載後" 的檔案路徑 68 | asset_path: src/dist/windows/StreamFileAssistant.exe 69 | asset_name: StreamFileAssistant.exe 70 | asset_content_type: application/octet-stream 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stream file Management Assistant 2 | 3 | ## Introduction 4 | 5 | In the past, missing medium LODs often caused crashes, forcing many creators to manually duplicate files and append `_hi` to make things work. 6 | 7 | Big thanks to **packfile** ([fix for crashes when no medium LOD exists](https://github.com/citizenfx/fivem/pull/2965)), this workaround is no longer necessary, saving significant file space. 8 | 9 | This tool, using Python and packaged to exe, is designed to clean up those extra, now unnecessary files, helping you optimize your stream resources. :smile: 10 | 11 | --- 12 | 13 | ## Features 14 | 15 | ### 1. Duplicate YFT Cleaner 16 | - Specifically designed to clean up duplicate-pasted files. 17 | - Scans for and identifies `_hi` YFT files that were created as a workaround for missing medium LODs. 18 | - Provides tools to select, review, and delete unnecessary duplicates. 19 | - What is size margin? 20 | - Allows you to define a margin (in KB) for file size comparison when _hi files differ slightly from their counterparts. 21 | 22 | ### 2. Stream Duplicate Checker 23 | - Checks all `stream` directories for duplicate files, regardless of extension. 24 | - Allows users to quickly locate files and their duplicate directories via right-click context menu. 25 | - Simplifies the process of managing large resource libraries. 26 | 27 | ### 3. Manual File List Checker 28 | - **New!** You can now paste an external file list into a text area and check whether those files exist in the specified Stream root directory. 29 | - The tool will compare each file name in your list against the files found in the stream directories. 30 | - If a file is found only once, its absolute path is shown. 31 | - If multiple copies exist, all locations are listed under a duplicate label. 32 | - If a file is not found, it is marked as **NOT FOUND**. 33 | --- 34 | 35 | ## Before You Proceed 36 | 37 | **Always back up everything you plan to modify!** It’s a good habit to avoid any unintended issues. 38 | 39 | --- 40 | 41 | ## Screenshots 42 | 43 | ### Duplicate YFT Cleaner 44 | ![Duplicate YFT Cleaner Screenshot](pic_01.png) 45 | *Identify and manage duplicate YFT files with ease.* 46 | 47 | ### Stream Duplicate Checker 48 | ![Stream Duplicate Checker Screenshot](pic_02.png) 49 | *Locate and resolve file collisions in stream directories.* 50 | 51 | ### Manual File List Checker 52 | ![Manual File List Checker Screenshot](pic_03.png) 53 | *Paste your file list and verify duplicates or missing files in the stream directory.* 54 | 55 | --- 56 | 57 | ## Contributions 58 | 59 | We welcome contributions to improve this tool! Feel free to fork the repository and submit a pull request. 60 | 61 | --- 62 | 63 | ## Acknowledgments 64 | 65 | This tool was inspired by and references the FiveM project. 66 | - GitHub Repository: [citizenfx/fivem](https://github.com/citizenfx/fivem/blob/master/code/components/citizen-server-impl/src/ResourceStreamComponent.cpp) 67 | 68 | --- 69 | 70 | For any questions or support, feel free to contact me. 71 | -------------------------------------------------------------------------------- /pic_01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/st860923/StreamFileAssistant/f271472b1f5ccb788579fe35d0ff5cf469531b51/pic_01.png -------------------------------------------------------------------------------- /pic_02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/st860923/StreamFileAssistant/f271472b1f5ccb788579fe35d0ff5cf469531b51/pic_02.png -------------------------------------------------------------------------------- /pic_03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/st860923/StreamFileAssistant/f271472b1f5ccb788579fe35d0ff5cf469531b51/pic_03.png -------------------------------------------------------------------------------- /src/StreamFileAssistant.py: -------------------------------------------------------------------------------- 1 | import os 2 | import struct 3 | import hashlib 4 | import pyperclip 5 | import threading 6 | import tkinter as tk 7 | from tkinter import ttk 8 | from tkinter import filedialog, messagebox 9 | from concurrent.futures import ThreadPoolExecutor, as_completed 10 | from pathlib import Path 11 | 12 | # -----------------------------------# 13 | # 1. Multi-Language Translation (Translator) 14 | # -----------------------------------# 15 | class Translator: 16 | """ 17 | Translation management class. 18 | Responsible for maintaining dictionaries for various languages, 19 | providing an external translate(...) method to get the corresponding text. 20 | 21 | alias: Translator 22 | parameter: language_code (str) - default is "en" 23 | """ 24 | def __init__(self, language_code="en"): 25 | self.current_language = language_code 26 | 27 | # You can place all translation dictionaries here 28 | self.translations = { 29 | "en": { 30 | "title": "Duplicate YFT Cleaner", 31 | "root_dir_label": "Root Directory:", 32 | "browse_button": "Browse...", 33 | "scan_button": "Start Scan", 34 | "progress_label": "Progress: {}/{}", 35 | "select_all_button": "Select All", 36 | "select_column": "Select", 37 | "model_name_column": "Model Name", 38 | "path_column": "File Path", 39 | "size_column": "Size (MB)", 40 | "status_column": "Oversize?", 41 | "copy_clipboard_button": "Copy List to Clipboard", 42 | "save_file_button": "Save List to File", 43 | "delete_files_button": "Delete Selected Files", 44 | "status_ready": "Ready", 45 | "status_scanning": "Scanning...", 46 | "status_completed": "Scan Completed.", 47 | "status_error": "Error:", 48 | "confirm_delete_title": "Confirm Deletion", 49 | "confirm_delete_message": "Are you sure you want to delete the selected {} files?", 50 | "success_delete": "Successfully deleted {} files.", 51 | "error_delete": "Failed to delete the following files:\n{}", 52 | "info_no_files": "No files available.", 53 | "info_no_selected": "No files selected.", 54 | "info_copy_success": "File list copied to clipboard.", 55 | "info_copy_fail": "Failed to copy to clipboard.", 56 | "info_save_success": "File list saved to {}.", 57 | "info_save_fail": "Failed to save file list.", 58 | "language_label": "Language:", 59 | "status_ok": "OK", 60 | "status_warning": "Warning", 61 | "status_critical": "Critical", 62 | "status_oversize": "Critical Oversized", 63 | "oversized_warning": "Oversized assets can and WILL lead to streaming issues (such as models not loading/rendering).", 64 | "view_folder": "View Folder", 65 | # Stream Duplicate Checker Translations 66 | "stream_tab": "Stream Duplicate Checker", 67 | "stream_root_dir_label": "Stream Root Directory:", 68 | "stream_browse_button": "Browse...", 69 | "stream_scan_button": "Scan for Duplicates", 70 | "stream_progress_label": "Progress: {}/{}", 71 | "stream_duplicate_file_column": "Duplicate File Name", 72 | "stream_locations_column": "Locations", 73 | "stream_select_all_button": "Select All", 74 | "stream_copy_clipboard_button": "Copy Duplicates to Clipboard", 75 | "stream_save_file_button": "Save Duplicates to File", 76 | "stream_delete_duplicates_button": "Delete Selected Duplicates", 77 | "stream_status_ready": "Ready", 78 | "stream_status_scanning": "Scanning...", 79 | "stream_status_completed": "Scan Completed.", 80 | "stream_status_error": "Error:", 81 | "stream_confirm_delete_title": "Confirm Deletion", 82 | "stream_confirm_delete_message": "Are you sure you want to delete the selected duplicate files?", 83 | "stream_success_delete": "Successfully deleted {} duplicate files.", 84 | "stream_error_delete": "Failed to delete the following duplicate files:\n{}", 85 | "stream_info_no_duplicates": "No duplicate files found.", 86 | "stream_info_no_selected": "No duplicate files selected.", 87 | "stream_info_copy_success": "Duplicate file list copied to clipboard.", 88 | "stream_info_copy_fail": "Failed to copy duplicate file list to clipboard.", 89 | "stream_info_save_success": "Duplicate file list saved to {}.", 90 | "stream_info_save_fail": "Failed to save duplicate file list.", 91 | "stream_select_column": "Select", 92 | "manual_check_button": "Manual Check for Duplicates", 93 | "stream_info_no_selected": "No directory selected.", 94 | "stream_info_no_files": "No directory or invalid path selected.", 95 | "stream_status_scanning": "Scanning...", 96 | "stream_status_completed": "Scan Completed.", 97 | "stream_status_error": "Error:", 98 | "manual_check_results": "Manual Check Results:", 99 | "not_found": "NOT FOUND", 100 | "duplicates_label": "Duplicate(s) Found in:", 101 | }, 102 | "zh_TW": { 103 | "title": "YFT 檢查管理工具 by pgonintwitch", 104 | "root_dir_label": "根目錄路徑:", 105 | "browse_button": "瀏覽...", 106 | "scan_button": "開始掃描", 107 | "progress_label": "進度:{}/{}", 108 | "select_all_button": "全選", 109 | "select_column": "選擇", 110 | "model_name_column": "模型名稱", 111 | "path_column": "檔案路徑", 112 | "size_column": "大小 (MB)", 113 | "status_column": "狀態", 114 | "copy_clipboard_button": "複製清單到剪貼簿", 115 | "save_file_button": "保存清單到文件", 116 | "delete_files_button": "刪除選定的檔案", 117 | "status_ready": "準備就緒", 118 | "status_scanning": "掃描中...", 119 | "status_completed": "掃描完成。", 120 | "status_error": "錯誤:", 121 | "confirm_delete_title": "確認刪除", 122 | "confirm_delete_message": "確定要刪除選定的 {} 個檔案嗎?", 123 | "success_delete": "已成功刪除 {} 個檔案。", 124 | "error_delete": "無法刪除以下檔案:\n{}", 125 | "info_no_files": "沒有可用的檔案。", 126 | "info_no_selected": "沒有選擇檔案。", 127 | "info_copy_success": "檔案清單已複製到剪貼簿。", 128 | "info_copy_fail": "無法複製到剪貼簿。", 129 | "info_save_success": "檔案清單已保存到 {}。", 130 | "info_save_fail": "無法保存檔案清單。", 131 | "language_label": "語言:", 132 | "status_ok": "正常", 133 | "status_warning": "警告", 134 | "status_critical": "危急", 135 | "status_oversize": "過大", 136 | "oversized_warning": "Oversized assets can and WILL lead to streaming issues (例如模型未載入/渲染)。", 137 | "view_folder": "打開資料夾", 138 | # Stream Duplicate Checker Translations 139 | "stream_tab": "Stream 重複檔案檢查", 140 | "stream_root_dir_label": "Stream 根目錄路徑:", 141 | "stream_browse_button": "瀏覽...", 142 | "stream_scan_button": "掃描重複檔案", 143 | "stream_progress_label": "進度:{}/{}", 144 | "stream_duplicate_file_column": "重複檔案名稱", 145 | "stream_locations_column": "所在位置", 146 | "stream_select_all_button": "全選", 147 | "stream_copy_clipboard_button": "複製重複檔案到剪貼簿", 148 | "stream_save_file_button": "保存重複檔案到文件", 149 | "stream_delete_duplicates_button": "刪除選定的重複檔案", 150 | "stream_status_ready": "準備就緒", 151 | "stream_status_scanning": "掃描中...", 152 | "stream_status_completed": "掃描完成。", 153 | "stream_status_error": "錯誤:", 154 | "stream_confirm_delete_title": "確認刪除", 155 | "stream_confirm_delete_message": "確定要刪除選定的重複檔案嗎?", 156 | "stream_success_delete": "已成功刪除 {} 個重複檔案。", 157 | "stream_error_delete": "無法刪除以下重複檔案:\n{}", 158 | "stream_info_no_duplicates": "未發現重複檔案。", 159 | "stream_info_no_selected": "未選擇任何重複檔案。", 160 | "stream_info_copy_success": "重複檔案清單已複製到剪貼簿。", 161 | "stream_info_copy_fail": "無法將重複檔案清單複製到剪貼簿。", 162 | "stream_info_save_success": "重複檔案清單已保存到 {}。", 163 | "stream_info_save_fail": "無法保存重複檔案清單。", 164 | "stream_select_column": "選擇", 165 | # Manual Duplicate Check texts 166 | "manual_check_button": "手動重複檢查", 167 | "stream_info_no_selected": "未選擇任何資料夾。", 168 | "stream_info_no_files": "沒有可用的資料夾,或路徑無效。", 169 | "stream_status_scanning": "掃描中...", 170 | "stream_status_completed": "掃描完成。", 171 | "stream_status_error": "錯誤:", 172 | "manual_check_results": "手動重複檢查結果:", 173 | "not_found": "未發現檔案", 174 | "duplicates_label": "重複檔案位置:", 175 | }, 176 | # 可依需求增加其他語系 177 | } 178 | 179 | def set_language(self, language_code: str): 180 | """ 181 | Set the current language. 182 | 183 | alias: set_language 184 | parameter: language_code (str) 185 | """ 186 | self.current_language = language_code 187 | 188 | def translate(self, text_id: str, *args) -> str: 189 | """ 190 | Return the text corresponding to the current language. 191 | If text_id does not exist in the dictionary, return text_id directly. 192 | If formatting is needed (e.g., "Progress: {}/{}"), pass the args accordingly. 193 | 194 | alias: translate 195 | parameter: text_id (str) 196 | parameter: *args 197 | """ 198 | lang_map = self.translations.get(self.current_language, {}) 199 | text = lang_map.get(text_id, text_id) 200 | if args: 201 | return text.format(*args) 202 | return text 203 | 204 | # -------------------------# 205 | # 2. YFT Cleaner Logic 206 | # -------------------------# 207 | class YftCleaner: 208 | """ 209 | A class dedicated to handling YFT ( *_hi.yft ) file scanning, size and status checking, deletion, etc. 210 | 211 | alias: YftCleaner 212 | parameter: translator (Translator) 213 | parameter: size_margin_kb (float) - the allowed difference in KB for considering two files 'identical' if their hashes differ 214 | """ 215 | def __init__(self, translator: Translator, size_margin_kb: float = 0.0): 216 | self._tr = translator 217 | self.deletable_files = [] 218 | self.size_margin_kb = size_margin_kb 219 | 220 | def find_hi_yft_files(self, root_dir: str): 221 | """ 222 | Recursively find all `*_hi.yft` files in any 'stream' folder under root_dir. 223 | Return: List of file paths (List[str]) 224 | 225 | alias: find_hi_yft_files 226 | parameter: root_dir (str) 227 | """ 228 | hi_yft_files = [] 229 | root = Path(root_dir) 230 | for stream_dir in root.rglob('stream'): 231 | if stream_dir.is_dir(): 232 | for yft_file in stream_dir.glob('**/*_hi.yft'): 233 | hi_yft_files.append(str(yft_file)) 234 | return hi_yft_files 235 | 236 | def scan_files(self, root_directory: str): 237 | """ 238 | Main entry point for performing the scanning procedure. 239 | Return: List of tuples: [(file_path, size_str, status), ...] 240 | 241 | alias: scan_files 242 | parameter: root_directory (str) 243 | """ 244 | hi_yft_files = self.find_hi_yft_files(root_directory) 245 | unique_hi_yft_files = list(set(hi_yft_files)) 246 | results = [] 247 | 248 | for f in unique_hi_yft_files: 249 | item = self.process_file(f) 250 | if item: 251 | results.append(item) 252 | self.deletable_files = results 253 | return results 254 | 255 | def process_file(self, hi_file: str): 256 | """ 257 | Performs logic for a given hi_file, including: 258 | 1. Try to match with the original file (model.yft) 259 | 2. Compare hashes 260 | 3. If hash mismatch, optionally check if size difference is within margin_kb 261 | 4. If considered identical, read header, check RSC7/8, compute size, determine status, etc. 262 | 263 | Return: (file_path, size_str, status) or None 264 | 265 | alias: process_file 266 | parameter: hi_file (str) 267 | """ 268 | original_file = self.get_original_file(hi_file) 269 | if not original_file or not os.path.isfile(original_file): 270 | return None 271 | 272 | # Step 1) Compute hashes 273 | hi_hash = self.compute_file_hash(hi_file) 274 | org_hash = self.compute_file_hash(original_file) 275 | 276 | # We'll track difference in bytes if we use margin 277 | diff_bytes = 0 278 | used_margin = False 279 | 280 | # Step 2) If hashes match, consider them identical 281 | if hi_hash and org_hash and hi_hash == org_hash: 282 | return self._process_identical_files(hi_file, diff_bytes=0) 283 | 284 | # Step 3) If hashes mismatch, check if size difference is within user margin 285 | if self.size_margin_kb > 0.0: 286 | size_hi_bytes = os.path.getsize(hi_file) 287 | size_org_bytes = os.path.getsize(original_file) 288 | diff_bytes = abs(size_hi_bytes - size_org_bytes) 289 | diff_kb = diff_bytes / 1024.0 290 | 291 | if diff_kb <= self.size_margin_kb: 292 | used_margin = True 293 | return self._process_identical_files(hi_file, diff_bytes=diff_bytes) 294 | 295 | # If neither matched nor within margin => not identical 296 | return None 297 | 298 | def _process_identical_files(self, hi_file: str, diff_bytes: int): 299 | """ 300 | Helper method to handle the rest of the logic if hi_file is considered identical to its original. 301 | Reads the header, checks resource type, calculates size/status, etc. 302 | 303 | alias: _process_identical_files 304 | parameter: hi_file (str) 305 | parameter: diff_bytes (int) - difference in bytes if margin is used, otherwise 0 306 | """ 307 | # Read header 308 | is_resource, physPages, virtPages = self.read_yft_header(hi_file) 309 | 310 | if is_resource: 311 | phys_size = self.convert_rsc7_size(physPages) 312 | virt_size = self.convert_rsc7_size(virtPages) 313 | phys_mb = phys_size / (1024.0 * 1024.0) 314 | virt_mb = virt_size / (1024.0 * 1024.0) 315 | size_str = f"PH:{phys_mb:.2f}/VR:{virt_mb:.2f} MB" 316 | max_mb = max(phys_mb, virt_mb) 317 | status = self.determine_status(max_mb) 318 | if status == self._tr.translate("status_oversize"): 319 | pass 320 | elif status in [self._tr.translate("status_warning"), self._tr.translate("status_critical")]: 321 | status += f" - {self._tr.translate('oversized_warning')}" 322 | else: 323 | status += " - good" 324 | else: 325 | # Not a recognized RSC resource, fall back to actual file size 326 | actual_size = os.path.getsize(hi_file) 327 | actual_mb = actual_size / (1024.0 * 1024.0) 328 | size_str = f"{actual_mb:.2f} MB" 329 | status = self.determine_status(actual_mb) 330 | if status == self._tr.translate("status_oversize"): 331 | pass 332 | elif status in [self._tr.translate("status_warning"), self._tr.translate("status_critical")]: 333 | status += f" - {self._tr.translate('oversized_warning')}" 334 | else: 335 | status += " - Unknown format" 336 | 337 | # If diff_bytes > 0, append margin info to the status 338 | if diff_bytes > 0: 339 | status += f" [Margin used: diff={diff_bytes} bytes]" 340 | 341 | return (hi_file, size_str, status) 342 | 343 | def get_original_file(self, hi_file: str): 344 | """ 345 | Retrieve the original file corresponding to hi_file (replace '_hi' with ''). 346 | Return: path to the original file or None if not found. 347 | 348 | alias: get_original_file 349 | parameter: hi_file (str) 350 | """ 351 | dirpath, hi_filename = os.path.split(hi_file) 352 | base_name, ext = os.path.splitext(hi_filename) 353 | if '_hi' not in base_name.lower(): 354 | return None 355 | original_base_name = base_name.lower().replace('_hi', '') 356 | original_filename = original_base_name + ext 357 | return os.path.join(dirpath, original_filename) 358 | 359 | def compute_file_hash(self, file_path: str): 360 | """ 361 | Compute the SHA256 hash of a file. 362 | Return: Hexadecimal digest string or None if error. 363 | 364 | alias: compute_file_hash 365 | parameter: file_path (str) 366 | """ 367 | try: 368 | hash_func = hashlib.sha256() 369 | with open(file_path, 'rb') as f: 370 | for chunk in iter(lambda: f.read(4096), b""): 371 | hash_func.update(chunk) 372 | return hash_func.hexdigest() 373 | except Exception as e: 374 | print(f"{self._tr.translate('status_error')} {e}") 375 | return None 376 | 377 | def read_yft_header(self, file_path: str): 378 | """ 379 | Read the YFT file header. 380 | Return: (is_resource, rscPagesPhysical, rscPagesVirtual) 381 | 382 | alias: read_yft_header 383 | parameter: file_path (str) 384 | """ 385 | try: 386 | with open(file_path, 'rb') as f: 387 | header = f.read(16) 388 | if len(header) < 16: 389 | return (False, 0, 0) 390 | magic, version, virtPages, physPages = struct.unpack('> 27) & 0x1) << 0 410 | s1 = ((flags >> 26) & 0x1) << 1 411 | s2 = ((flags >> 25) & 0x1) << 2 412 | s3 = ((flags >> 24) & 0x1) << 3 413 | s4 = ((flags >> 17) & 0x7F) << 4 414 | s5 = ((flags >> 11) & 0x3F) << 5 415 | s6 = ((flags >> 7) & 0xF) << 6 416 | s7 = ((flags >> 5) & 0x3) << 7 417 | s8 = ((flags >> 4) & 0x1) << 8 418 | ss = (flags >> 0) & 0xF 419 | baseSize = 0x200 << ss 420 | size = baseSize * (s0 + s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8) 421 | return size 422 | 423 | def determine_status(self, size_mb: float): 424 | """ 425 | Determine the status based on size in MB. 426 | 427 | alias: determine_status 428 | parameter: size_mb (float) 429 | """ 430 | if size_mb > 64: 431 | return self._tr.translate("status_oversize") 432 | elif size_mb > 32: 433 | return self._tr.translate("status_critical") 434 | elif size_mb > 16: 435 | return self._tr.translate("status_warning") 436 | else: 437 | return self._tr.translate("status_ok") 438 | 439 | 440 | # ------------------------------------------# 441 | # 3. Stream Duplicate Checker Logic 442 | # ------------------------------------------# 443 | class StreamDuplicateChecker: 444 | """ 445 | A class dedicated to scanning and removing duplicate files in 'Stream' folders 446 | (based on filenames). 447 | 448 | alias: StreamDuplicateChecker 449 | parameter: translator (Translator) 450 | """ 451 | def __init__(self, translator: Translator): 452 | self._tr = translator 453 | self.duplicate_files = {} 454 | 455 | def scan_stream_duplicates(self, stream_root_directory: str): 456 | """ 457 | Scan 'stream_root_directory' for all 'stream' folders and gather all files. 458 | Then determine duplicates based on filename alone. 459 | Return: dictionary of duplicates. 460 | 461 | alias: scan_stream_duplicates 462 | parameter: stream_root_directory (str) 463 | """ 464 | stream_files = self.find_stream_files(stream_root_directory) 465 | file_dict = {} 466 | for file in stream_files: 467 | filename = os.path.basename(file) 468 | if filename not in file_dict: 469 | file_dict[filename] = [os.path.dirname(file)] 470 | else: 471 | file_dict[filename].append(os.path.dirname(file)) 472 | 473 | duplicates = {k: v for k, v in file_dict.items() if len(v) > 1} 474 | self.duplicate_files = duplicates 475 | return duplicates 476 | 477 | def find_stream_files(self, root_dir: str): 478 | """ 479 | Recursively find all files in any 'stream' folders under root_dir. 480 | Return: List of file paths. 481 | 482 | alias: find_stream_files 483 | parameter: root_dir (str) 484 | """ 485 | stream_files = [] 486 | root = Path(root_dir) 487 | for stream_dir in root.rglob('stream'): 488 | if stream_dir.is_dir(): 489 | for file in stream_dir.glob('**/*'): 490 | if file.is_file(): 491 | stream_files.append(str(file)) 492 | return stream_files 493 | 494 | # ----------------------------------------# 495 | # 4. GUI and Main Controller 496 | # ----------------------------------------# 497 | class GUI_MAIN: 498 | """ 499 | A class responsible for managing the entire Tkinter GUI, 500 | combining YftCleaner and StreamDuplicateChecker objects 501 | and handling user interactions. 502 | 503 | alias: GUI_MAIN 504 | parameter: root (Tk) 505 | """ 506 | def __init__(self, root): 507 | self.root = root 508 | self.root.title("Stream files assistant by pgonintwitch") 509 | self.root.geometry("1150x800") 510 | self.root.resizable(True, True) 511 | 512 | # Default language 513 | self.translator = Translator(language_code="en") 514 | self.current_language = "en" 515 | 516 | # Margin-related variables (checkbox + entry for KB) 517 | self.enable_margin_var = tk.BooleanVar(value=False) # Whether margin is enabled 518 | self.size_margin_kb_var = tk.StringVar(value="0.0") # Margin in KB as string 519 | 520 | # Will be created after user hits 'Start Scan' 521 | self.yft_cleaner = None 522 | self.stream_checker = None 523 | 524 | self.root_directory = tk.StringVar() 525 | self.stream_root_directory = tk.StringVar() 526 | self.total_files = 0 527 | self.processed_files = 0 528 | self.total_stream_files = 0 529 | self.processed_stream_files = 0 530 | self.sort_column = None 531 | self.sort_reverse = False 532 | self.right_clicked_row = None 533 | 534 | # Build UI 535 | self.setup_ui() 536 | 537 | # ------------------------------# 538 | # Helper & Translation 539 | # ------------------------------# 540 | def translate(self, text_id, *args): 541 | """ 542 | Shortcut to use the translator object. 543 | 544 | alias: translate 545 | parameter: text_id (str) 546 | parameter: *args 547 | """ 548 | return self.translator.translate(text_id, *args) 549 | 550 | def set_language(self, lang_code: str): 551 | """ 552 | Set the current language and update translator. 553 | 554 | alias: set_language 555 | parameter: lang_code (str) 556 | """ 557 | self.current_language = lang_code 558 | self.translator.set_language(lang_code) 559 | 560 | # ------------------------------# 561 | # UI Setup 562 | # ------------------------------# 563 | def setup_ui(self): 564 | style = ttk.Style(self.root) 565 | style.theme_use("clam") 566 | style.configure("Treeview", rowheight=25) 567 | style.configure("Treeview.Heading", font=('Calibri', 12, 'bold')) 568 | style.configure("TButton", padding=6, font=('Calibri', 10)) 569 | style.configure("TLabel", font=('Calibri', 10)) 570 | style.configure("TCombobox", font=('Calibri', 10)) 571 | 572 | # Notebook 573 | self.notebook = ttk.Notebook(self.root) 574 | self.notebook.pack(fill=tk.BOTH, expand=True) 575 | 576 | # Tab 1: YFT Cleaner 577 | self.tab_yft = ttk.Frame(self.notebook) 578 | self.notebook.add(self.tab_yft, text=self.translate("title")) 579 | self.setup_yft_tab() 580 | 581 | # Tab 2: Stream Duplicate Checker 582 | self.tab_stream = ttk.Frame(self.notebook) 583 | self.notebook.add(self.tab_stream, text=self.translate("stream_tab")) 584 | self.setup_stream_tab() 585 | 586 | # Status bar 587 | self.status = tk.StringVar() 588 | self.status.set(self.translate("status_ready")) 589 | lbl_status = ttk.Label(self.root, textvariable=self.status, relief=tk.SUNKEN, anchor="w") 590 | lbl_status.pack(fill=tk.X, side=tk.BOTTOM) 591 | 592 | # Right-click menus 593 | self.yft_context_menu = tk.Menu(self.root, tearoff=0) 594 | self.yft_context_menu.add_command(label=self.translate("view_folder"), command=self.view_folder) 595 | 596 | self.stream_context_menu = tk.Menu(self.root, tearoff=0) 597 | 598 | def setup_yft_tab(self): 599 | frame_top = ttk.Frame(self.tab_yft, padding=10) 600 | frame_top.pack(fill=tk.X) 601 | 602 | lbl_dir = ttk.Label(frame_top, text=self.translate("root_dir_label")) 603 | lbl_dir.grid(row=0, column=0, sticky="w", padx=(0, 5)) 604 | 605 | entry_dir = ttk.Entry(frame_top, textvariable=self.root_directory, width=60) 606 | entry_dir.grid(row=0, column=1, sticky="w", padx=(0, 5)) 607 | 608 | btn_browse = ttk.Button(frame_top, text=self.translate("browse_button"), command=self.browse_directory) 609 | btn_browse.grid(row=0, column=2, sticky="w") 610 | 611 | # Language switch 612 | lbl_language = ttk.Label(frame_top, text=self.translate("language_label")) 613 | lbl_language.grid(row=0, column=3, sticky="w", padx=(20, 5)) 614 | 615 | self.language_var = tk.StringVar(value="en") 616 | cmb_language = ttk.Combobox( 617 | frame_top, textvariable=self.language_var, state="readonly", 618 | values=list(self.translator.translations.keys()), width=10 619 | ) 620 | cmb_language.grid(row=0, column=4, sticky="w") 621 | cmb_language.bind("<>", self.change_language) 622 | 623 | # ------------------------------ 624 | # Margin UI 625 | # ------------------------------ 626 | frame_margin = ttk.Frame(self.tab_yft, padding=(0, 5)) 627 | frame_margin.pack(fill=tk.X) 628 | 629 | check_margin = ttk.Checkbutton( 630 | frame_margin, 631 | text="Enable size margin (KB)", 632 | variable=self.enable_margin_var 633 | ) 634 | check_margin.grid(row=0, column=0, sticky="w") 635 | 636 | entry_margin_kb = ttk.Entry(frame_margin, textvariable=self.size_margin_kb_var, width=10) 637 | entry_margin_kb.grid(row=0, column=1, padx=(5, 0), sticky="w") 638 | # ------------------------------ 639 | 640 | frame_scan = ttk.Frame(self.tab_yft, padding=10) 641 | frame_scan.pack(fill=tk.X) 642 | 643 | btn_scan = ttk.Button(frame_scan, text=self.translate("scan_button"), command=self.start_scan) 644 | btn_scan.grid(row=0, column=0, sticky="w") 645 | 646 | self.progress = ttk.Progressbar(frame_scan, orient="horizontal", length=500, mode="determinate") 647 | self.progress.grid(row=0, column=1, padx=10, sticky="w") 648 | 649 | self.lbl_progress = ttk.Label(frame_scan, text=self.translate("progress_label", 0, 0)) 650 | self.lbl_progress.grid(row=0, column=2, sticky="w") 651 | 652 | frame_select_all = ttk.Frame(self.tab_yft, padding=(10, 0)) 653 | frame_select_all.pack(fill=tk.X) 654 | btn_select_all = ttk.Button(frame_select_all, text=self.translate("select_all_button"), command=self.select_all_yft) 655 | btn_select_all.pack(anchor='w') 656 | 657 | frame_list = ttk.Frame(self.tab_yft, padding=10) 658 | frame_list.pack(fill=tk.BOTH, expand=True) 659 | 660 | # Scrollbar for YFT list 661 | scrollbar_yft = ttk.Scrollbar(frame_list, orient=tk.VERTICAL) 662 | scrollbar_yft.pack(side=tk.RIGHT, fill=tk.Y) 663 | 664 | columns = ("select", "model_name", "path", "size", "status") 665 | self.tree = ttk.Treeview(frame_list, columns=columns, show="headings", selectmode="none") 666 | self.tree.heading("select", text=self.translate("select_column"), command=lambda: self.sort_tree("select")) 667 | self.tree.heading("model_name", text=self.translate("model_name_column"), command=lambda: self.sort_tree("model_name")) 668 | self.tree.heading("path", text=self.translate("path_column"), command=lambda: self.sort_tree("path")) 669 | self.tree.heading("size", text=self.translate("size_column"), command=lambda: self.sort_tree("size")) 670 | self.tree.heading("status", text=self.translate("status_column"), command=lambda: self.sort_tree("status")) 671 | 672 | self.tree.column("select", width=80, anchor="center") 673 | self.tree.column("model_name", width=200, anchor="w") 674 | self.tree.column("path", width=400, anchor="w") 675 | self.tree.column("size", width=150, anchor="center") 676 | self.tree.column("status", width=200, anchor="center") 677 | 678 | self.tree.configure(yscrollcommand=scrollbar_yft.set) 679 | scrollbar_yft.config(command=self.tree.yview) 680 | 681 | self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 682 | self.tree.bind('', self.handle_click_yft) 683 | self.tree.bind('', self.show_yft_context_menu) 684 | 685 | frame_actions = ttk.Frame(self.tab_yft, padding=10) 686 | frame_actions.pack(fill=tk.X) 687 | btn_copy = ttk.Button(frame_actions, text=self.translate("copy_clipboard_button"), command=self.copy_to_clipboard_yft) 688 | btn_copy.pack(side=tk.LEFT, padx=5) 689 | btn_save = ttk.Button(frame_actions, text=self.translate("save_file_button"), command=self.save_to_file_yft) 690 | btn_save.pack(side=tk.LEFT, padx=5) 691 | btn_delete = ttk.Button(frame_actions, text=self.translate("delete_files_button"), command=self.delete_selected_files_yft) 692 | btn_delete.pack(side=tk.LEFT, padx=5) 693 | 694 | def setup_stream_tab(self): 695 | frame_top = ttk.Frame(self.tab_stream, padding=10) 696 | frame_top.pack(fill=tk.X) 697 | 698 | lbl_dir = ttk.Label(frame_top, text=self.translate("stream_root_dir_label")) 699 | lbl_dir.grid(row=0, column=0, sticky="w", padx=(0, 5)) 700 | 701 | entry_dir = ttk.Entry(frame_top, textvariable=self.stream_root_directory, width=60) 702 | entry_dir.grid(row=0, column=1, sticky="w", padx=(0, 5)) 703 | 704 | btn_browse = ttk.Button(frame_top, text=self.translate("stream_browse_button"), command=self.browse_stream_directory) 705 | btn_browse.grid(row=0, column=2, sticky="w") 706 | 707 | frame_scan = ttk.Frame(self.tab_stream, padding=10) 708 | frame_scan.pack(fill=tk.X) 709 | 710 | btn_scan = ttk.Button(frame_scan, text=self.translate("stream_scan_button"), command=self.start_stream_scan) 711 | btn_scan.grid(row=0, column=0, sticky="w") 712 | 713 | self.stream_progress = ttk.Progressbar(frame_scan, orient="horizontal", length=500, mode="determinate") 714 | self.stream_progress.grid(row=0, column=1, padx=10, sticky="w") 715 | 716 | self.stream_lbl_progress = ttk.Label(frame_scan, text=self.translate("stream_progress_label", 0, 0)) 717 | self.stream_lbl_progress.grid(row=0, column=2, sticky="w") 718 | 719 | frame_select_all = ttk.Frame(self.tab_stream, padding=(10, 0)) 720 | frame_select_all.pack(fill=tk.X) 721 | btn_select_all_stream = ttk.Button(frame_select_all, text=self.translate("stream_select_all_button"), command=self.select_all_stream) 722 | btn_select_all_stream.pack(anchor='w') 723 | 724 | frame_list = ttk.Frame(self.tab_stream, padding=10) 725 | frame_list.pack(fill=tk.BOTH, expand=True) 726 | 727 | # Scrollbar for stream list 728 | scrollbar_stream = ttk.Scrollbar(frame_list, orient=tk.VERTICAL) 729 | scrollbar_stream.pack(side=tk.RIGHT, fill=tk.Y) 730 | 731 | stream_columns = ("select", "duplicate_file", "locations") 732 | self.stream_tree = ttk.Treeview(frame_list, columns=stream_columns, show="headings", selectmode="none") 733 | self.stream_tree.heading("select", text=self.translate("stream_select_column"), command=lambda: self.sort_stream_tree("select")) 734 | self.stream_tree.heading("duplicate_file", text=self.translate("stream_duplicate_file_column"), command=lambda: self.sort_stream_tree("duplicate_file")) 735 | self.stream_tree.heading("locations", text=self.translate("stream_locations_column"), command=lambda: self.sort_stream_tree("locations")) 736 | 737 | self.stream_tree.column("select", width=80, anchor="center") 738 | self.stream_tree.column("duplicate_file", width=300, anchor="w") 739 | self.stream_tree.column("locations", width=700, anchor="w") 740 | 741 | self.stream_tree.configure(yscrollcommand=scrollbar_stream.set) 742 | scrollbar_stream.config(command=self.stream_tree.yview) 743 | 744 | self.stream_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) 745 | self.stream_tree.bind('', self.handle_click_stream) 746 | self.stream_tree.bind('', self.show_stream_context_menu) 747 | 748 | frame_actions = ttk.Frame(self.tab_stream, padding=10) 749 | frame_actions.pack(fill=tk.X) 750 | btn_copy = ttk.Button(frame_actions, text=self.translate("stream_copy_clipboard_button"), command=self.copy_stream_to_clipboard) 751 | btn_copy.pack(side=tk.LEFT, padx=5) 752 | btn_save = ttk.Button(frame_actions, text=self.translate("stream_save_file_button"), command=self.save_stream_to_file) 753 | btn_save.pack(side=tk.LEFT, padx=5) 754 | 755 | frame_manual = ttk.Frame(self.tab_stream, padding=10) 756 | frame_manual.pack(fill=tk.BOTH, expand=True) 757 | 758 | btn_manual_check = ttk.Button(frame_manual, text=self.translate("manual_check_button"), command=self.check_manual_duplicates) 759 | btn_manual_check.pack(anchor="e", pady=5) 760 | 761 | self.txt_manual = tk.Text(frame_manual, height=10) 762 | self.txt_manual.pack(fill=tk.BOTH, expand=True) 763 | 764 | # --------------------------------------# 765 | # YFT Cleaner Events 766 | # --------------------------------------# 767 | def browse_directory(self): 768 | """ 769 | Browse for a directory to set the YFT root path. 770 | 771 | alias: browse_directory 772 | """ 773 | directory = filedialog.askdirectory() 774 | if directory: 775 | self.root_directory.set(directory) 776 | 777 | def start_scan(self): 778 | """ 779 | Start scanning for *_hi.yft files. 780 | 781 | alias: start_scan 782 | """ 783 | if self.enable_margin_var.get(): 784 | try: 785 | margin_kb = float(self.size_margin_kb_var.get()) 786 | except ValueError: 787 | margin_kb = 0.0 788 | else: 789 | margin_kb = 0.0 790 | 791 | self.yft_cleaner = YftCleaner(self.translator, size_margin_kb=margin_kb) 792 | 793 | if not self.root_directory.get(): 794 | messagebox.showwarning("Warning", self.translate("info_no_selected")) 795 | return 796 | if not os.path.isdir(self.root_directory.get()): 797 | messagebox.showerror("Error", self.translate("info_no_files")) 798 | return 799 | 800 | for item in self.tree.get_children(): 801 | self.tree.delete(item) 802 | 803 | self.yft_cleaner.deletable_files.clear() 804 | self.total_files = 0 805 | self.processed_files = 0 806 | self.progress["value"] = 0 807 | self.lbl_progress.config(text=self.translate("progress_label", 0, 0)) 808 | self.status.set(self.translate("status_scanning")) 809 | 810 | threading.Thread(target=self.scan_files_thread, daemon=True).start() 811 | 812 | def scan_files_thread(self): 813 | """ 814 | Worker thread to scan files in the background. 815 | 816 | alias: scan_files_thread 817 | """ 818 | try: 819 | hi_yft_files = self.yft_cleaner.find_hi_yft_files(self.root_directory.get()) 820 | unique_hi_yft_files = list(set(hi_yft_files)) 821 | self.total_files = len(unique_hi_yft_files) 822 | 823 | results = [] 824 | with ThreadPoolExecutor(max_workers=os.cpu_count() or 4) as executor: 825 | future_map = {executor.submit(self.yft_cleaner.process_file, f): f for f in unique_hi_yft_files} 826 | for idx, future in enumerate(as_completed(future_map), 1): 827 | r = future.result() 828 | if r: 829 | results.append(r) 830 | self.processed_files = idx 831 | self.update_progress() 832 | 833 | self.yft_cleaner.deletable_files = results 834 | self.populate_treeview_yft(results) 835 | self.status.set(self.translate("status_completed")) 836 | except Exception as e: 837 | self.status.set(f"{self.translate('status_error')} {e}") 838 | 839 | def update_progress(self): 840 | """ 841 | Update the progress bar and label for YFT scanning. 842 | 843 | alias: update_progress 844 | """ 845 | if self.total_files > 0: 846 | progress_percent = (self.processed_files / self.total_files) * 100 847 | self.progress["value"] = progress_percent 848 | self.lbl_progress.config(text=self.translate("progress_label", self.processed_files, self.total_files)) 849 | self.root.update_idletasks() 850 | 851 | def populate_treeview_yft(self, file_info_list): 852 | """ 853 | Populate the YFT TreeView with the scanned file information. 854 | 855 | alias: populate_treeview_yft 856 | parameter: file_info_list (List[Tuple]) 857 | """ 858 | root_dir = self.root_directory.get() 859 | for file_path, size_str, status in file_info_list: 860 | model_name = os.path.basename(file_path) 861 | dir_path = os.path.dirname(file_path) 862 | try: 863 | relative_path = os.path.relpath(dir_path, root_dir) 864 | except ValueError: 865 | relative_path = dir_path 866 | 867 | item_id = self.tree.insert("", tk.END, values=("☐", model_name, relative_path, size_str, status)) 868 | if status.startswith(self.translate("status_ok")): 869 | self.tree.item(item_id, tags=("ok",)) 870 | self.tree.tag_configure("ok", background="lightgreen") 871 | elif status.startswith(self.translate("status_warning")): 872 | self.tree.item(item_id, tags=("warning",)) 873 | self.tree.tag_configure("warning", background="yellow") 874 | elif status.startswith(self.translate("status_critical")): 875 | self.tree.item(item_id, tags=("critical",)) 876 | self.tree.tag_configure("critical", background="red") 877 | elif status.startswith(self.translate("status_oversize")): 878 | self.tree.item(item_id, tags=("oversize",)) 879 | self.tree.tag_configure("oversize", background="orange") 880 | else: 881 | self.tree.tag_configure("default", background="white") 882 | self.tree.item(item_id, tags=("default",)) 883 | 884 | def handle_click_yft(self, event): 885 | """ 886 | 處理 YFT TreeView 點選事件,用於切換選取框 (checkbox) 狀態。 887 | 888 | alias: handle_click_yft 889 | parameter: event (Event) 890 | """ 891 | region = self.tree.identify("region", event.x, event.y) 892 | if region != "cell": 893 | return 894 | column = self.tree.identify_column(event.x) 895 | if column == "#1": 896 | row_id = self.tree.identify_row(event.y) 897 | if row_id: 898 | current_value = self.tree.set(row_id, "select") 899 | new_value = "☑" if current_value == "☐" else "☐" 900 | self.tree.set(row_id, "select", new_value) 901 | 902 | def show_yft_context_menu(self, event): 903 | """ 904 | 顯示 YFT 項目的右鍵選單。 905 | 906 | alias: show_yft_context_menu 907 | parameter: event (Event) 908 | """ 909 | row_id = self.tree.identify_row(event.y) 910 | if row_id: 911 | self.tree.selection_set(row_id) 912 | self.right_clicked_row = row_id 913 | self.yft_context_menu.post(event.x_root, event.y_root) 914 | else: 915 | self.right_clicked_row = None 916 | 917 | def view_folder(self): 918 | """ 919 | 在系統檔案總管中打開所選項目的資料夾。 920 | 921 | alias: view_folder 922 | """ 923 | if self.right_clicked_row: 924 | path_val = self.tree.set(self.right_clicked_row, "path") 925 | model_name = self.tree.set(self.right_clicked_row, "model_name") 926 | full_path = os.path.join(self.root_directory.get(), path_val, model_name) 927 | folder_path = os.path.dirname(full_path) 928 | else: 929 | selected = self.get_selected_files_yft() 930 | if not selected: 931 | messagebox.showinfo("Info", self.translate("info_no_selected")) 932 | return 933 | folder_path = os.path.dirname(selected[0]) 934 | 935 | try: 936 | if os.name == 'nt': 937 | os.startfile(folder_path) 938 | else: 939 | messagebox.showerror("Error", "Unsupported OS.") 940 | except Exception as e: 941 | messagebox.showerror("Error", f"{self.translate('status_error')} {e}") 942 | 943 | def get_selected_files_yft(self): 944 | """ 945 | 取得 YFT TreeView 中被選取 (☑) 的檔案列表。 946 | 947 | alias: get_selected_files_yft 948 | """ 949 | selected_files = [] 950 | for item in self.tree.get_children(): 951 | if self.tree.set(item, "select") == "☐": 952 | continue 953 | model_name = self.tree.set(item, "model_name") 954 | path_val = self.tree.set(item, "path") 955 | full_path = os.path.join(self.root_directory.get(), path_val, model_name) 956 | selected_files.append(full_path) 957 | return selected_files 958 | 959 | def copy_to_clipboard_yft(self): 960 | """ 961 | 將選中的 YFT 檔案路徑複製到剪貼簿。 962 | 963 | alias: copy_to_clipboard_yft 964 | """ 965 | selected = self.get_selected_files_yft() 966 | if not selected: 967 | messagebox.showinfo("Info", self.translate("info_no_selected")) 968 | return 969 | try: 970 | pyperclip.copy('\n'.join(selected)) 971 | messagebox.showinfo("Success", self.translate("info_copy_success")) 972 | except pyperclip.PyperclipException as e: 973 | messagebox.showerror("Error", f"{self.translate('status_error')} {e}") 974 | 975 | def save_to_file_yft(self): 976 | """ 977 | 將選中的 YFT 檔案路徑儲存到文字檔。 978 | 979 | alias: save_to_file_yft 980 | """ 981 | selected = self.get_selected_files_yft() 982 | if not selected: 983 | messagebox.showinfo("Info", self.translate("info_no_selected")) 984 | return 985 | file_path = filedialog.asksaveasfilename( 986 | defaultextension=".txt", 987 | filetypes=[("Text files", "*.txt"), ("All files", "*.*")], 988 | title=self.translate("save_file_button") 989 | ) 990 | if file_path: 991 | try: 992 | with open(file_path, 'w', encoding='utf-8') as f: 993 | f.write('\n'.join(selected)) 994 | messagebox.showinfo("Success", self.translate("info_save_success", file_path)) 995 | except Exception as e: 996 | messagebox.showerror("Error", f"{self.translate('status_error')} {e}") 997 | 998 | def delete_selected_files_yft(self): 999 | """ 1000 | 刪除 YFT TreeView 中選取的檔案 (對磁碟進行刪除)。 1001 | 1002 | alias: delete_selected_files_yft 1003 | """ 1004 | selected_files = self.get_selected_files_yft() 1005 | if not selected_files: 1006 | messagebox.showinfo("Info", self.translate("info_no_selected")) 1007 | return 1008 | 1009 | confirm = messagebox.askyesno(self.translate("confirm_delete_title"), self.translate("confirm_delete_message", len(selected_files))) 1010 | if not confirm: 1011 | return 1012 | 1013 | deleted = [] 1014 | failed = [] 1015 | for fp in selected_files: 1016 | try: 1017 | os.remove(fp) 1018 | deleted.append(fp) 1019 | for item in self.tree.get_children(): 1020 | model_name = self.tree.set(item, "model_name") 1021 | path_val = self.tree.set(item, "path") 1022 | full_check = os.path.join(self.root_directory.get(), path_val, model_name) 1023 | if full_check == fp: 1024 | self.tree.delete(item) 1025 | break 1026 | except Exception as e: 1027 | failed.append((fp, str(e))) 1028 | 1029 | if deleted: 1030 | messagebox.showinfo("Success", self.translate("success_delete", len(deleted))) 1031 | self.yft_cleaner.deletable_files = [df for df in self.yft_cleaner.deletable_files if df[0] not in deleted] 1032 | 1033 | if failed: 1034 | err_msg = "\n".join([f"{p}: {msg}" for p, msg in failed]) 1035 | messagebox.showerror("Error", self.translate("error_delete", err_msg)) 1036 | 1037 | self.status.set(self.translate("status_completed")) 1038 | 1039 | def select_all_yft(self): 1040 | """ 1041 | 將 YFT TreeView 中所有項目選取 (☑)。 1042 | 1043 | alias: select_all_yft 1044 | """ 1045 | for item in self.tree.get_children(): 1046 | self.tree.set(item, "select", "☑") 1047 | 1048 | def sort_tree(self, column): 1049 | """ 1050 | 根據指定欄位排序 YFT TreeView。 1051 | 1052 | alias: sort_tree 1053 | parameter: column (str) 1054 | """ 1055 | if self.sort_column == column: 1056 | self.sort_reverse = not self.sort_reverse 1057 | else: 1058 | self.sort_reverse = False 1059 | self.sort_column = column 1060 | 1061 | def sort_key(item_id): 1062 | value = self.tree.set(item_id, column) 1063 | if column == "size": 1064 | try: 1065 | if '/' in value: 1066 | parts = value.split('/') 1067 | return max(float(parts[0].split(':')[1]), float(parts[1].split(':')[1])) 1068 | else: 1069 | return float(value.split(' ')[0]) 1070 | except: 1071 | return 0.0 1072 | return value.lower() 1073 | 1074 | sorted_items = sorted(self.tree.get_children(), key=sort_key, reverse=self.sort_reverse) 1075 | for idx, item_id in enumerate(sorted_items): 1076 | self.tree.move(item_id, '', idx) 1077 | 1078 | # ------------------------------------# 1079 | # Stream Duplicate Checker Events 1080 | # ------------------------------------# 1081 | def browse_stream_directory(self): 1082 | """ 1083 | 瀏覽設定 Stream 根目錄。 1084 | 1085 | alias: browse_stream_directory 1086 | """ 1087 | directory = filedialog.askdirectory() 1088 | if directory: 1089 | self.stream_root_directory.set(directory) 1090 | 1091 | def start_stream_scan(self): 1092 | """ 1093 | 開始掃描 'stream' 資料夾中重複的檔案。 1094 | 1095 | alias: start_stream_scan 1096 | """ 1097 | if not self.stream_root_directory.get(): 1098 | messagebox.showwarning("Warning", self.translate("stream_info_no_selected")) 1099 | return 1100 | if not os.path.isdir(self.stream_root_directory.get()): 1101 | messagebox.showerror("Error", self.translate("stream_info_no_duplicates")) 1102 | return 1103 | 1104 | for item in self.stream_tree.get_children(): 1105 | self.stream_tree.delete(item) 1106 | if not self.stream_checker: 1107 | self.stream_checker = StreamDuplicateChecker(self.translator) 1108 | 1109 | self.stream_checker.duplicate_files.clear() 1110 | self.total_stream_files = 0 1111 | self.processed_stream_files = 0 1112 | self.stream_progress["value"] = 0 1113 | self.stream_lbl_progress.config(text=self.translate("stream_progress_label", 0, 0)) 1114 | self.status.set(self.translate("stream_status_scanning")) 1115 | 1116 | threading.Thread(target=self.scan_stream_thread, daemon=True).start() 1117 | 1118 | def scan_stream_thread(self): 1119 | """ 1120 | 掃描 Stream 資料夾重複檔案的背景執行緒。 1121 | 1122 | alias: scan_stream_thread 1123 | """ 1124 | try: 1125 | all_files = self.stream_checker.find_stream_files(self.stream_root_directory.get()) 1126 | self.total_stream_files = len(all_files) 1127 | duplicates = self.stream_checker.scan_stream_duplicates(self.stream_root_directory.get()) 1128 | self.processed_stream_files = self.total_stream_files 1129 | self.update_stream_progress() 1130 | if duplicates: 1131 | self.populate_stream_treeview(duplicates) 1132 | self.status.set(self.translate("stream_status_completed")) 1133 | else: 1134 | self.status.set(self.translate("stream_info_no_duplicates")) 1135 | except Exception as e: 1136 | self.status.set(f"{self.translate('stream_status_error')} {e}") 1137 | 1138 | def update_stream_progress(self): 1139 | """ 1140 | 更新 Stream 掃描的進度條與進度文字。 1141 | 1142 | alias: update_stream_progress 1143 | """ 1144 | if self.total_stream_files > 0: 1145 | p = (self.processed_stream_files / self.total_stream_files) * 100 1146 | self.stream_progress["value"] = p 1147 | self.stream_lbl_progress.config(text=self.translate("stream_progress_label", self.processed_stream_files, self.total_stream_files)) 1148 | self.root.update_idletasks() 1149 | 1150 | def populate_stream_treeview(self, duplicates): 1151 | """ 1152 | 將重複檔案資訊填入 Stream TreeView。 1153 | 1154 | alias: populate_stream_treeview 1155 | parameter: duplicates (dict) 1156 | """ 1157 | stream_root = self.stream_root_directory.get() 1158 | for file_name, locations in duplicates.items(): 1159 | relative_locations = [] 1160 | for loc in locations: 1161 | try: 1162 | rel_loc = os.path.relpath(loc, stream_root) 1163 | except ValueError: 1164 | rel_loc = loc 1165 | relative_locations.append(rel_loc) 1166 | loc_str = '; '.join(relative_locations) 1167 | item_id = self.stream_tree.insert("", tk.END, values=("☐", file_name, loc_str)) 1168 | self.stream_tree.tag_configure("duplicate", background="lightcoral") 1169 | self.stream_tree.item(item_id, tags=("duplicate",)) 1170 | 1171 | def handle_click_stream(self, event): 1172 | """ 1173 | 處理 Stream TreeView 點選事件,切換選取框狀態。 1174 | 1175 | alias: handle_click_stream 1176 | parameter: event (Event) 1177 | """ 1178 | region = self.stream_tree.identify("region", event.x, event.y) 1179 | if region != "cell": 1180 | return 1181 | column = self.stream_tree.identify_column(event.x) 1182 | if column == "#1": 1183 | row_id = self.stream_tree.identify_row(event.y) 1184 | if row_id: 1185 | current_value = self.stream_tree.set(row_id, "select") 1186 | new_value = "☑" if current_value == "☐" else "☐" 1187 | self.stream_tree.set(row_id, "select", new_value) 1188 | 1189 | def show_stream_context_menu(self, event): 1190 | """ 1191 | 顯示 Stream 項目的右鍵選單。 1192 | 1193 | alias: show_stream_context_menu 1194 | parameter: event (Event) 1195 | """ 1196 | row_id = self.stream_tree.identify_row(event.y) 1197 | if row_id: 1198 | self.stream_tree.selection_set(row_id) 1199 | self.right_clicked_row = row_id 1200 | self.stream_context_menu.delete(0, tk.END) 1201 | duplicate_file = self.stream_tree.set(row_id, "duplicate_file") 1202 | locations_str = self.stream_tree.set(row_id, "locations") 1203 | locations = locations_str.split('; ') 1204 | self.stream_context_menu.add_command( 1205 | label="📂 " + self.translate("view_folder"), 1206 | command=lambda: self.open_folder_for_stream(duplicate_file, locations[0] if locations else "") 1207 | ) 1208 | self.stream_context_menu.add_separator() 1209 | for loc in locations: 1210 | full_path = os.path.join(self.stream_root_directory.get(), loc, duplicate_file) 1211 | self.stream_context_menu.add_command( 1212 | label=f"🔍 {loc}", 1213 | command=lambda path=full_path: self.open_folder_for_stream_file(path) 1214 | ) 1215 | self.stream_context_menu.add_command( 1216 | label=f"🗑️ Delete {loc}", 1217 | command=lambda path=full_path: self.delete_stream_file(path) 1218 | ) 1219 | self.stream_context_menu.add_separator() 1220 | self.stream_context_menu.add_command( 1221 | label="❌ Delete All Duplicates", 1222 | command=lambda: self.delete_all_stream_duplicates(duplicate_file, locations) 1223 | ) 1224 | self.stream_context_menu.post(event.x_root, event.y_root) 1225 | else: 1226 | self.right_clicked_row = None 1227 | 1228 | def open_folder_for_stream(self, duplicate_file, loc): 1229 | """ 1230 | 開啟指定 stream 檔案所在的資料夾。 1231 | 1232 | alias: open_folder_for_stream 1233 | parameter: duplicate_file (str) 1234 | parameter: loc (str) 1235 | """ 1236 | path = os.path.join(self.stream_root_directory.get(), loc, duplicate_file) 1237 | self.open_folder_for_stream_file(path) 1238 | 1239 | def open_folder_for_stream_file(self, file_path): 1240 | """ 1241 | 在系統檔案總管中開啟所給檔案所在的資料夾。 1242 | 1243 | alias: open_folder_for_stream_file 1244 | parameter: file_path (str) 1245 | """ 1246 | folder_path = os.path.dirname(file_path) 1247 | try: 1248 | if os.name == 'nt': 1249 | os.startfile(folder_path) 1250 | else: 1251 | messagebox.showerror("Error", "Unsupported OS.") 1252 | except Exception as e: 1253 | messagebox.showerror("Error", f"{self.translate('stream_status_error')} {e}") 1254 | 1255 | def delete_stream_file(self, file_path): 1256 | """ 1257 | 刪除單一 stream 檔案,並提示確認。 1258 | 1259 | alias: delete_stream_file 1260 | parameter: file_path (str) 1261 | """ 1262 | confirm = messagebox.askyesno(self.translate("stream_confirm_delete_title"), f"{self.translate('stream_confirm_delete_message')}\n{file_path}") 1263 | if not confirm: 1264 | return 1265 | try: 1266 | os.remove(file_path) 1267 | messagebox.showinfo("Success", self.translate("stream_success_delete", 1)) 1268 | self.update_stream_tree_after_delete(file_path) 1269 | except Exception as e: 1270 | messagebox.showerror("Error", f"{self.translate('stream_error_delete').format(file_path)}\n{e}") 1271 | 1272 | def update_stream_tree_after_delete(self, file_path): 1273 | """ 1274 | Update the stream TreeView after deleting a file. 1275 | 1276 | alias: update_stream_tree_after_delete 1277 | parameter: file_path (str) 1278 | """ 1279 | basename = os.path.basename(file_path) 1280 | rel_loc = os.path.relpath(os.path.dirname(file_path), self.stream_root_directory.get()) 1281 | for item in self.stream_tree.get_children(): 1282 | if self.stream_tree.set(item, "duplicate_file") == basename: 1283 | locations_str = self.stream_tree.set(item, "locations") 1284 | locations = locations_str.split('; ') 1285 | if rel_loc in locations: 1286 | locations.remove(rel_loc) 1287 | if len(locations) <= 1: 1288 | self.stream_tree.delete(item) 1289 | self.stream_checker.duplicate_files.pop(basename, None) 1290 | else: 1291 | new_loc_str = '; '.join(locations) 1292 | self.stream_tree.set(item, "locations", new_loc_str) 1293 | break 1294 | 1295 | def delete_all_stream_duplicates(self, duplicate_file, locations): 1296 | """ 1297 | Delete all duplicates of a given file at multiple locations. 1298 | 1299 | alias: delete_all_stream_duplicates 1300 | parameter: duplicate_file (str) 1301 | parameter: locations (List[str]) 1302 | """ 1303 | confirm = messagebox.askyesno(self.translate("stream_confirm_delete_title"), f"{self.translate('stream_confirm_delete_message')}\n{duplicate_file}") 1304 | if not confirm: 1305 | return 1306 | deleted = [] 1307 | failed = [] 1308 | for loc in locations: 1309 | full_path = os.path.join(self.stream_root_directory.get(), loc, duplicate_file) 1310 | try: 1311 | os.remove(full_path) 1312 | deleted.append(full_path) 1313 | except Exception as e: 1314 | failed.append((full_path, str(e))) 1315 | if deleted: 1316 | messagebox.showinfo("Success", self.translate("stream_success_delete", len(deleted))) 1317 | for item in self.stream_tree.get_children(): 1318 | if self.stream_tree.set(item, "duplicate_file") == duplicate_file: 1319 | self.stream_tree.delete(item) 1320 | break 1321 | self.stream_checker.duplicate_files.pop(duplicate_file, None) 1322 | if failed: 1323 | err_msg = "\n".join([f"{p}: {msg}" for p, msg in failed]) 1324 | messagebox.showerror("Error", self.translate("stream_error_delete", err_msg)) 1325 | self.status.set(self.translate("stream_status_completed")) 1326 | 1327 | def copy_stream_to_clipboard(self): 1328 | """ 1329 | Copy the list of duplicate files to clipboard. 1330 | 1331 | alias: copy_stream_to_clipboard 1332 | """ 1333 | duplicates = self.stream_checker.duplicate_files 1334 | if not duplicates: 1335 | messagebox.showinfo("Info", self.translate("stream_info_no_duplicates")) 1336 | return 1337 | try: 1338 | lines = [] 1339 | for file, locs in duplicates.items(): 1340 | lines.append(f"{file}:") 1341 | for loc in locs: 1342 | lines.append(loc) 1343 | lines.append("") 1344 | pyperclip.copy('\n'.join(lines)) 1345 | messagebox.showinfo("Success", self.translate("stream_info_copy_success")) 1346 | except pyperclip.PyperclipException as e: 1347 | messagebox.showerror("Error", f"{self.translate('stream_status_error')} {e}") 1348 | 1349 | def save_stream_to_file(self): 1350 | """ 1351 | Save the list of duplicate files to a text file. 1352 | 1353 | alias: save_stream_to_file 1354 | """ 1355 | duplicates = self.stream_checker.duplicate_files 1356 | if not duplicates: 1357 | messagebox.showinfo("Info", self.translate("stream_info_no_duplicates")) 1358 | return 1359 | file_path = filedialog.asksaveasfilename( 1360 | defaultextension=".txt", 1361 | filetypes=[("Text files", "*.txt"), ("All files", "*.*")], 1362 | title=self.translate("stream_save_file_button") 1363 | ) 1364 | if file_path: 1365 | try: 1366 | with open(file_path, 'w', encoding='utf-8') as f: 1367 | for file, locs in duplicates.items(): 1368 | f.write(f"{file}:\n") 1369 | for loc in locs: 1370 | f.write(f"{loc}\n") 1371 | f.write("\n") 1372 | messagebox.showinfo("Success", self.translate("stream_info_save_success", file_path)) 1373 | except Exception as e: 1374 | messagebox.showerror("Error", f"{self.translate('stream_status_error')} {e}") 1375 | 1376 | def select_all_stream(self): 1377 | """ 1378 | Select (☑) all items in the stream TreeView. 1379 | 1380 | alias: select_all_stream 1381 | """ 1382 | for item in self.stream_tree.get_children(): 1383 | self.stream_tree.set(item, "select", "☑") 1384 | 1385 | def sort_stream_tree(self, column): 1386 | """ 1387 | Sort the stream TreeView by the specified column. 1388 | 1389 | alias: sort_stream_tree 1390 | parameter: column (str) 1391 | """ 1392 | if self.sort_column == column: 1393 | self.sort_reverse = not self.sort_reverse 1394 | else: 1395 | self.sort_reverse = False 1396 | self.sort_column = column 1397 | 1398 | def sort_key(item_id): 1399 | return self.stream_tree.set(item_id, column).lower() 1400 | 1401 | sorted_items = sorted(self.stream_tree.get_children(), key=sort_key, reverse=self.sort_reverse) 1402 | for idx, item_id in enumerate(sorted_items): 1403 | self.stream_tree.move(item_id, '', idx) 1404 | 1405 | def check_manual_duplicates(self): 1406 | """ 1407 | 1. 讀取 txt_manual 中使用者貼上的「檔案清單」。 1408 | 2. 於 stream_root_directory 下搜尋所有檔案, 1409 | 建立「檔名 -> [所有絕對路徑]」的對照資料, 1410 | 再逐一比對使用者貼上的檔案清單: 1411 | - 若存在一個副本則顯示路徑, 1412 | - 若存在多個副本則標示重複並列出所有位置, 1413 | - 若找不到則標示「NOT FOUND」。 1414 | 3. 將結果寫回 txt_manual (Text widget) 中。 1415 | 1416 | alias: check_manual_duplicates 1417 | parameter: None 1418 | """ 1419 | stream_root = self.stream_root_directory.get() 1420 | if not stream_root: 1421 | messagebox.showwarning("Warning", self.translate("stream_info_no_selected")) 1422 | return 1423 | 1424 | if not os.path.isdir(stream_root): 1425 | messagebox.showerror("Error", self.translate("stream_info_no_files")) 1426 | return 1427 | 1428 | if not self.stream_checker: 1429 | self.stream_checker = StreamDuplicateChecker(self.translator) 1430 | 1431 | user_text = self.txt_manual.get("1.0", tk.END) 1432 | file_list = [line.strip() for line in user_text.splitlines() if line.strip()] 1433 | 1434 | self.txt_manual.delete("1.0", tk.END) 1435 | self.txt_manual.insert(tk.END, f"{self.translate('manual_check_results')}\n\n") 1436 | self.root.update_idletasks() 1437 | 1438 | all_stream_files = self.stream_checker.find_stream_files(stream_root) 1439 | 1440 | file_map = {} 1441 | for fp in all_stream_files: 1442 | basename = os.path.basename(fp) 1443 | file_map.setdefault(basename, []).append(fp) 1444 | 1445 | result_lines = [] 1446 | for target_filename in file_list: 1447 | if target_filename in file_map: 1448 | found_locations = file_map[target_filename] 1449 | if len(found_locations) == 1: 1450 | result_lines.append(f"{target_filename} -> {found_locations[0]}") 1451 | else: 1452 | result_lines.append(f"{target_filename} ({self.translate('duplicates_label')})") 1453 | for loc in found_locations: 1454 | result_lines.append(f" {loc}") 1455 | else: 1456 | result_lines.append(f"{target_filename} -> {self.translate('not_found')}") 1457 | 1458 | final_output = "\n".join(result_lines) 1459 | self.txt_manual.insert(tk.END, final_output + "\n") 1460 | 1461 | def _thread_check_manual_duplicates(self): 1462 | try: 1463 | duplicates = self.stream_checker.scan_stream_duplicates(self.stream_root_directory.get()) 1464 | if duplicates: 1465 | lines = [] 1466 | for file, locs in duplicates.items(): 1467 | lines.append(f"{file}:") 1468 | for loc in locs: 1469 | lines.append(f" {loc}") 1470 | lines.append("") 1471 | result_text = "\n".join(lines) 1472 | else: 1473 | result_text = self.translate("stream_info_no_duplicates") 1474 | self.root.after(0, self._update_text_manual, result_text) 1475 | self.root.after(0, lambda: self.status.set(self.translate("stream_status_completed"))) 1476 | except Exception as e: 1477 | self.root.after(0, lambda: messagebox.showerror("Error", f"{self.translate('stream_status_error')} {e}")) 1478 | self.root.after(0, lambda: self.status.set(self.translate("stream_status_error"))) 1479 | 1480 | def _update_text_manual(self, text): 1481 | """ 1482 | alias: _update_text_manual 1483 | parameter: text (str) 1484 | """ 1485 | self.txt_manual.delete("1.0", tk.END) 1486 | self.txt_manual.insert(tk.END, text) 1487 | 1488 | # --------------------# 1489 | # Language Switching 1490 | # --------------------# 1491 | def change_language(self, event=None): 1492 | """ 1493 | Event handler for language combobox selection. 1494 | 1495 | alias: change_language 1496 | parameter: event (Event) 1497 | """ 1498 | lang = self.language_var.get() 1499 | self.set_language(lang) 1500 | # Refresh tab labels 1501 | self.notebook.tab(0, text=self.translate("title")) 1502 | self.notebook.tab(1, text=self.translate("stream_tab")) 1503 | self.status.set(self.translate("status_ready")) 1504 | # For a complete multi-language solution, 1505 | # re-building or dynamically updating all UI text is recommended. 1506 | 1507 | # --------------------# 1508 | # Main Entry 1509 | # --------------------# 1510 | @staticmethod 1511 | def main(): 1512 | """ 1513 | Main entry point to run the Tkinter application. 1514 | 1515 | alias: main 1516 | """ 1517 | root = tk.Tk() 1518 | app = GUI_MAIN(root) 1519 | root.mainloop() 1520 | 1521 | if __name__ == "__main__": 1522 | GUI_MAIN.main() -------------------------------------------------------------------------------- /src/StreamFileAssistant.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | a = Analysis( 5 | ['StreamFileAssistant.py'], 6 | pathex=[], 7 | binaries=[], 8 | datas=[], 9 | hiddenimports=[], 10 | hookspath=[], 11 | hooksconfig={}, 12 | runtime_hooks=[], 13 | excludes=[], 14 | noarchive=False, 15 | optimize=0, 16 | ) 17 | pyz = PYZ(a.pure) 18 | 19 | exe = EXE( 20 | pyz, 21 | a.scripts, 22 | a.binaries, 23 | a.datas, 24 | [], 25 | name='StreamFileAssistant', 26 | debug=False, 27 | bootloader_ignore_signals=False, 28 | strip=False, 29 | upx=True, 30 | upx_exclude=[], 31 | runtime_tmpdir=None, 32 | console=False, 33 | disable_windowed_traceback=False, 34 | argv_emulation=False, 35 | target_arch=None, 36 | codesign_identity=None, 37 | entitlements_file=None, 38 | ) 39 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | pyperclip 2 | --------------------------------------------------------------------------------