├── AliTV-Union.js ├── BatchClearOnedriveRecycleBin-Admin.ps1 ├── BatchDeleteFolder-Onedrive-Admin.ps1 ├── BatchSetOnedriveStorageLimit-Admin.ps1 ├── BatchVideoToMP3.py ├── CheckTgBotHealthyAndRestart.py ├── CleanGITHUBArtifacts.js ├── FixEncoding4AudioMetadata.py ├── GalgameMoveToSubFolder.py ├── ImportMEGALinks.py ├── M365WholeTenantOnedriveToRcloneConf.py ├── ProxyRedirect.py ├── README.md └── RemovePasswordForArchive.py /AliTV-Union.js: -------------------------------------------------------------------------------- 1 | addEventListener('fetch', event => { 2 | event.respondWith(handleRequest(event.request)) 3 | }) 4 | 5 | async function handleRequest(request) { 6 | const url = new URL(request.url) 7 | 8 | if (url.pathname === '/auth') { 9 | return handleAuthRequest() 10 | } else if (url.pathname === '/check-status') { 11 | return handleStatusRequest(url) 12 | } else if (url.pathname === '/token') { 13 | return handleTokenRequest(request) 14 | } else { 15 | return new Response('Not Found', { status: 404 }) 16 | } 17 | } 18 | 19 | async function handleAuthRequest() { 20 | const apiResponse = await fetch('http://api.extscreen.com/aliyundrive/qrcode', { 21 | method: 'POST', 22 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 23 | body: new URLSearchParams({ 24 | 'scopes': 'user:base,file:all:read,file:all:write', 25 | 'width': 500, 26 | 'height': 500 27 | }) 28 | }) 29 | 30 | const data = await apiResponse.json() 31 | const qrData = data.data 32 | const sid = qrData.sid 33 | 34 | const responseHeaders = new Headers() 35 | const qrLink = qrData.qrCodeUrl 36 | const qrID = qrLink.split('/qrcode/')[1] 37 | responseHeaders.set('Content-Type', 'text/html') 38 | responseHeaders.set('Refresh', '0; url=/check-status?sid=' + sid + "&qrid=" + qrID) 39 | responseHeaders.set('Cache-Control', 'no-cache, no-store, must-revalidate') 40 | const html = ` 41 | 42 | 43 | 44 | 45 | Redirecting 46 | 54 | 55 | 56 |
57 |
58 |
59 |

60 | Redirecting 61 |

62 |

63 | Redirecting to authentication status check... 64 |

65 | 66 |
67 |
68 |
69 | 70 | 71 | ` 72 | return new Response(html, { headers: responseHeaders }) 73 | } 74 | 75 | async function handleStatusRequest(url) { 76 | const sid = url.searchParams.get('sid') 77 | const qrID = url.searchParams.get('qrid') 78 | const statusResponse = await fetch(`https://openapi.alipan.com/oauth/qrcode/${sid}/status`) 79 | const statusData = await statusResponse.json() 80 | const status = statusData.status 81 | 82 | if (status === 'LoginSuccess') { 83 | const authCode = statusData.authCode 84 | const tokenResponse = await fetch('http://api.extscreen.com/aliyundrive/v2/token', { 85 | method: 'POST', 86 | headers: { 87 | 'Content-Type': 'application/x-www-form-urlencoded', 88 | 'token': '6733b42e28cdba32' 89 | }, 90 | body: new URLSearchParams({ 'code': authCode }) 91 | }) 92 | const tokenResponseText = await tokenResponse.text() 93 | 94 | const ivMatch = tokenResponseText.match(/"iv":"(.*?)"/) 95 | const ciphertextMatch = tokenResponseText.match(/"ciphertext":"(.*?)"/) 96 | 97 | if (!ivMatch || !ciphertextMatch) { 98 | return new Response(JSON.stringify({ error: "Failed to extract token data" }), { status: 500, headers: { 'Content-Type': 'application/json' } }) 99 | } 100 | 101 | const iv = hexStringToUint8Array(ivMatch[1]) 102 | const ciphertext = base64StringToUint8Array(ciphertextMatch[1]) 103 | const key = await importKeyFromString("^(i/x>>5(ebyhumz*i1wkpk^orIs^Na.") 104 | 105 | try { 106 | const decrypted = await decryptAES(key, iv, ciphertext) 107 | const tokenInfo = JSON.parse(new TextDecoder().decode(decrypted)) 108 | const refreshToken = tokenInfo.refresh_token 109 | const accessToken = tokenInfo.access_token || '' 110 | 111 | let html = ` 112 | 113 | 114 | 115 | 116 | Login Success 117 | 128 | 129 | 130 |
131 |
132 |
133 |
Refresh Token
134 |
135 |
136 | 137 |
138 |
139 | 142 |
143 |
144 | ${accessToken ? `
Access Token
145 |
146 |
147 | 148 |
149 |
150 | 153 |
154 |
` : ''} 155 |
156 |

Source Code

157 |

Original Post

158 |

Welcome to ACG Database, where all ACG resources meet.

159 |
160 |
161 |
162 | 176 | 177 | 178 | ` 179 | return new Response(html, { headers: { 'Content-Type': 'text/html' } }) 180 | } catch (e) { 181 | return new Response(JSON.stringify({ error: "Failed to decrypt token data" }), { status: 500, headers: { 'Content-Type': 'application/json' } }) 182 | } 183 | } else { 184 | const qrLink = "https://openapi.alipan.com/oauth/qrcode/"+ qrID 185 | 186 | let html = ` 187 | 188 | 189 | 190 | 191 | Waiting for Authentication 192 | 204 | 205 | 206 |
207 |
208 |
209 |
210 | QR Code 211 |
212 |

Or login using this link

213 |

Waiting for authentication...

214 |
215 |

Source Code

216 |

Original Post

217 |

Welcome to ACG Database, where all ACG resources meet.

218 |
219 |
220 |
221 | 222 | 223 | ` 224 | const responseHeaders = new Headers() 225 | responseHeaders.set('Content-Type', 'text/html') 226 | responseHeaders.set('Refresh', '10; url=/check-status?sid=' + sid + "&qrid=" + qrID) 227 | return new Response(html, { headers: responseHeaders }) 228 | } 229 | } 230 | 231 | async function handleTokenRequest(request) { 232 | const originalUrl = "http://api.extscreen.com/aliyundrive/v2/token" 233 | const { headers } = request 234 | const body = await request.json() 235 | 236 | const clientId = body.client_id 237 | const clientSecret = body.client_secret 238 | const grantType = body.grant_type 239 | const refreshToken = body.refresh_token 240 | 241 | if (clientId && clientSecret) { 242 | const response = await fetch(originalUrl, { 243 | method: 'POST', 244 | headers: { 245 | ...headers, 246 | 'token': '6733b42e28cdba32' 247 | }, 248 | body: JSON.stringify(body) 249 | }) 250 | const data = await response.json() 251 | return new Response(JSON.stringify(data), { status: response.status, headers: { 'Content-Type': 'application/json' } }) 252 | } 253 | 254 | let decodedToken 255 | try { 256 | decodedToken = JSON.parse(atob(refreshToken.split('.')[1])) 257 | } catch (e) { 258 | return new Response(JSON.stringify({ error: "Invalid token" }), { status: 400, headers: { 'Content-Type': 'application/json' } }) 259 | } 260 | 261 | if (decodedToken.aud !== '6b5b52e144f748f78b3f96a2626ed5d7') { 262 | const response = await fetch(originalUrl, { 263 | method: 'POST', 264 | headers: { 265 | ...headers, 266 | 'token': '6733b42e28cdba32' 267 | }, 268 | body: JSON.stringify(body) 269 | }) 270 | const data = await response.json() 271 | return new Response(JSON.stringify(data), { status: response.status, headers: { 'Content-Type': 'application/json' } }) 272 | } 273 | 274 | const tokenInfoResponse = await fetch('http://api.extscreen.com/aliyundrive/v2/token', { 275 | method: 'POST', 276 | headers: { 277 | 'Content-Type': 'application/x-www-form-urlencoded', 278 | 'token': '6733b42e28cdba32' 279 | }, 280 | body: new URLSearchParams({ 'refresh_token': refreshToken }) 281 | }) 282 | 283 | if (tokenInfoResponse.status !== 200) { 284 | return new Response(JSON.stringify({ error: "Failed to fetch token info" }), { status: 500, headers: { 'Content-Type': 'application/json' } }) 285 | } 286 | 287 | const tokenResponseText = await tokenInfoResponse.text() 288 | 289 | const ivMatch = tokenResponseText.match(/"iv":"(.*?)"/) 290 | const ciphertextMatch = tokenResponseText.match(/"ciphertext":"(.*?)"/) 291 | 292 | if (!ivMatch || !ciphertextMatch) { 293 | return new Response(JSON.stringify({ error: "Failed to extract token data" }), { status: 500, headers: { 'Content-Type': 'application/json' } }) 294 | } 295 | 296 | const iv = hexStringToUint8Array(ivMatch[1]) 297 | const ciphertext = base64StringToUint8Array(ciphertextMatch[1]) 298 | const key = await importKeyFromString("^(i/x>>5(ebyhumz*i1wkpk^orIs^Na.") 299 | 300 | try { 301 | const decrypted = await decryptAES(key, iv, ciphertext) 302 | const tokenInfo = JSON.parse(new TextDecoder().decode(decrypted)) 303 | 304 | const accessToken = tokenInfo.access_token 305 | const newRefreshToken = tokenInfo.refresh_token 306 | 307 | return new Response(JSON.stringify({ 308 | token_type: "Bearer", 309 | access_token: accessToken, 310 | refresh_token: newRefreshToken, 311 | expires_in: 7200 312 | }), { headers: { 'Content-Type': 'application/json' } }) 313 | } catch (e) { 314 | return new Response(JSON.stringify({ error: "Failed to decrypt token data" }), { status: 500, headers: { 'Content-Type': 'application/json' } }) 315 | } 316 | } 317 | 318 | // Helper Functions 319 | function hexStringToUint8Array(hexString) { 320 | const result = [] 321 | for (let i = 0; i < hexString.length; i += 2) { 322 | result.push(parseInt(hexString.substr(i, 2), 16)) 323 | } 324 | return new Uint8Array(result) 325 | } 326 | 327 | function base64StringToUint8Array(base64String) { 328 | const binaryString = atob(base64String) 329 | const len = binaryString.length 330 | const bytes = new Uint8Array(len) 331 | for (let i = 0; i < len; i++) { 332 | bytes[i] = binaryString.charCodeAt(i) 333 | } 334 | return bytes 335 | } 336 | 337 | async function importKeyFromString(keyString) { 338 | const keyData = new TextEncoder().encode(keyString) 339 | return await crypto.subtle.importKey( 340 | 'raw', 341 | keyData, 342 | { name: 'AES-CBC' }, 343 | false, 344 | ['decrypt'] 345 | ) 346 | } 347 | 348 | async function decryptAES(key, iv, ciphertext) { 349 | return await crypto.subtle.decrypt( 350 | { 351 | name: 'AES-CBC', 352 | iv: iv 353 | }, 354 | key, 355 | ciphertext 356 | ) 357 | } 358 | -------------------------------------------------------------------------------- /BatchClearOnedriveRecycleBin-Admin.ps1: -------------------------------------------------------------------------------- 1 | #Set Parameters 2 | $AdminSiteURL="https://YOURORG-admin.sharepoint.com" 3 | 4 | #Get Credentials to connect 5 | $Cred = Get-Credential 6 | 7 | #Connect to Tenant Admin Site 8 | Connect-PnPOnline $AdminSiteURL -Credentials $Cred 9 | 10 | #Get All OneDrive for Business Sites 11 | $OneDriveSites = Get-PnPTenantSite -IncludeOneDriveSites -Filter "Url -like '-my.sharepoint.com/personal/'" 12 | 13 | #Loop through each site 14 | ForEach($Site in $OneDriveSites) 15 | { 16 | #Grant admin permissions to the user 17 | Set-PnPTenantSite -Url $Site.Url -Owners $Cred.UserName 18 | Write-Host -f Yellow "Admin Rights Granted to: "$Site.Url 19 | #Connect to OneDrive for Business Site 20 | Connect-PnPOnline $Site.URL -Credentials $Cred 21 | Write-Host -f Yellow "Processing Site: "$Site.URL 22 | #empty recycle bin onedrive for business powershell 23 | Clear-PnPRecycleBinItem -All -force 24 | } -------------------------------------------------------------------------------- /BatchDeleteFolder-Onedrive-Admin.ps1: -------------------------------------------------------------------------------- 1 | # This folder is used to delete a folder that are under rclone union's control 2 | # Way faster than use rclone to delete. 3 | 4 | #Set Parameters 5 | $AdminSiteURL="https://TENANT-admin.sharepoint.com" 6 | 7 | #Get Credentials to connect 8 | $Cred = Get-Credential 9 | 10 | #Connect to Tenant Admin Site 11 | Connect-PnPOnline $AdminSiteURL -Credentials $Cred 12 | 13 | #Get All OneDrive for Business Sites 14 | $OneDriveSites = Get-PnPTenantSite -IncludeOneDriveSites -Filter "Url -like '-my.sharepoint.com/personal/'" 15 | 16 | #Loop through each site 17 | ForEach($Site in $OneDriveSites) 18 | { 19 | #Grant admin permissions to the user 20 | Set-PnPTenantSite -Url $Site.Url -Owners $Cred.UserName 21 | Write-Host -f Yellow "Admin Rights Granted to: "$Site.Url 22 | #Connect to OneDrive for Business Site 23 | Connect-PnPOnline $Site.URL -Credentials $Cred 24 | Write-Host -f Yellow "Processing Site: "$Site.URL 25 | # Delete folder "FOLDER_NAME" 26 | Remove-PnPFolder -name "FOLDER_NAME" -Folder "Documents" -Force 27 | } 28 | -------------------------------------------------------------------------------- /BatchSetOnedriveStorageLimit-Admin.ps1: -------------------------------------------------------------------------------- 1 | #Set Parameters 2 | $AdminSiteURL="https://YOURORG-admin.sharepoint.com" 3 | 4 | #Get Credentials to connect 5 | $Cred = Get-Credential 6 | 7 | #Connect to Tenant Admin Site 8 | Connect-PnPOnline $AdminSiteURL -Credentials $Cred 9 | 10 | #Get All OneDrive for Business Sites 11 | $OneDriveSites = Get-PnPTenantSite -IncludeOneDriveSites -Filter "Url -like '-my.sharepoint.com/personal/'" 12 | 13 | #Loop through each site 14 | ForEach($Site in $OneDriveSites) 15 | { 16 | #Print current site,storage limit and used quota 17 | Write-host "Site URL:"$Site.Url -f Yellow 18 | Write-host "Storage Limit:"$Site.StorageQuota "MB" -f Yellow 19 | Write-host "Storage Used:"$Site.StorageUsageCurrent "MB" -f Yellow 20 | #Set storage limit to 5TB 21 | Set-SPOSite -Identity $Site.Url -StorageQuota 5242880 22 | } -------------------------------------------------------------------------------- /BatchVideoToMP3.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import argparse 4 | import shutil 5 | from concurrent.futures import ThreadPoolExecutor, as_completed 6 | from pathlib import Path 7 | import sys 8 | import json 9 | import re 10 | import mimetypes # Needed for guessing image MIME type 11 | from typing import List, Tuple, Optional, Dict, Any, Iterator, Union 12 | 13 | # Third-party imports 14 | try: 15 | from mutagen.mp3 import MP3 16 | from mutagen.id3 import ID3, TPE1, TIT2, TDRC, COMM, APIC, Encoding, ID3NoHeaderError # Added APIC 17 | except ImportError: 18 | print("Error: 'mutagen' library not found. Please install it: pip install mutagen", file=sys.stderr) 19 | sys.exit(1) 20 | 21 | try: 22 | from tqdm import tqdm 23 | except ImportError: 24 | print("Error: 'tqdm' library not found. Please install it: pip install tqdm", file=sys.stderr) 25 | # Provide a dummy tqdm class if not found, so the script can still run without progress bars 26 | class tqdm: 27 | def __init__(self, iterable=None, *args, **kwargs): 28 | self.iterable = iterable 29 | self.total = kwargs.get('total', None) 30 | if self.iterable is not None and self.total is None: 31 | try: 32 | self.total = len(self.iterable) 33 | except (TypeError, AttributeError): 34 | self.total = None 35 | 36 | def __iter__(self): 37 | return iter(self.iterable) 38 | 39 | def __enter__(self): 40 | return self 41 | 42 | def __exit__(self, *args): 43 | pass 44 | 45 | def update(self, n=1): 46 | pass 47 | 48 | def set_description(self, desc): 49 | pass 50 | 51 | def set_postfix_str(self, s): 52 | pass 53 | 54 | @staticmethod 55 | def write(s, file=sys.stdout): 56 | print(s, file=file) 57 | 58 | # --- Configuration --- 59 | SUPPORTED_EXTENSIONS: Tuple[str, ...] = ('.mp4', '.ts', '.mkv', '.avi', '.mov', '.wmv', '.flv') 60 | FILENAME_METADATA_REGEX: re.Pattern = re.compile( 61 | r"^\[([^\]]+)\]\[(\d{4}-\d{2}-\d{2})\]\[(.+?)\](?:\[(\d{4}-\d{2}-\d{2})\])?$" 62 | ) 63 | # Standard ID3 picture type for Cover (front) 64 | ID3_PIC_TYPE_COVER_FRONT = 3 65 | TEMP_SUFFIX = ".tmp" # Define the temporary suffix 66 | # Specific FFmpeg error messages indicating no audio stream 67 | NO_AUDIO_STREAM_ERRORS = ( 68 | "Stream map '0:a:0' matches no streams", # Common when using -map 0:a:0? 69 | "Output file #0 does not contain any stream" # Can happen if -map isn't used but still no audio 70 | ) 71 | # List of folder names (case-insensitive) to completely ignore during scanning 72 | IGNORE_FOLDERS: List[str] = [ 73 | "[MissWarmJ]", 74 | # Add more folder names here as needed, e.g., "backup", "[Old Stuff]" 75 | ] 76 | 77 | 78 | # --- Helper Types --- 79 | FFmpegResult = Tuple[bool, Optional[str]] # (success, error_message) 80 | # Added 'skipped_no_audio' to status types 81 | ConversionResult = Tuple[str, Path, Any, str, str] # (status, input_path, output_or_error, action, metadata_status) 82 | 83 | # --- Core Logic Functions --- 84 | 85 | def find_executable(name: str) -> Optional[str]: 86 | """Checks if an executable is accessible in the system PATH.""" 87 | path = shutil.which(name) 88 | if path: 89 | print(f"{name} found in PATH: {path}") 90 | return path 91 | else: 92 | tqdm.write(f"ERROR: {name} not found in system PATH.", file=sys.stderr) 93 | tqdm.write(f"Please install FFmpeg (which includes {name}) and ensure it's added to your PATH.", file=sys.stderr) 94 | tqdm.write("Download from: https://ffmpeg.org/download.html", file=sys.stderr) 95 | return None 96 | 97 | def get_audio_codec(video_path: Path, ffprobe_path: str) -> Optional[str]: 98 | """Uses ffprobe to determine the codec of the first audio stream.""" 99 | command: List[str] = [ 100 | ffprobe_path, 101 | '-v', 'error', '-select_streams', 'a:0', 102 | '-show_entries', 'stream=codec_name', '-of', 'json', str(video_path) 103 | ] 104 | try: 105 | process = subprocess.run(command, capture_output=True, text=True, check=True, encoding='utf-8', errors='replace') 106 | data = json.loads(process.stdout) 107 | if data and 'streams' in data and data['streams']: 108 | return data['streams'][0].get('codec_name') 109 | # If ffprobe runs successfully but finds no streams, return specific indicator 110 | return "no_audio_stream_found_by_ffprobe" 111 | except FileNotFoundError: 112 | tqdm.write(f"\nERROR: ffprobe executable not found at '{ffprobe_path}'. Cannot check audio codec.", file=sys.stderr) 113 | return None 114 | except subprocess.CalledProcessError as e: 115 | # Check if ffprobe failed because there were no streams selected 116 | if "does not contain any stream" in e.stderr or "could not find codec parameters" in e.stderr: 117 | # This indicates successful run but no audio stream 118 | return "no_audio_stream_found_by_ffprobe" 119 | tqdm.write(f"Warning: Could not get audio codec for {video_path.name} (will attempt conversion). FFprobe Error: {e.stderr}", file=sys.stderr) 120 | return None # Indicate error occurred, distinct from no audio found 121 | except (json.JSONDecodeError, Exception) as e: 122 | tqdm.write(f"Warning: Could not get audio codec for {video_path.name} (will attempt conversion). Error: {e}", file=sys.stderr) 123 | return None 124 | 125 | def extract_metadata_from_filename(filename_stem: str) -> Optional[Dict[str, str]]: 126 | """Parses the filename stem using regex to extract metadata.""" 127 | match = FILENAME_METADATA_REGEX.match(filename_stem) 128 | if match: 129 | return { 130 | 'artist': match.group(1).strip(), 131 | 'date': match.group(2).strip(), 132 | 'title': match.group(3).strip(), 133 | 'original_filename': filename_stem 134 | } 135 | return None 136 | 137 | def add_metadata_to_mp3( 138 | mp3_path: Path, # Path to the MP3 file (will be the temp file during processing) 139 | metadata: Dict[str, str], 140 | image_data: Optional[bytes], 141 | image_mime: Optional[str] 142 | ) -> bool: 143 | """ 144 | Adds ID3 metadata tags (text and optional album art) to the MP3 file. 145 | 146 | Args: 147 | mp3_path: Path to the MP3 file (likely the temporary file). 148 | metadata: Dictionary with 'artist', 'date', 'title'. 149 | image_data: Raw bytes of the album art image, or None. 150 | image_mime: MIME type of the album art image, or None. 151 | 152 | Returns: 153 | True if successful, False otherwise. 154 | """ 155 | try: 156 | try: 157 | audio = MP3(mp3_path, ID3=ID3) 158 | except ID3NoHeaderError: 159 | audio = MP3(mp3_path) 160 | # If no ID3 header, try adding one 161 | try: 162 | audio.add_tags() 163 | except Exception as add_tags_err: 164 | tqdm.write(f"Warning: Could not add ID3 tag structure to {mp3_path.name}. Metadata might not be saved. Error: {add_tags_err}", file=sys.stderr) 165 | # Attempt to proceed without tags if structure couldn't be added 166 | if audio.tags is None: # If tags are still None, fail 167 | raise ValueError(f"Failed to initialize ID3 tags for {mp3_path.name}") from add_tags_err 168 | 169 | # Ensure tags attribute exists after attempting to add them 170 | if audio.tags is None: 171 | audio.tags = ID3() # Create an empty ID3 object if still missing 172 | 173 | # --- Add/Update Text Metadata --- 174 | if metadata.get('artist'): 175 | audio.tags.add(TPE1(encoding=Encoding.UTF8, text=metadata['artist'])) 176 | if metadata.get('title'): 177 | audio.tags.add(TIT2(encoding=Encoding.UTF8, text=metadata['title'])) 178 | if metadata.get('date'): 179 | # Validate date format slightly before adding (basic YYYY-MM-DD check) 180 | date_str = metadata['date'] 181 | if re.match(r"^\d{4}-\d{2}-\d{2}$", date_str): 182 | audio.tags.add(TDRC(encoding=Encoding.UTF8, text=date_str)) 183 | else: 184 | tqdm.write(f"Warning: Skipping invalid date format '{date_str}' for {mp3_path.name}. Expected YYYY-MM-DD.", file=sys.stderr) 185 | 186 | 187 | comment_text = f"Original Filename Stem: {metadata.get('original_filename', mp3_path.stem.replace(TEMP_SUFFIX,''))}" # Clean temp suffix for comment 188 | # Remove existing comments before adding new one 189 | audio.tags.delall('COMM') 190 | audio.tags.add(COMM(encoding=Encoding.UTF8, lang='eng', desc='Converted Info', text=comment_text)) 191 | 192 | # --- Add/Update Album Art --- 193 | # Remove existing cover art first to avoid duplicates 194 | audio.tags.delall('APIC') 195 | if image_data and image_mime: 196 | apic = APIC( 197 | encoding=Encoding.UTF8, # Encoding for the description text 198 | mime=image_mime, # Image mime type 199 | type=ID3_PIC_TYPE_COVER_FRONT, # 3: Cover (front) 200 | desc='Cover', # Description 201 | data=image_data # Image data as bytes 202 | ) 203 | audio.tags.add(apic) 204 | 205 | # Save changes using ID3v2.3, removing ID3v1 tags 206 | audio.save(v1=0, v2_version=3) 207 | return True 208 | 209 | except Exception as e: 210 | tqdm.write(f"Error: Failed to add metadata/art to {mp3_path.name}. Reason: {e}", file=sys.stderr) 211 | return False 212 | 213 | def build_ffmpeg_command( 214 | video_path: Path, 215 | temp_output_path: Path, # Takes the temporary path now 216 | ffmpeg_path: str, 217 | original_audio_codec: Optional[str], 218 | # Removed bitrate_k: int 219 | vbr_quality: int # Added VBR quality level 220 | # Removed overwrite flag here - it's handled during rename check. FFmpeg always uses -y for temp file. 221 | ) -> Tuple[List[str], str]: 222 | """Constructs the appropriate ffmpeg command list and determines the action.""" 223 | common_opts: List[str] = [ 224 | ffmpeg_path, 225 | '-y', # Always allow ffmpeg to overwrite the temp file if it somehow exists 226 | '-i', str(video_path), 227 | '-vn', # Disable video recording 228 | '-loglevel', 'error', # Only show errors 229 | '-hide_banner', 230 | # Strip existing metadata from video - we'll add fresh tags later 231 | '-map_metadata', '-1', 232 | # map only the first audio stream to the output, fail if none exists initially 233 | '-map', '0:a:0?', # Map first audio stream, '?' makes it optional (but FFmpeg might still error if *no* output stream results) 234 | ] 235 | 236 | action: str 237 | command: List[str] 238 | 239 | # We rely on get_audio_codec result, but FFmpeg will ultimately decide based on stream content 240 | if original_audio_codec == 'mp3': 241 | # Copy MP3 stream if ffprobe identified it as mp3 242 | command = common_opts + ['-codec:a', 'copy', '-f','mp3', str(temp_output_path)] 243 | action = "copied" 244 | else: 245 | # Convert to MP3 using quality-based VBR for other codecs or if codec detection failed/was inconclusive 246 | command = common_opts + [ 247 | '-codec:a', 'libmp3lame', 248 | '-q:a', str(vbr_quality), # Use -q:a (maps to LAME -V) and the quality level 249 | '-ar', '44100', # Common sample rate 250 | '-ac', '2', # Stereo 251 | '-f', 'mp3', # Explicitly set output container format 252 | str(temp_output_path) 253 | ] 254 | action = f"converted (VBR Q{vbr_quality})" # Updated action description 255 | 256 | return command, action 257 | 258 | def run_ffmpeg(command: List[str]) -> FFmpegResult: 259 | """Executes the ffmpeg command and returns success status and error message.""" 260 | try: 261 | process = subprocess.run(command, capture_output=True, text=True, check=False, encoding='utf-8', errors='replace') 262 | if process.returncode == 0: 263 | return True, None 264 | else: 265 | error_msg = f"FFmpeg error (code {process.returncode}):\n--- FFMPEG STDERR ---\n{process.stderr.strip()}" 266 | # Check for specific "no audio stream" errors 267 | is_no_audio_error = any(err_msg in process.stderr for err_msg in NO_AUDIO_STREAM_ERRORS) 268 | if is_no_audio_error: 269 | # Append a more user-friendly reason if possible 270 | error_msg += "\n(Likely reason: No audio stream found in the input video)" 271 | return False, error_msg 272 | except Exception as e: 273 | return False, f"Failed to run ffmpeg command: {e}" 274 | 275 | def verify_output(output_path: Path) -> bool: 276 | """Checks if the output file exists and is not empty.""" 277 | # Verifies the temp path or the final path after rename 278 | return output_path.exists() and output_path.stat().st_size > 0 279 | 280 | def cleanup_temp_file(temp_output_path: Path): 281 | """Attempts to remove the temporary output file.""" 282 | if temp_output_path and temp_output_path.exists() and temp_output_path.name.endswith(TEMP_SUFFIX): 283 | try: 284 | temp_output_path.unlink() 285 | # tqdm.write(f"Cleaned up temporary file: {temp_output_path.name}", file=sys.stderr) # Optional: more verbose logging 286 | except OSError as e: 287 | tqdm.write(f"Warning: Could not remove temporary file {temp_output_path}: {e}", file=sys.stderr) 288 | 289 | def handle_metadata_tagging( 290 | add_metadata_flag: bool, 291 | video_path: Path, 292 | temp_mp3_path: Path, # Operates on the temporary MP3 file 293 | image_data: Optional[bytes], 294 | image_mime: Optional[str] 295 | ) -> str: 296 | """Handles metadata extraction and tagging on the temp file, returning the status.""" 297 | if not add_metadata_flag and not (image_data and image_mime): # Skip if no text meta AND no image 298 | return 'not_attempted' 299 | if not temp_mp3_path.exists(): # Ensure temp file exists before tagging 300 | tqdm.write(f"Error: Temp file {temp_mp3_path.name} not found before metadata tagging.", file=sys.stderr) 301 | return 'failed' 302 | 303 | filename_stem = video_path.stem 304 | metadata = extract_metadata_from_filename(filename_stem) or {} # Use empty dict if no match 305 | 306 | if not metadata and not (image_data and image_mime): 307 | # This case is already covered by the first check, but keep for clarity 308 | return 'skipped' # No text match and no art provided 309 | 310 | if not metadata and add_metadata_flag: # Log only if text meta was expected but not found 311 | # Check if the original filename itself matches the ignore pattern to suppress this message 312 | # This check might be redundant if file is already excluded, but good safeguard 313 | if not any(part.lower() == ignored.lower() for part in video_path.parts for ignored in IGNORE_FOLDERS): 314 | tqdm.write(f"Info: No metadata pattern matched for '{filename_stem}', skipping text tagging.") 315 | 316 | # Attempt to add metadata (text and/or art) 317 | if add_metadata_to_mp3(temp_mp3_path, metadata, image_data, image_mime): 318 | # Check which parts were actually added if we need finer grain status 319 | if metadata and (image_data and image_mime): 320 | return 'added' # Both attempted and succeeded (or just text/just art if only one provided) 321 | elif metadata: 322 | return 'added' # Only text attempted and succeeded 323 | elif image_data and image_mime: 324 | return 'added' # Only art attempted and succeeded 325 | else: 326 | return 'skipped' # Should not happen if initial checks are correct 327 | else: 328 | return 'failed' # Tagging failed 329 | 330 | 331 | # --- Main Worker Function --- 332 | 333 | def convert_single_video( 334 | video_path: Path, 335 | source_base: Path, 336 | output_base: Path, 337 | # Removed bitrate_k: int, 338 | vbr_quality: int, # Added VBR quality level 339 | ffmpeg_path: str, 340 | ffprobe_path: str, 341 | overwrite: bool, 342 | add_metadata_flag: bool, 343 | album_art_data: Optional[bytes], 344 | album_art_mime: Optional[str] 345 | ) -> ConversionResult: 346 | """Orchestrates the conversion and tagging process for a single video file using a temporary file.""" 347 | output_path: Optional[Path] = None 348 | temp_output_path: Optional[Path] = None 349 | metadata_status: str = 'not_attempted' 350 | action: str = 'failed' # Default action state 351 | 352 | try: 353 | # --- Path Setup --- 354 | relative_path = video_path.relative_to(source_base) 355 | output_path = output_base / relative_path.with_suffix('.mp3') 356 | # Create the temporary path 357 | temp_output_path = output_path.with_suffix(output_path.suffix + TEMP_SUFFIX) 358 | output_dir = output_path.parent 359 | output_dir.mkdir(parents=True, exist_ok=True) 360 | 361 | # --- Pre-flight Check (Final Destination) --- 362 | if not overwrite and output_path.exists(): 363 | # This check should ideally be caught by filter_existing_files, but double-check 364 | return 'skipped', video_path, output_path, 'skipped', 'not_attempted' 365 | 366 | 367 | # --- Determine Audio Codec (and check for existence) --- 368 | original_audio_codec = get_audio_codec(video_path, ffprobe_path) 369 | # Check if ffprobe explicitly found no audio stream 370 | if original_audio_codec == "no_audio_stream_found_by_ffprobe": 371 | # No need to even run ffmpeg if ffprobe already confirmed no audio 372 | return 'skipped_no_audio', video_path, "No audio stream detected by ffprobe", 'skipped', 'not_attempted' 373 | 374 | 375 | # --- Build and Run FFmpeg Command (to Temp File) --- 376 | # Pass VBR quality instead of bitrate 377 | command, action = build_ffmpeg_command( 378 | video_path, temp_output_path, ffmpeg_path, original_audio_codec, vbr_quality 379 | ) 380 | success, ffmpeg_error = run_ffmpeg(command) 381 | 382 | # --- Handle FFmpeg Result --- 383 | if not success: 384 | cleanup_temp_file(temp_output_path) 385 | # Check if the failure was due to no audio stream 386 | if ffmpeg_error and any(err_msg in ffmpeg_error for err_msg in NO_AUDIO_STREAM_ERRORS): 387 | return 'skipped_no_audio', video_path, "No audio stream found by FFmpeg", action, metadata_status 388 | else: 389 | # Otherwise, it's a genuine failure 390 | return 'failed', video_path, ffmpeg_error, action, metadata_status 391 | 392 | # --- Verify Temporary Output --- 393 | if not verify_output(temp_output_path): 394 | error_message = f"FFmpeg OK, but temp output is invalid/empty: {temp_output_path.name}" 395 | cleanup_temp_file(temp_output_path) 396 | return 'failed', video_path, error_message, action, metadata_status 397 | 398 | # --- Handle Metadata Tagging (on Temp File) --- 399 | # Only attempt if flag is set OR album art is provided 400 | if add_metadata_flag or (album_art_data and album_art_mime): 401 | metadata_status = handle_metadata_tagging( 402 | add_metadata_flag, video_path, temp_output_path, album_art_data, album_art_mime 403 | ) 404 | if metadata_status == 'failed': 405 | # Decide if metadata failure should prevent the rename (treat as overall failure) 406 | # Current decision: Yes, metadata failure means the whole process failed for this file. 407 | error_message = f"Metadata tagging failed for {temp_output_path.name}" 408 | cleanup_temp_file(temp_output_path) 409 | return 'failed', video_path, error_message, action, metadata_status 410 | else: 411 | metadata_status = 'not_attempted' # Explicitly set if no tagging was done 412 | 413 | 414 | # --- Final Rename --- 415 | try: 416 | # Double-check final destination right before rename, respecting overwrite flag 417 | if output_path.exists(): 418 | if overwrite: 419 | try: 420 | output_path.unlink() # Remove final destination if overwriting 421 | except OSError as e: 422 | error_message = f"Cannot overwrite existing file {output_path.name}: {e}" 423 | cleanup_temp_file(temp_output_path) 424 | return 'failed', video_path, error_message, action, metadata_status 425 | else: 426 | # This should not happen if initial checks worked, but handle defensively 427 | error_message = f"Final output file {output_path.name} appeared unexpectedly before rename (and overwrite is False)." 428 | cleanup_temp_file(temp_output_path) 429 | # Treat as skipped because the final file exists and we shouldn't overwrite 430 | return 'skipped', video_path, error_message, action, metadata_status 431 | 432 | # Perform the rename 433 | temp_output_path.rename(output_path) 434 | 435 | # --- Final Verification (Optional but Recommended) --- 436 | if not verify_output(output_path): 437 | error_message = f"Rename appeared successful, but final file is invalid/empty: {output_path.name}" 438 | # Don't cleanup temp here as it's already renamed (or failed rename) 439 | # output_path might exist but be empty, try cleaning it 440 | cleanup_temp_file(output_path) # Try cleaning the potentially bad final file 441 | return 'failed', video_path, error_message, action, metadata_status 442 | 443 | # --- Success --- 444 | # Status 'success' refers to the conversion/copy *and* rename being successful. 445 | # Metadata status reflects the outcome of the tagging step. 446 | return 'success', video_path, output_path, action, metadata_status 447 | 448 | except OSError as e: 449 | error_msg = f"Failed to rename temp file {temp_output_path.name} to {output_path.name}: {e}" 450 | cleanup_temp_file(temp_output_path) # Clean up the temp file 451 | # If rename failed, try to clean up potential partial final file if overwrite was true 452 | if overwrite and output_path.exists(): 453 | cleanup_temp_file(output_path) 454 | return 'failed', video_path, error_msg, action, 'failed' # Metadata status becomes failed as rename failed 455 | 456 | except Exception as e: 457 | # Ensure cleanup happens even with unexpected errors 458 | if temp_output_path: cleanup_temp_file(temp_output_path) 459 | # Determine final meta status - if tagging was attempted and failed before this error, keep 'failed' 460 | final_meta_status = metadata_status if metadata_status in ['failed', 'added', 'skipped'] else 'failed' 461 | # Add traceback for better debugging of unexpected Python errors 462 | import traceback 463 | error_msg = f"Unexpected Python error processing {video_path.name}: {e}\n{traceback.format_exc()}" 464 | return 'failed', video_path, error_msg, action, final_meta_status 465 | 466 | 467 | # --- File Discovery and Filtering --- 468 | 469 | def find_video_files(source_folder: Path) -> List[Path]: 470 | """Finds all supported video files recursively, skipping ignored folders.""" 471 | video_files: List[Path] = [] 472 | print(f"Scanning for video files in: {source_folder}") 473 | if IGNORE_FOLDERS: 474 | # Prepare lowercase version for efficient checking 475 | ignore_folders_lower = {f.lower() for f in IGNORE_FOLDERS} 476 | print(f"Ignoring folders (case-insensitive): {', '.join(IGNORE_FOLDERS)}") 477 | else: 478 | ignore_folders_lower = set() 479 | 480 | try: 481 | # Efficiently find all files first, then filter 482 | all_files_gen = source_folder.rglob("*") 483 | try: 484 | all_files_list = list(tqdm(all_files_gen, desc="Discovering files", unit="file", leave=False, ncols=100, miniters=100, mininterval=0.1)) 485 | except TypeError: 486 | all_files_list = list(all_files_gen) 487 | print("Found a large number of files, discovery progress bar may be inaccurate.") 488 | 489 | print(f"Filtering videos (ignoring specified folders)...") 490 | ignored_count = 0 491 | for file_path in tqdm(all_files_list, desc="Filtering videos", unit="file", leave=False, ncols=100): 492 | if file_path.is_file(): 493 | # --- Ignore Folder Check --- 494 | should_ignore = False 495 | if ignore_folders_lower: 496 | try: 497 | relative_path = file_path.relative_to(source_folder) 498 | # Check if any directory component in the relative path is in the ignore list 499 | for part in relative_path.parts[:-1]: # Check only directory parts, not the filename itself 500 | if part.lower() in ignore_folders_lower: 501 | should_ignore = True 502 | break 503 | except ValueError: 504 | # Should not happen if file_path is within source_folder from rglob 505 | tqdm.write(f"Warning: Could not get relative path for {file_path}, skipping ignore check.", file=sys.stderr) 506 | 507 | if should_ignore: 508 | ignored_count += 1 509 | continue # Skip this file 510 | 511 | # --- Standard Checks (Extension, Temp Suffix) --- 512 | if file_path.suffix.lower() in SUPPORTED_EXTENSIONS and not file_path.name.endswith(TEMP_SUFFIX): 513 | video_files.append(file_path) 514 | 515 | except Exception as e: 516 | print(f"\nError during file scan: {e}", file=sys.stderr) 517 | 518 | # Report ignored count only if some were ignored, keep it separate from final stats 519 | if ignored_count > 0: 520 | print(f"(Skipped {ignored_count} files found within ignored folders)") 521 | 522 | print(f"Found {len(video_files)} potential video files to process.") 523 | return video_files 524 | 525 | 526 | def filter_existing_files( 527 | all_video_files: List[Path], 528 | source_path: Path, 529 | output_base: Path, 530 | overwrite: bool 531 | ) -> Tuple[List[Path], int]: 532 | """ 533 | Filters out videos whose corresponding FINAL MP3 output already exists. 534 | Does NOT skip based on the presence of .tmp files. 535 | """ 536 | files_to_process: List[Path] = [] 537 | skipped_count: int = 0 538 | if not overwrite: 539 | print("Checking for existing final output files (.mp3) to skip...") 540 | for video_path in tqdm(all_video_files, desc="Pre-checking", unit="file", leave=False, ncols=100): 541 | try: 542 | relative_path = video_path.relative_to(source_path) 543 | # Check ONLY for the final .mp3 file 544 | potential_output_path = output_base / relative_path.with_suffix('.mp3') 545 | if potential_output_path.exists(): 546 | skipped_count += 1 547 | else: 548 | files_to_process.append(video_path) 549 | except ValueError: 550 | tqdm.write(f"Warning: Skipping file due to path issue: {video_path}", file=sys.stderr) 551 | skipped_count += 1 # Treat as skipped 552 | 553 | return files_to_process, skipped_count 554 | else: 555 | # If overwriting, process all files found 556 | return all_video_files, 0 557 | 558 | # --- Argument Parsing and Validation --- 559 | 560 | def parse_arguments() -> argparse.Namespace: 561 | """Parses command-line arguments.""" 562 | parser = argparse.ArgumentParser( 563 | description="Convert video files to MP3 using quality-based VBR, optionally adding metadata and album art. Uses temporary files for safety. Skips videos with no audio stream and ignores specified folders.", 564 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 565 | ) 566 | parser.add_argument("source_folder", help="Path to the folder containing video files (scanned recursively).") 567 | parser.add_argument("output_folder", help="Path to the folder where MP3 files will be saved.") 568 | # Changed from bitrate to VBR quality 569 | parser.add_argument("-q", "--vbr-quality", type=int, default=3, choices=range(10), metavar='[0-9]', 570 | help="VBR quality level for libmp3lame (0=best/largest, 9=worst/smallest). Used when re-encoding.") 571 | parser.add_argument("-t", "--threads", type=int, default=os.cpu_count() or 1, help="Number of concurrent ffmpeg processes.") 572 | parser.add_argument("-o", "--overwrite", action="store_true", help="Overwrite existing FINAL MP3 files.") 573 | parser.add_argument("--no-metadata", action="store_true", help="Disable extracting/embedding text metadata from filenames.") 574 | parser.add_argument("-i","--album-art", type=str, default=None, metavar="IMAGE_PATH", help="Path to an image file (jpg/png) to embed as album art.") 575 | parser.add_argument("--ffmpeg", default=None, help="Optional: Explicit path to ffmpeg executable.") 576 | parser.add_argument("--ffprobe", default=None, help="Optional: Explicit path to ffprobe executable.") 577 | # Add argument for ignore folders (optional, can also just edit the list) 578 | parser.add_argument("--ignore-folder", action='append', default=[], help="Add a folder name to ignore (case-insensitive). Can be used multiple times. Overrides internal list if used.") 579 | 580 | return parser.parse_args() 581 | 582 | def validate_args_and_paths(args: argparse.Namespace) -> Tuple[Path, Path, int, int, bool]: 583 | """Validates arguments and paths, creates output directory.""" 584 | source_path = Path(args.source_folder).resolve() 585 | output_path_base = Path(args.output_folder).resolve() 586 | 587 | if not source_path.is_dir(): 588 | print(f"Error: Source folder '{args.source_folder}' not found or is not a directory.", file=sys.stderr) 589 | sys.exit(1) 590 | 591 | try: 592 | output_path_base.mkdir(parents=True, exist_ok=True) 593 | except OSError as e: 594 | print(f"Error: Could not create output directory '{output_path_base}': {e}", file=sys.stderr) 595 | sys.exit(1) 596 | 597 | threads = args.threads 598 | if threads <= 0: 599 | tqdm.write(f"Warning: Invalid number of threads ({threads}). Using 1 thread.", file=sys.stderr) 600 | threads = 1 601 | 602 | # Validate VBR quality level (though argparse choices already does this) 603 | vbr_quality = args.vbr_quality 604 | if not 0 <= vbr_quality <= 9: 605 | # This check is redundant if argparse choices is used, but good practice 606 | tqdm.write(f"Warning: Invalid VBR quality ({vbr_quality}). Using default 3.", file=sys.stderr) 607 | vbr_quality = 3 608 | 609 | add_metadata_flag = not args.no_metadata 610 | 611 | # --- Handle Ignore Folders Argument --- 612 | # If the command-line argument is used, it *replaces* the hardcoded list 613 | global IGNORE_FOLDERS 614 | if args.ignore_folder: 615 | IGNORE_FOLDERS = args.ignore_folder 616 | print(f"Using specified ignore folders: {', '.join(IGNORE_FOLDERS)}") 617 | 618 | # Return vbr_quality instead of bitrate 619 | return source_path, output_path_base, threads, vbr_quality, add_metadata_flag 620 | 621 | def load_album_art(image_path_str: Optional[str]) -> Tuple[Optional[bytes], Optional[str]]: 622 | """Loads album art data and determines MIME type.""" 623 | if not image_path_str: 624 | return None, None 625 | 626 | image_path = Path(image_path_str).resolve() # Resolve the path 627 | if not image_path.is_file(): 628 | tqdm.write(f"Warning: Album art file not found: {image_path}. Skipping album art embedding.", file=sys.stderr) 629 | return None, None 630 | 631 | try: 632 | with open(image_path, 'rb') as f: 633 | image_data = f.read() 634 | 635 | mime_type, _ = mimetypes.guess_type(str(image_path)) # Use str() for guess_type 636 | 637 | # Allow common image types even if guess fails, as mutagen might support them. 638 | # Focus check on whether it *looks* like an image mime type. 639 | supported_mimes = ('image/jpeg', 'image/png', 'image/gif') # Key types for ID3 640 | is_supported = False 641 | if mime_type: 642 | mime_type_lower = mime_type.lower() 643 | if mime_type_lower.startswith('image/'): 644 | # If it's a known supported type, great. 645 | if mime_type_lower in supported_mimes: 646 | is_supported = True 647 | else: 648 | # If it's another image type, warn but proceed. Mutagen might handle it. 649 | tqdm.write(f"Warning: Album art MIME type '{mime_type}' is not guaranteed to be supported by all players, but attempting embedding.", file=sys.stderr) 650 | is_supported = True # Try it anyway 651 | else: 652 | # If mimetypes guessed something that isn't an image 653 | tqdm.write(f"Warning: Detected MIME type '{mime_type}' for album art file {image_path.name} is not an image type. Skipping album art.", file=sys.stderr) 654 | return None, None 655 | else: 656 | # If mimetypes couldn't guess, maybe check extension? Or just warn and proceed? 657 | # Let's warn and skip if we can't even guess it's an image. 658 | tqdm.write(f"Warning: Could not determine MIME type for album art file {image_path.name}. Skipping album art.", file=sys.stderr) 659 | return None, None 660 | 661 | if not is_supported: # Should not be reachable if logic above is correct, but as safeguard. 662 | tqdm.write(f"Warning: Album art MIME type '{mime_type}' not supported or identified correctly. Skipping album art.", file=sys.stderr) 663 | return None, None 664 | 665 | 666 | print(f"Album art loaded: {image_path} (Type: {mime_type})") 667 | return image_data, mime_type 668 | 669 | except IOError as e: 670 | tqdm.write(f"Error reading album art file {image_path}: {e}. Skipping album art.", file=sys.stderr) 671 | return None, None 672 | except Exception as e: 673 | tqdm.write(f"Unexpected error loading album art {image_path}: {e}. Skipping album art.", file=sys.stderr) 674 | return None, None 675 | 676 | # --- Concurrent Processing --- 677 | 678 | def process_files_concurrently( 679 | files_to_process: List[Path], 680 | source_path: Path, 681 | output_base: Path, 682 | # Removed bitrate_k: int, 683 | vbr_quality: int, # Added VBR quality level 684 | ffmpeg_path: str, 685 | ffprobe_path: str, 686 | overwrite: bool, 687 | add_metadata_flag: bool, 688 | album_art_data: Optional[bytes], 689 | album_art_mime: Optional[str], 690 | max_workers: int 691 | ) -> Iterator[ConversionResult]: 692 | """Submits conversion tasks to a ThreadPoolExecutor and yields results.""" 693 | with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='Converter') as executor: 694 | futures = { 695 | executor.submit( 696 | convert_single_video, 697 | video_path, source_path, output_base, vbr_quality, # Pass vbr_quality 698 | ffmpeg_path, ffprobe_path, overwrite, add_metadata_flag, 699 | album_art_data, album_art_mime 700 | ): video_path 701 | for video_path in files_to_process 702 | } 703 | 704 | # Use tqdm directly on as_completed for progress 705 | for future in tqdm(as_completed(futures), total=len(files_to_process), desc="Converting", unit="file", ncols=100, smoothing=0.05): # Added smoothing 706 | video_path_orig = futures[future] 707 | rel_input_str = "Unknown" 708 | try: 709 | # Calculate relative path for logging before potential errors in future.result() 710 | try: 711 | rel_input = video_path_orig.relative_to(source_path) 712 | rel_input_str = str(rel_input) 713 | except ValueError: 714 | rel_input_str = str(video_path_orig) # Fallback 715 | 716 | result: ConversionResult = future.result() 717 | yield result 718 | 719 | except Exception as exc: 720 | # This catches exceptions *within* the future processing itself (if not caught by convert_single_video) 721 | # Or exceptions during future.result() call. 722 | tqdm.write(f"\nCRITICAL WORKER ERROR: {rel_input_str}\n Reason: {exc}", file=sys.stderr) 723 | import traceback 724 | traceback.print_exc(file=sys.stderr) # Print stack trace for debugging 725 | 726 | # Try to find associated temp file and clean it up if possible (best effort) 727 | try: 728 | # Recalculate paths needed for cleanup 729 | relative_path = video_path_orig.relative_to(source_path) # May fail if source_path is odd 730 | output_path = output_base / relative_path.with_suffix('.mp3') 731 | temp_output_path = output_path.with_suffix(output_path.suffix + TEMP_SUFFIX) 732 | cleanup_temp_file(temp_output_path) 733 | except Exception as cleanup_err: 734 | tqdm.write(f" Cleanup attempt failed during critical error handling: {cleanup_err}", file=sys.stderr) 735 | 736 | yield 'failed', video_path_orig, f"Critical Worker Exception: {exc}", 'failed', 'failed' 737 | 738 | 739 | # --- Summary Reporting --- 740 | 741 | def print_summary( 742 | total_files_found: int, # Files found *after* ignoring folders 743 | skipped_initially_count: int, # Files skipped because final .mp3 existed 744 | skipped_no_audio_count: int, # Files skipped because no audio stream detected 745 | total_submitted_for_process: int,# Files actually sent to the thread pool 746 | success_count: int, 747 | copied_count: int, 748 | converted_count: int, 749 | failed_count: int, # Files failed during conversion/rename/internal error (excluding no_audio skips) 750 | skipped_during_process_count: int,# Files skipped by the worker (e.g., output existed just before rename) 751 | metadata_added_count: int, 752 | metadata_skipped_count: int, # No pattern matched for text meta 753 | metadata_failed_count: int, # Error during tagging process 754 | add_metadata_flag: bool, 755 | album_art_provided: bool 756 | ): 757 | """Prints the final summary of the conversion process.""" 758 | print("\n" + "=" * 30) 759 | print(" Processing Summary ".center(30, "=")) 760 | print(f"Video Files Found (excl. ignored): {total_files_found}") # Clarified label 761 | print(f"Skipped (Output Existed): {skipped_initially_count}") 762 | print(f"Skipped (No Audio Stream): {skipped_no_audio_count}") 763 | print(f"----------------------------------") 764 | print(f"Files Submitted for Processing: {total_submitted_for_process}") 765 | # Modified converted count description slightly 766 | print(f" Successfully Processed: {success_count} ({copied_count} copied, {converted_count} converted VBR)") 767 | # Display metadata stats only if it was relevant 768 | if add_metadata_flag or album_art_provided: 769 | # Indent metadata stats under success count 770 | print(f" Metadata Added/Updated: {metadata_added_count}") 771 | print(f" Metadata Skipped (No Match): {metadata_skipped_count}") 772 | print(f" Metadata Tagging Failed: {metadata_failed_count}") # Note: Tagging failures count towards overall failed_count too 773 | print(f" Skipped during processing: {skipped_during_process_count}") # e.g., race condition where output appeared 774 | print(f" Failed (Error/Convert/Tag): {failed_count}") # This now excludes the 'skipped_no_audio' cases 775 | print("=" * 30) 776 | 777 | # --- Startup Cleanup --- 778 | def initial_cleanup(output_base: Path): 779 | """Recursively removes leftover .mp3.tmp files from the output directory.""" 780 | print("Performing startup cleanup of temporary files...") 781 | cleanup_count = 0 782 | try: 783 | # Use rglob to find all matching files recursively 784 | tmp_files = list(output_base.rglob(f"*{TEMP_SUFFIX}")) 785 | if not tmp_files: 786 | print("No leftover temporary files found.") 787 | return 788 | 789 | for tmp_file in tqdm(tmp_files, desc="Cleaning up", unit="file", leave=False, ncols=100): 790 | if tmp_file.is_file(): # Ensure it's a file before deleting 791 | try: 792 | tmp_file.unlink() 793 | # Use tqdm.write for logging within the loop if needed, but might be too verbose 794 | # tqdm.write(f"Removed leftover temporary file: {tmp_file.relative_to(output_base)}") 795 | cleanup_count += 1 796 | except OSError as e: 797 | tqdm.write(f"Warning: Could not remove temporary file {tmp_file}: {e}", file=sys.stderr) 798 | except Exception as e: # Catch broader exceptions during unlink 799 | tqdm.write(f"Warning: Error removing temp file {tmp_file}: {e}", file=sys.stderr) 800 | 801 | except Exception as e: 802 | # Catch errors during the rglob scan itself 803 | tqdm.write(f"Error during startup cleanup scan in {output_base}: {e}", file=sys.stderr) 804 | 805 | print(f"Startup cleanup complete. Removed {cleanup_count} temporary files.") 806 | print("-" * 30) 807 | 808 | 809 | # --- Main Execution --- 810 | 811 | def main(): 812 | """Main script execution function.""" 813 | args = parse_arguments() 814 | # Validation now returns vbr_quality instead of bitrate 815 | source_path, output_path_base, threads, vbr_quality, add_metadata_flag = validate_args_and_paths(args) 816 | 817 | # --- Initial Cleanup --- 818 | initial_cleanup(output_path_base) 819 | 820 | # --- Find Executables --- 821 | ffmpeg_executable = args.ffmpeg or find_executable("ffmpeg") 822 | ffprobe_executable = args.ffprobe or find_executable("ffprobe") 823 | if not ffmpeg_executable or not ffprobe_executable: 824 | sys.exit(1) 825 | print("-" * 30) 826 | 827 | # --- Load Album Art (do this once) --- 828 | album_art_data, album_art_mime = load_album_art(args.album_art) 829 | album_art_provided = bool(album_art_data) # Flag if art was successfully loaded 830 | print("-" * 30) 831 | 832 | # --- Find and Filter Files --- 833 | # find_video_files now skips ignored folders 834 | all_video_files = find_video_files(source_path) 835 | total_files_found = len(all_video_files) # This count already excludes ignored folders 836 | if total_files_found == 0: 837 | print("No video files found matching extensions (or all were in ignored folders):", ', '.join(SUPPORTED_EXTENSIONS)) 838 | sys.exit(0) 839 | 840 | files_to_process, skipped_initially_count = filter_existing_files( 841 | all_video_files, source_path, output_path_base, args.overwrite 842 | ) 843 | # Actual number submitted to the pool 844 | actual_submitted_count = len(files_to_process) 845 | 846 | if skipped_initially_count > 0: 847 | print(f"Skipped {skipped_initially_count} files as corresponding final MP3s already exist (use -o to overwrite).") 848 | 849 | if actual_submitted_count == 0: 850 | if skipped_initially_count > 0: 851 | print("No new files to process (all remaining found files already have existing outputs).") 852 | else: 853 | # This means files were found, but filter_existing removed them all 854 | print("No files left to process after checking for existing output.") 855 | sys.exit(0) 856 | 857 | 858 | print("-" * 30) 859 | 860 | # --- Processing Information --- 861 | print(f"Starting processing for {actual_submitted_count} files using {threads} threads...") 862 | print(f"Source: {source_path}") 863 | print(f"Output: {output_path_base}") 864 | # Print VBR quality instead of bitrate 865 | print(f"VBR Quality: {vbr_quality} (-q:a {vbr_quality} for re-encoding)") 866 | print(f"Overwrite: {'Yes' if args.overwrite else 'No'}") 867 | print(f"Add Text Meta:{'Yes' if add_metadata_flag else 'No'}") 868 | print(f"Add Album Art:{'Yes' if album_art_provided else 'No'}") 869 | if IGNORE_FOLDERS: # Remind user about ignored folders if list is active 870 | print(f"Ignoring: {', '.join(IGNORE_FOLDERS)}") 871 | print("-" * 30) 872 | 873 | # --- Initialize Counters --- 874 | success_count = copied_count = converted_count = failed_count = 0 875 | skipped_during_process_count = 0 # Skipped due to race condition (output appeared) 876 | skipped_no_audio_count = 0 # Skipped specifically due to lack of audio stream 877 | metadata_added_count = metadata_skipped_count = metadata_failed_count = 0 878 | 879 | # --- Process Files --- 880 | # Pass vbr_quality instead of bitrate 881 | results_iterator = process_files_concurrently( 882 | files_to_process, source_path, output_path_base, vbr_quality, 883 | ffmpeg_executable, ffprobe_executable, args.overwrite, add_metadata_flag, 884 | album_art_data, album_art_mime, 885 | threads 886 | ) 887 | 888 | 889 | for status, input_path, output_or_error, action, meta_status in results_iterator: 890 | # Determine display name safely 891 | rel_input_display = "Unknown Path" 892 | if isinstance(input_path, Path): 893 | try: 894 | rel_input_display = str(input_path.relative_to(source_path)) 895 | except ValueError: 896 | rel_input_display = str(input_path) # Fallback 897 | 898 | # Update counters based on results 899 | if status == 'success': 900 | success_count += 1 901 | if action == 'copied': copied_count += 1 902 | # Check if action indicates conversion (might contain VBR Qx) 903 | elif 'converted' in action: converted_count += 1 904 | 905 | # Count metadata success based on its specific status 906 | if meta_status == 'added': metadata_added_count += 1 907 | elif meta_status == 'skipped': metadata_skipped_count += 1 908 | # 'not_attempted' doesn't increment any metadata counter here 909 | # 'failed' metadata status would have led to overall 'failed' status below 910 | 911 | elif status == 'failed': 912 | failed_count += 1 913 | # Log the failure reason using tqdm.write to avoid messing up progress bar 914 | # Add more context to the failure message 915 | tqdm.write(f"\nFAILED : {rel_input_display} (Action attempted: {action})\n Reason: {output_or_error}", file=sys.stderr) 916 | # If the failure was *specifically* due to failed metadata tagging, increment that counter too 917 | # (Note: metadata failure already caused the 'failed' status in convert_single_video) 918 | if meta_status == 'failed': 919 | metadata_failed_count += 1 920 | 921 | elif status == 'skipped': 922 | skipped_during_process_count += 1 923 | # Optionally log these skips if they are unexpected (e.g., race condition) 924 | tqdm.write(f"INFO: Skipped during processing: {rel_input_display} - Reason: {output_or_error}", file=sys.stderr) 925 | 926 | elif status == 'skipped_no_audio': # Handle the new status 927 | skipped_no_audio_count += 1 928 | # Log these skips informatively 929 | tqdm.write(f"INFO: Skipped (No Audio): {rel_input_display} - Reason: {output_or_error}", file=sys.stdout) # Use stdout for info 930 | 931 | # --- Print Summary --- 932 | print_summary( 933 | total_files_found, # Files found after ignoring folders 934 | skipped_initially_count, 935 | skipped_no_audio_count, 936 | actual_submitted_count, # Use the actual number submitted 937 | success_count, copied_count, converted_count, failed_count, 938 | skipped_during_process_count, 939 | metadata_added_count, metadata_skipped_count, metadata_failed_count, 940 | add_metadata_flag, album_art_provided 941 | ) 942 | 943 | # --- Determine Exit Code --- 944 | # Consider only genuine conversion/file/tagging errors for non-zero exit code. 945 | # Skipped files (existing, no audio) are not errors in this context. 946 | final_error_count = failed_count 947 | 948 | if final_error_count > 0: 949 | print(f"\nWARNING: {final_error_count} files encountered errors during processing. Check logs above.", file=sys.stderr) 950 | sys.exit(1) 951 | elif total_files_found == 0: # Already handled earlier, but catch here too 952 | print("\nNo processable video files found in the specified source (considering ignored folders).") 953 | sys.exit(0) 954 | elif actual_submitted_count == 0: # Files were found, but all skipped before processing 955 | if skipped_initially_count > 0: 956 | print("\nProcessing complete. All found files were skipped because output already existed.") 957 | else: # Should not happen based on earlier checks, but covers edge cases 958 | print("\nProcessing complete. No files were submitted for conversion.") 959 | sys.exit(0) 960 | elif success_count == 0 and skipped_no_audio_count > 0 and failed_count == 0 and skipped_during_process_count == 0: 961 | print("\nProcessing complete. All submitted files were skipped due to lacking audio streams.") 962 | sys.exit(0) 963 | # Add other conditions if needed (e.g., only skips occurred) 964 | else: 965 | # Covers cases with success, or a mix of success/skips without errors 966 | print("\nAll tasks completed.") 967 | sys.exit(0) 968 | 969 | 970 | if __name__ == "__main__": 971 | try: 972 | main() 973 | except KeyboardInterrupt: 974 | print("\n\nProcess interrupted by user (Ctrl+C). Cleaning up may be needed on next run.", file=sys.stderr) 975 | # Exit code indicating interruption 976 | sys.exit(130) 977 | -------------------------------------------------------------------------------- /CheckTgBotHealthyAndRestart.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import subprocess 3 | import time 4 | from telethon import TelegramClient, events 5 | 6 | api_id = '' 7 | api_hash = '' 8 | command = '' 9 | bot_username = '' 10 | bot_id = '' 11 | process_path = '' 12 | 13 | client = TelegramClient('userbot', api_id, api_hash) 14 | process = None 15 | last_response_time = time.time() - 10 16 | healthy_status = True 17 | check_interval = 30 18 | healthy_until = time.time() - check_interval 19 | async def start_process(): 20 | global process 21 | if process: 22 | process.kill() 23 | process = subprocess.Popen(['python3', process_path]) 24 | print('Started process') 25 | await client.send_message(bot_username, command) 26 | print('Sent command by restarting process') 27 | 28 | async def send_command(): 29 | global last_response_time, healthy_status 30 | while True: 31 | if healthy_status: 32 | await client.send_message(bot_username, command) 33 | print('Sent command') 34 | else: 35 | print('Bot is unhealthy, skip sending command') 36 | await asyncio.sleep(check_interval) # 5 minutes 37 | 38 | @client.on(events.NewMessage) 39 | async def my_event_handler(event): 40 | global last_response_time,healthy_until 41 | if str(event.sender_id) == bot_id: 42 | healthy_until = time.time() + check_interval + 10 43 | print('Received response') 44 | last_response_time = time.time() 45 | 46 | async def check_response(): 47 | global last_response_time, healthy_status 48 | asyncio.ensure_future(send_command()) 49 | while True: 50 | if time.time() > healthy_until: 51 | print('Bot is unhealthy, restarting') 52 | await start_process() 53 | healthy_status = False 54 | else: 55 | healthy_status = True 56 | await asyncio.sleep(1) # check every second 57 | 58 | async def main(): 59 | await start_process() 60 | await check_response() 61 | 62 | client.start(phone='YOURPHONE') 63 | print('Client started') 64 | client.loop.run_until_complete(main()) 65 | 66 | -------------------------------------------------------------------------------- /CleanGITHUBArtifacts.js: -------------------------------------------------------------------------------- 1 | // install axios first!: yarn add axios 2 | // based on https://www.meziantou.net/deleting-github-actions-artifacts-using-the-github-rest-api.htm 3 | const axios = require('axios'); 4 | 5 | // https://github.com/settings/tokens/new 6 | const githubToken = 'ghp_xxxxxxxxxxxxxxxxxx' 7 | 8 | const httpClient = axios.create({ 9 | baseURL: 'https://api.github.com', 10 | headers: { 11 | 'Accept': 'application/vnd.github.v3+json', 12 | 'Authorization': `token ${githubToken}`, 13 | 'User-Agent': 'ArtifactsCleaner/1.0' 14 | } 15 | }); 16 | 17 | async function getAllProjects() { 18 | try { 19 | let page = 1; 20 | const pageSize = 100; 21 | let allProjects = []; 22 | let response; 23 | 24 | do { 25 | response = await httpClient.get(`/user/repos?per_page=${pageSize}&page=${page}`); 26 | allProjects = allProjects.concat(response.data.map(repo => repo.full_name)); 27 | page++; 28 | } while (response.data.length === pageSize); 29 | 30 | return allProjects; 31 | } catch (err) { 32 | console.error('Error fetching repositories:', err); 33 | return []; 34 | } 35 | } 36 | 37 | async function deleteOldArtifacts(projects) { 38 | console.log(`Exploring ${projects.length} repos...`); 39 | 40 | for (const project of projects) { 41 | let pageIndex = 1; 42 | const pageSize = 100; 43 | let page; 44 | 45 | do { 46 | const url = `/repos/${project}/actions/artifacts?per_page=${pageSize}&page=${pageIndex}`; 47 | try { 48 | const response = await httpClient.get(url); 49 | page = response.data; 50 | for (const item of page.artifacts) { 51 | if (!item.expired && new Date(item.created_at) < new Date(Date.now() - 24 * 60 * 60 * 1000)) { 52 | const deleteUrl = `/repos/${project}/actions/artifacts/${item.id}`; 53 | try { 54 | await httpClient.delete(deleteUrl); 55 | console.log(`Deleted: ${item.name} created at ${item.created_at} from ${project}`); 56 | } catch (deleteErr) { 57 | console.error(`Error ${deleteErr} deleting artifact: ${item.name} in ${project}, ${deleteErr}`); 58 | } 59 | } 60 | } 61 | pageIndex++; 62 | } catch (err) { 63 | console.error(`Error retrieving artifacts for ${project}: ${err}`); 64 | break; 65 | } 66 | } while (page.artifacts.length >= pageSize); 67 | } 68 | } 69 | 70 | async function cleanUpArtifacts() { 71 | const projects = await getAllProjects(); 72 | await deleteOldArtifacts(projects); 73 | } 74 | 75 | cleanUpArtifacts(); 76 | -------------------------------------------------------------------------------- /FixEncoding4AudioMetadata.py: -------------------------------------------------------------------------------- 1 | import os 2 | import chardet 3 | from mutagen.mp3 import MP3 4 | from mutagen.flac import FLAC 5 | from mutagen.oggopus import OggOpus 6 | from mutagen.wave import WAVE 7 | from mutagen.easyid3 import EasyID3 8 | 9 | def detect_and_correct_encoding(text): 10 | encodings_to_try = [ 11 | 'shift_jis', 'utf-8', 'latin1', 'cp1252', 'iso-8859-1', 'iso-8859-2', 12 | 'iso-8859-15', 'cp1251', 'cp1253', 'cp1254', 'cp1255', 'cp1256', 13 | 'cp1257', 'cp1258', 'big5', 'gb2312', 'gbk', 'euc-kr', 'euc-jp' 14 | ] 15 | for enc in encodings_to_try: 16 | try: 17 | # Attempt to decode the text using the current encoding 18 | decoded_text = text.encode('latin1').decode(enc) 19 | # Detect if the newly decoded text is valid UTF-8 20 | if detect_encoding(decoded_text.encode()) == 'utf-8': 21 | return decoded_text 22 | except (UnicodeDecodeError, UnicodeEncodeError): 23 | continue 24 | return text # If no valid decoding found, return the original text 25 | 26 | def detect_encoding(text): 27 | result = chardet.detect(text) 28 | return result['encoding'] 29 | 30 | def convert_encoding_if_needed(file_path, audio): 31 | if 'title' in audio: 32 | title = audio['title'][0] 33 | corrected_title = detect_and_correct_encoding(title) 34 | if corrected_title != title: 35 | try: 36 | audio['title'] = corrected_title 37 | audio.save() 38 | print(f"Converted title encoding for {file_path} to UTF-8") 39 | except Exception as e: 40 | print(f"Failed to convert title encoding for {file_path}: {e}") 41 | 42 | if 'artist' in audio: 43 | artist = audio['artist'][0] 44 | corrected_artist = detect_and_correct_encoding(artist) 45 | if corrected_artist != artist: 46 | try: 47 | audio['artist'] = corrected_artist 48 | audio.save() 49 | print(f"Converted artist encoding for {file_path} to UTF-8") 50 | except Exception as e: 51 | print(f"Failed to convert artist encoding for {file_path}: {e}") 52 | 53 | def process_file(file_path): 54 | try: 55 | if file_path.endswith('.mp3'): 56 | audio = MP3(file_path, ID3=EasyID3) 57 | elif file_path.endswith('.flac'): 58 | audio = FLAC(file_path) 59 | elif file_path.endswith('.wav'): 60 | audio = WAVE(file_path) 61 | elif file_path.endswith('.opus'): 62 | audio = OggOpus(file_path) 63 | else: 64 | print(f"Unsupported file format: {file_path}") 65 | return 66 | 67 | convert_encoding_if_needed(file_path, audio) 68 | except Exception as e: 69 | print(f"Failed to process {file_path}: {e}") 70 | 71 | def process_directory(directory): 72 | for root, _, files in os.walk(directory): 73 | for file in files: 74 | if file.endswith(('.mp3', '.flac', '.wav', '.opus')): 75 | file_path = os.path.join(root, file) 76 | process_file(file_path) 77 | 78 | if __name__ == "__main__": 79 | directory = input("Enter the directory path containing audio files: ") 80 | process_directory(directory) 81 | -------------------------------------------------------------------------------- /GalgameMoveToSubFolder.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import re 4 | import datetime 5 | source_folder = "/path/to/files/waiting/to be move" 6 | destination_folder = "/path/to/folders/where/your/sub folder are" 7 | ignore_folders = () 8 | known_existing = () 9 | # Get list of files in source folder 10 | files = os.listdir(source_folder) 11 | #abstraction moving 12 | def my_move(file, source_folder, folder_path): 13 | #detect whether the folder exists 14 | if not os.path.exists(folder_path): 15 | os.makedirs(folder_path, exist_ok=True) 16 | try: 17 | shutil.move(os.path.join(source_folder, file), folder_path) 18 | except shutil.Error as e: 19 | print(f"Error occurred while moving {file} to {folder_path}: {e}") 20 | print("Here are two files' info for you to choose:") 21 | file1 = os.path.join(source_folder, file) 22 | file2 = os.path.join(folder_path, file) 23 | print(f"File 1 (Waiting to move): {file1}") 24 | print(f"Size: {os.path.getsize(file1)}") 25 | print(f"Last modified: {datetime.datetime.fromtimestamp(os.path.getmtime(file1))}") 26 | print(f"File 2 (Already in dest): {file2}") 27 | print(f"Size: {os.path.getsize(file2)}") 28 | print(f"Last modified: {datetime.datetime.fromtimestamp(os.path.getmtime(file2))}") 29 | choice = input("Please enter the file number to keep, or type 3 to enter a new name for new file:") 30 | if choice == "1": 31 | os.remove(file2) 32 | shutil.move(file1, folder_path) 33 | elif choice == "2": 34 | return 35 | elif choice == "3": 36 | new_name = input("Please enter the new name:") 37 | shutil.move(file1, os.path.join(folder_path, new_name)) 38 | for file in files: 39 | # cut first 8 characters 40 | proc_file = file[8:] 41 | club_name = re.search(r"\[(.*?)\]", proc_file).group(1) 42 | print("Found club name:", club_name," in file:", file) 43 | # Search for matching folder in destination folder (excluding source folder) 44 | matching_folders = [] 45 | name = [] 46 | for folders in os.listdir(destination_folder): 47 | #if is a file, skip 48 | if os.path.isfile(os.path.join(destination_folder, folders)): 49 | continue 50 | temp_root = os.path.join(destination_folder, folders) 51 | print("Checking folder:", temp_root, "for", club_name, "in", file, "...") 52 | if temp_root == source_folder: 53 | continue 54 | if any(temp_root.startswith(x) for x in ignore_folders): 55 | continue 56 | for root, dirs, files in os.walk(temp_root): 57 | record_root = root 58 | for dir in dirs: 59 | if club_name.lower() in dir.lower(): 60 | matching_folders.append(os.path.join(root, dir)) 61 | name.append(dir) 62 | for file_loop in files: 63 | proc_ori_file = file_loop[8:] 64 | match = re.search(r"\[(.*?)\]", proc_ori_file) 65 | if match: 66 | ori_club_name = match.group(1) 67 | if club_name.lower() == ori_club_name.lower(): 68 | if record_root not in matching_folders: 69 | #append folder 70 | matching_folders.append(record_root) 71 | #add file to name 72 | name.append(file_loop) 73 | # Move file to matching folder 74 | if len(matching_folders) == 0: 75 | print(f"No matching folder found for {file}") 76 | # folder_path = input("Please enter the folder path:") 77 | # #if nothing is entered, skip 78 | # if folder_path == "": 79 | # continue 80 | 81 | #4 choices: 82 | #1. create new folder with club name 83 | #2. create new folder with custom name 84 | #3. choose existing folder 85 | #4. skip 86 | #print instructions 87 | print("Search with Google: https://www.google.com/search?q="+club_name.replace(" ","+")) 88 | print("1. Create new folder \"",os.path.join(destination_folder,club_name),"\"") 89 | print("2. Create new folder with custom name") 90 | print("3. Choose existing folder") 91 | print("4. Skip") 92 | if "steam" in file.lower(): 93 | print("5. Guessed from steam, use:"+known_existing[0]) 94 | choice = int(input("Please enter the folder number:")) 95 | if choice == 1: 96 | folder_path = os.path.join(destination_folder,club_name) 97 | os.mkdir(folder_path) 98 | elif choice == 2: 99 | folder_path = input("Please enter the folder path:") 100 | os.mkdir(folder_path) 101 | elif choice == 3: 102 | if len(known_existing) == 0: 103 | print("No known existing folders.") 104 | folder_path = input("Please enter the folder path:") 105 | else: 106 | print("Choose an known existing folders, or manually type it in:") 107 | for i in range(len(known_existing)): 108 | print(f"{i+1}. {known_existing[i]}") 109 | choice = input("Please enter the folder number / other paths:") 110 | #if choice is int 111 | if choice.isdigit(): 112 | folder_path = known_existing[int(choice)-1] 113 | else: 114 | folder_path = choice 115 | elif choice == 4: 116 | continue 117 | elif choice == 5: 118 | folder_path = known_existing[0] 119 | else: 120 | raise ValueError("Invalid choice") 121 | my_move(file, source_folder, folder_path) 122 | elif len(matching_folders) == 1: 123 | folder_path = matching_folders[0] 124 | print(f"Moving {file} to {folder_path} because {name[0]} found") 125 | my_move(file, source_folder, folder_path) 126 | else: 127 | print(f"Multiple matching folders found for {file}:") 128 | for i, folder in enumerate(matching_folders): 129 | print(f"{i+1}. {folder} because {name[i]} found") 130 | choice = int(input("Please enter the folder number:")) 131 | if choice < 1 or choice > len(matching_folders): 132 | print("Invalid choice") 133 | choice = int(input("Please enter the folder number:")) 134 | continue 135 | folder_path = matching_folders[choice-1] 136 | print(f"Moving {file} to {folder_path}") 137 | my_move(file, source_folder, folder_path) 138 | -------------------------------------------------------------------------------- /ImportMEGALinks.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | import os 4 | from tqdm import tqdm 5 | 6 | def read_links(file_path): 7 | """Read and return all non-empty lines from the links file.""" 8 | with open(file_path, 'r') as f: 9 | lines = [line.strip() for line in f if line.strip()] 10 | return lines 11 | 12 | def write_links(file_path, links): 13 | """Write the remaining links back to the links file.""" 14 | with open(file_path, 'w') as f: 15 | for link in links: 16 | f.write(f"{link}\n") 17 | 18 | def import_link(link, destination): 19 | """ 20 | Import a single MEGA link to mega drive using mega-import. 21 | Returns True if import is successful, False otherwise. 22 | """ 23 | try: 24 | # Call mega-import with the link and destination directory 25 | result = subprocess.run( 26 | ['mega-import', link, destination], 27 | text=True 28 | ) 29 | if result.returncode == 0: 30 | return True 31 | else: 32 | print(f"Error importing {link}:\n{result.stderr}") 33 | return False 34 | except Exception as e: 35 | print(f"Exception occurred while importing {link}: {e}") 36 | return False 37 | 38 | def main(): 39 | links_file = 'links.txt' # Path to your links.txt 40 | destination_dir = '/asmr' # Destination directory 41 | 42 | # Check if links file exists 43 | if not os.path.isfile(links_file): 44 | print(f"Links file '{links_file}' does not exist.") 45 | sys.exit(1) 46 | # Read all links 47 | links = read_links(links_file) 48 | 49 | if not links: 50 | print("No links to process.") 51 | sys.exit(0) 52 | 53 | # Logins to MEGA 54 | subprocess.run(['mega-login', 'EMAIL', 'PASSWORD'], text=True) 55 | 56 | # Initialize tqdm progress bar 57 | with tqdm(total=len(links), desc="Downloading files") as pbar: 58 | for index, link in enumerate(links[:]): # Iterate over a copy of the list 59 | print(f"Starting download: {link}") 60 | success = import_link(link, destination_dir) 61 | if success: 62 | # Remove the imported link from the list 63 | links.remove(link) 64 | # Write the updated list back to the file 65 | write_links(links_file, links) 66 | pbar.update(1) 67 | print(f"Successfully imported and removed: {link}") 68 | else: 69 | print(f"Aborting due to download failure: {link}") 70 | sys.exit(1) # Exit the script with error 71 | 72 | print("All files imported successfully.") 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /M365WholeTenantOnedriveToRcloneConf.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import requests 3 | tenant_id="Your Tenant ID" 4 | client_credentials = [ 5 | ("AppID","App Secret"), 6 | ] 7 | 8 | # function to get the access and refresh tokens 9 | def get_tokens(client_id, client_secret, tenant_id, username, password): 10 | #get refresh token and access token 11 | url = "https://login.microsoftonline.com/{}/oauth2/v2.0/token".format(tenant_id) 12 | data = { 13 | "client_id": client_id, 14 | "client_secret": client_secret, 15 | "grant_type": "password", 16 | #offline_access is required to get a refresh token 17 | "scope": "https://graph.microsoft.com/.default offline_access", 18 | "username": username, 19 | "password": password 20 | } 21 | response = requests.post(url, data=data) 22 | return response.json()["access_token"], response.json()["refresh_token"] 23 | # function to get the drive id 24 | def get_drive_id(access_token): 25 | # construct the drive request url 26 | url = "https://graph.microsoft.com/v1.0/me/drive" 27 | 28 | # construct the drive request headers 29 | headers = { 30 | "Authorization": "Bearer " + access_token 31 | } 32 | 33 | # send the drive request and get the response 34 | response = requests.get(url, headers=headers) 35 | print("Response: "+response.text) 36 | # return the drive id 37 | return response.json()["id"] 38 | def get_full_name(access_token): 39 | url="https://graph.microsoft.com/v1.0/me" 40 | headers = { 41 | "Authorization": "Bearer " + access_token 42 | } 43 | response = requests.get(url, headers=headers) 44 | return response.json()["displayName"] 45 | def get_client_id_secret(now_row): 46 | index = now_row % len(client_credentials) 47 | client_id = client_credentials[index][0] 48 | client_secret = client_credentials[index][1] 49 | print("Now using:",client_id,"now_row:",now_row) 50 | return client_id, client_secret 51 | # read the csv file containing the office 365 accounts and passwords 52 | with open("accounts.csv", "r") as csvfile: 53 | reader = csv.reader(csvfile) 54 | # next(reader) # skip the header row 55 | now_row=0 56 | # open the rclone.conf file for appending 57 | with open("rclone.conf", "a") as conf: 58 | for row in reader: 59 | username = row[0] 60 | password = row[1] 61 | client_id, client_secret = get_client_id_secret(now_row) 62 | # get the access and refresh tokens 63 | access_token, refresh_token = get_tokens(client_id, client_secret, tenant_id, username, password) 64 | 65 | # get the drive id 66 | drive_id = get_drive_id(access_token) 67 | #get full name 68 | 69 | #get expiry 70 | #username=get_full_name(access_token) 71 | #username: a@c.com-> a 72 | username= username.split("@")[0] 73 | print("username:",username) 74 | # construct the rclone config entry 75 | config_entry = "[{}]\ntype=onedrive\nclient_id={}\nclient_secret={}\ntoken={{\"access_token\":\"{}\",\"token_type\":\"Bearer\",\"refresh_token\":\"{}\",\"expiry\":\"2022-11-05T14:43:09.1206112+08:00\"}}\ndrive_id={}\ndrive_type=business\n".format(username, client_id, client_secret, access_token, refresh_token, drive_id) 76 | # append the config entry to the rclone.conf file 77 | conf.write(config_entry) 78 | now_row+=1 79 | -------------------------------------------------------------------------------- /ProxyRedirect.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 3 | from apscheduler.triggers.cron import CronTrigger 4 | import asyncio 5 | import flask 6 | import random 7 | import threading 8 | from waitress import serve 9 | import time 10 | 11 | #storage url and status 12 | proxies=[ 13 | {"url":"https://example1.com","status":True}, 14 | {"url":"https://example2.com","status":True}, 15 | 16 | ] 17 | def check_alive(url): 18 | try: 19 | r = requests.get(url, timeout=10) 20 | if r.status_code == 200: 21 | return True 22 | else: 23 | return False 24 | except: 25 | return False 26 | 27 | app=flask.Flask(__name__) 28 | # route all requests to flask here 29 | # 302 to a random proxy address 30 | # for example: http://localhost:5000/1/b/c?d=e -> http://live_proxy.com/1/b/c?d=e 31 | @app.route('/', defaults={'path': ''}) 32 | @app.route('/') 33 | def catch_all(path): 34 | proxy=None 35 | #get random proxy in status=True 36 | proxy = random.choice([proxy_loop for proxy_loop in proxies if proxy_loop["status"]==True]) 37 | #check proxy status 38 | if proxy!=None: 39 | #preserve query string 40 | query_string=flask.request.query_string.decode("utf-8") 41 | #return flask.redirect(proxy["url"]+path, code=302) 42 | if query_string=="": 43 | return flask.redirect(proxy["url"]+'/'+path, code=302) 44 | else: 45 | return flask.redirect(proxy["url"]+'/'+path+'?'+query_string, code=302) 46 | else: 47 | #output in html: no proxy avaliable 48 | return flask.Response("No proxy avaliable", mimetype='text/html') 49 | 50 | 51 | def main(): 52 | print("Count: "+str(len(proxies))) 53 | for proxy in proxies: 54 | if check_alive(proxy["url"])==False: 55 | proxy["status"]=False 56 | print(str(proxies.index(proxy)+1)+": "+proxy["url"]+" is down") 57 | else: 58 | proxy["status"]=True 59 | print(str(proxies.index(proxy)+1)+": "+proxy["url"]+" is up") 60 | if proxy["url"].endswith("eu.org"): 61 | time.sleep(0.2) 62 | print("Alive: "+str(len([proxy for proxy in proxies if proxy["status"]==True]))) 63 | main() 64 | scheduler = AsyncIOScheduler() 65 | #check proxy every 5 minute 66 | scheduler.add_job(main, CronTrigger.from_crontab('*/5 * * * *'),misfire_grace_time=60,coalesce=True,max_instances=1) 67 | scheduler.start() 68 | def serve_in_thread(): 69 | serve(app, host='0.0.0.0',port=5000) 70 | thread = threading.Thread(target=serve_in_thread) 71 | thread.start() 72 | 73 | loop = asyncio.get_event_loop() 74 | loop.run_forever() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **ACGDB Open Source Project** 2 | 3 | This is a repository intend to make ACGDB Open Source. We want to make everyone(including you) to have a smooth and automatic experience when organizing files. 4 | 5 | ACGDB Team will continue to add different script that we use daily. Please stay focused. 6 | 7 | If you like this project, please give me a star. 8 | 9 | 10 | ## Link To ACGDB 11 | 12 | **ACG Database: [https://acgdb.de](https://acgdb.de)** 13 | 14 | **ACGDB Premium: [https://acgdb.cc](https://acgdb.cc)** 15 | -------------------------------------------------------------------------------- /RemovePasswordForArchive.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import re 4 | import shutil 5 | from typing import List, Optional, Tuple 6 | from concurrent.futures import ThreadPoolExecutor, as_completed 7 | import threading 8 | 9 | # Optional: Uncomment the following lines to use logging instead of print statements 10 | # import logging 11 | # logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 12 | 13 | 14 | def check_rar_installed(): 15 | """ 16 | Check if 'rar' executable is available in the system's PATH. 17 | """ 18 | try: 19 | subprocess.run(["rar"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) 20 | except (subprocess.CalledProcessError, FileNotFoundError): 21 | print("Error: 'rar' executable not found. Please install WinRAR and ensure 'rar.exe' is in your PATH.") 22 | exit(1) 23 | 24 | def is_password_protected(filepath: str, dummy_password: str = "worilepython") -> bool: 25 | """ 26 | Check if the archive is password protected by examining the 'Encrypted' status of files. 27 | 28 | Args: 29 | filepath (str): Path to the archive file. 30 | 31 | Returns: 32 | bool: True if the archive is password protected, False otherwise. 33 | """ 34 | try: 35 | result = subprocess.run( 36 | ["7z", "l", "-slt", f"-p{dummy_password}", filepath], 37 | check=True, 38 | stdout=subprocess.PIPE, 39 | stderr=subprocess.PIPE 40 | ) 41 | output = result.stdout.decode('utf-8') 42 | # Parse the output to find 'Encrypted = +' 43 | encrypted_files = re.findall(r'^Encrypted = \+', output, re.MULTILINE) 44 | if encrypted_files: 45 | return True 46 | else: 47 | return False 48 | except subprocess.CalledProcessError: 49 | # If an error occurs (e.g., archive is corrupted), assume it's password-protected 50 | return True 51 | 52 | def get_codec() -> str: 53 | """ 54 | Get the appropriate codec based on the operating system. 55 | 56 | Returns: 57 | str: 'utf-8' for all OS. 58 | """ 59 | return "utf-8" 60 | 61 | def handle_remove_readonly(func, path, exc): 62 | """ 63 | Handle "access denied" errors when deleting files/folders by changing permissions. 64 | 65 | Args: 66 | func: The function that raised the exception. 67 | path (str): Path to the file/folder. 68 | exc: The exception that was raised. 69 | """ 70 | os.chmod(path, 0o777) 71 | func(path) 72 | 73 | 74 | def extract_outpaths(output: bytes, codec: str) -> List[str]: 75 | """ 76 | Extract the 'Path' values from the archive listing output. 77 | 78 | Args: 79 | output (bytes): Output from the subprocess command. 80 | codec (str): The codec to decode the output. 81 | 82 | Returns: 83 | List[str]: List of paths extracted from the archive. 84 | """ 85 | outpaths = [] 86 | decoded_output = output.decode(codec).splitlines() 87 | for line in decoded_output: 88 | if line.startswith("Path = "): 89 | path = line.split(" = ", 1)[1] 90 | if os.name == 'nt': 91 | if '\\' not in path: 92 | outpaths.append(path) 93 | else: 94 | if '/' not in path: 95 | outpaths.append(path) 96 | return outpaths 97 | 98 | 99 | def execute_subprocess(command: List[str], **kwargs) -> subprocess.CompletedProcess: 100 | """ 101 | Execute a subprocess command with error handling. 102 | 103 | Args: 104 | command (List[str]): The command to execute. 105 | **kwargs: Additional arguments for subprocess.run. 106 | 107 | Returns: 108 | subprocess.CompletedProcess: The result of the subprocess execution. 109 | 110 | Raises: 111 | subprocess.CalledProcessError: If the subprocess fails. 112 | """ 113 | return subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) 114 | 115 | 116 | def recompress_archive( 117 | archive_path: str, 118 | outpaths: List[str], 119 | recompress_cmd: List[str] 120 | ): 121 | """ 122 | Recompress the extracted files into a RAR archive. 123 | 124 | Args: 125 | archive_path (str): Path to the new RAR archive. 126 | outpaths (List[str]): List of file/folder paths to include in the archive. 127 | recompress_cmd (List[str]): The command to recompress the archive. 128 | 129 | Raises: 130 | subprocess.CalledProcessError: If recompression fails. 131 | """ 132 | # Convert outpaths to absolute paths 133 | outpath_abs = [os.path.abspath(path) for path in outpaths] 134 | print(f"Running command: {' '.join(recompress_cmd)} {archive_path} {' '.join(outpath_abs)}") 135 | # logging.info(f"Running command: {' '.join(recompress_cmd)} {archive_path} {' '.join(outpath_abs)}") 136 | subprocess.run( 137 | recompress_cmd + [archive_path] + outpath_abs, 138 | check=True 139 | ) 140 | print(f"{archive_path} re-compressed successfully!") 141 | # logging.info(f"{archive_path} re-compressed successfully!") 142 | 143 | 144 | def clean_extracted_files(outpaths: List[str]): 145 | """ 146 | Remove extracted files and directories. 147 | 148 | Args: 149 | outpaths (List[str]): List of file/folder paths to remove. 150 | """ 151 | for path in outpaths: 152 | if os.path.isdir(path): 153 | shutil.rmtree(path, onerror=handle_remove_readonly) 154 | else: 155 | os.remove(path) 156 | 157 | 158 | class ArchiveHandler: 159 | """ 160 | A class to handle different types of archives, encapsulating their specific behaviors. 161 | """ 162 | 163 | def __init__(self, filepath: str, dirpath: str, filename: str, codec: str): 164 | self.filepath = filepath 165 | self.dirpath = dirpath 166 | self.filename = filename 167 | self.codec = codec 168 | 169 | def try_passwords( 170 | self, 171 | password_list: List[str], 172 | is_split: bool = False 173 | ) -> bool: 174 | """ 175 | Attempt to decrypt the archive using a list of passwords. 176 | 177 | Args: 178 | password_list (List[str]): List of passwords to try. 179 | is_split (bool): Indicates if the archive is split. 180 | 181 | Returns: 182 | bool: True if decryption was successful, False otherwise. 183 | """ 184 | for password in password_list: 185 | print(f"Trying password '{password}' for '{self.filepath}'") 186 | try: 187 | # Attempt to list the archive contents with the password 188 | list_cmd = self.get_list_command(password, is_split) 189 | list_result = execute_subprocess(list_cmd) 190 | outpaths = extract_outpaths(list_result.stdout, self.codec) 191 | 192 | if not outpaths: 193 | print(f"No contents found with password '{password}' for '{self.filepath}'.") 194 | continue 195 | 196 | # Attempt to decompress the archive 197 | decompress_cmd = self.get_decompress_command(password, is_split) 198 | decompress_result = execute_subprocess(decompress_cmd) 199 | 200 | # Check if extraction was successful 201 | if decompress_result.returncode != 0 or b"Wrong password" in decompress_result.stderr: 202 | print(f"Extraction failed with password '{password}' for '{self.filepath}'.") 203 | clean_extracted_files(outpaths) 204 | continue 205 | 206 | print(f"Password found for '{self.filepath}': {password}") 207 | 208 | # Proceed with recompression 209 | outpaths = [os.path.join(self.dirpath, p) for p in outpaths] 210 | self.recompress_archive(outpaths) 211 | 212 | # Clean up extracted files and original archive 213 | clean_extracted_files(outpaths) 214 | os.remove(self.filepath) 215 | return True 216 | 217 | except Exception as e: 218 | print(f"Error: {e}") 219 | continue 220 | return False 221 | 222 | def get_list_command(self, password: str, is_split: bool) -> List[str]: 223 | """ 224 | Get the command to list archive contents. 225 | 226 | Args: 227 | password (str): The password to use. 228 | is_split (bool): Indicates if the archive is split. 229 | 230 | Returns: 231 | List[str]: The command to list archive contents. 232 | """ 233 | raise NotImplementedError 234 | 235 | def get_decompress_command(self, password: str, is_split: bool) -> List[str]: 236 | """ 237 | Get the command to decompress the archive. 238 | 239 | Args: 240 | password (str): The password to use. 241 | is_split (bool): Indicates if the archive is split. 242 | 243 | Returns: 244 | List[str]: The command to decompress the archive. 245 | """ 246 | raise NotImplementedError 247 | 248 | def get_recompress_command(self) -> List[str]: 249 | """ 250 | Get the command to recompress the archive. 251 | 252 | Returns: 253 | List[str]: The command to recompress the archive. 254 | """ 255 | # Include '-r' to handle empty directories 256 | return ["rar", "a", "-ep1", "-r"] 257 | 258 | def recompress_archive(self, outpaths: List[str]): 259 | """ 260 | Recompress the extracted files into a RAR archive. 261 | 262 | Args: 263 | outpaths (List[str]): List of file/folder paths to include in the archive. 264 | """ 265 | recompress_cmd = self.get_recompress_command() 266 | recompress_archive(self.output_archive_path(), outpaths, recompress_cmd) 267 | 268 | def output_archive_path(self) -> str: 269 | """ 270 | Determine the output archive path. For standard archives, it remains the same. 271 | For split archives, it should be the base name without split indicators. 272 | 273 | Returns: 274 | str: The path to the recompressed archive. 275 | """ 276 | return self.filepath # Default behavior; to be overridden if needed 277 | 278 | 279 | class RARHandler(ArchiveHandler): 280 | """ 281 | Handler for RAR archives. 282 | """ 283 | 284 | def get_list_command(self, password: str, is_split: bool) -> List[str]: 285 | return ["7z", "l", f"-p{password}","-ba","-slt", self.filepath] 286 | 287 | def get_decompress_command(self, password: str, is_split: bool) -> List[str]: 288 | return ["7z", "x", f"-o{self.dirpath}", f"-p{password}", self.filepath, "-y"] 289 | 290 | def output_archive_path(self) -> str: 291 | """ 292 | For standard RARs, recompress into the same RAR file. 293 | """ 294 | return self.filepath 295 | 296 | 297 | class SplitRARHandler(ArchiveHandler): 298 | """ 299 | Handler for split RAR archives. 300 | """ 301 | 302 | def __init__(self, filepath: str, dirpath: str, filename: str, codec: str, split_files: List[str]): 303 | super().__init__(filepath, dirpath, filename, codec) 304 | self.split_files = split_files 305 | 306 | def get_list_command(self, password: str, is_split: bool) -> List[str]: 307 | # Only list the first split file 308 | return ["7z", "l", f"-p{password}","-ba","-slt", self.filepath] 309 | 310 | def get_decompress_command(self, password: str, is_split: bool) -> List[str]: 311 | return ["7z", "x", f"-o{self.dirpath}", f"-p{password}", self.filepath, "-y"] 312 | 313 | def output_archive_path(self) -> str: 314 | """ 315 | For split RARs, recompress into a single RAR file without split parts. 316 | """ 317 | base_name = re.sub(r'\.part\d+\.rar$', '', self.filename, flags=re.IGNORECASE) 318 | return os.path.join(self.dirpath, f"{base_name}.rar") 319 | 320 | def recompress_archive(self, outpaths: List[str]): 321 | """ 322 | Recompress the extracted files into a single RAR archive and remove split files. 323 | """ 324 | recompress_cmd = self.get_recompress_command() 325 | output_path = self.output_archive_path() 326 | recompress_archive(output_path, outpaths, recompress_cmd) 327 | # Remove split files after successful recompression 328 | for split_file in self.split_files: 329 | os.remove(split_file) 330 | 331 | 332 | class StandardArchiveHandler(ArchiveHandler): 333 | """ 334 | Handler for standard ZIP and 7z archives. 335 | """ 336 | 337 | def get_list_command(self, password: str, is_split: bool) -> List[str]: 338 | return ["7z", "l", f"-p{password}","-ba","-slt", self.filepath] 339 | 340 | def get_decompress_command(self, password: str, is_split: bool) -> List[str]: 341 | return ["7z", "x", f"-o{self.dirpath}", f"-p{password}", self.filepath, "-y"] 342 | 343 | def output_archive_path(self) -> str: 344 | """ 345 | For standard ZIP and 7z, recompress into a RAR archive with the same base name. 346 | """ 347 | base_name, _ = os.path.splitext(self.filename) 348 | return os.path.join(self.dirpath, f"{base_name}.rar") 349 | 350 | 351 | class SplitStandardArchiveHandler(ArchiveHandler): 352 | """ 353 | Handler for split ZIP and 7z archives. 354 | """ 355 | 356 | def __init__(self, filepath: str, dirpath: str, filename: str, codec: str, split_files: List[str]): 357 | super().__init__(filepath, dirpath, filename, codec) 358 | self.split_files = split_files 359 | 360 | def get_list_command(self, password: str, is_split: bool) -> List[str]: 361 | # Only list the first split file 362 | return ["7z", "l", f"-p{password}","-ba","-slt", self.filepath] 363 | 364 | def get_decompress_command(self, password: str, is_split: bool) -> List[str]: 365 | return ["7z", "x", f"-o{self.dirpath}", f"-p{password}", self.filepath, "-y"] 366 | 367 | def output_archive_path(self) -> str: 368 | """ 369 | For split ZIP/7z, recompress into a single RAR file without split parts. 370 | """ 371 | # Assuming the split files are named like file.zip.001, file.zip.002, etc. 372 | base_name = re.sub(r'\.(zip|7z)\.\d+$', '', self.filename, flags=re.IGNORECASE) 373 | return os.path.join(self.dirpath, f"{base_name}.rar") 374 | 375 | def recompress_archive(self, outpaths: List[str]): 376 | """ 377 | Recompress the extracted files into a single RAR archive and remove split files. 378 | """ 379 | recompress_cmd = self.get_recompress_command() 380 | output_path = self.output_archive_path() 381 | recompress_archive(output_path, outpaths, recompress_cmd) 382 | # Remove split files after successful recompression 383 | for split_file in self.split_files: 384 | os.remove(split_file) 385 | 386 | 387 | def determine_archive_handler( 388 | file_path: str, 389 | dirpath: str, 390 | filename: str, 391 | codec: str 392 | ) -> Tuple[Optional[ArchiveHandler], Optional[List[str]]]: 393 | """ 394 | Determine the appropriate archive handler based on the file type. 395 | 396 | Args: 397 | file_path (str): Path to the archive file. 398 | dirpath (str): Directory path where the file is located. 399 | filename (str): Name of the file. 400 | codec (str): Codec used for decoding subprocess output. 401 | 402 | Returns: 403 | Tuple[Optional[ArchiveHandler], Optional[List[str]]]: The handler instance and list of split files if applicable. 404 | """ 405 | # Check for split RAR files (e.g., file.part1.rar, file.part2.rar) 406 | split_rar_match = re.match(r"(.+)\.part(\d+)\.rar$", filename, re.IGNORECASE) 407 | if split_rar_match: 408 | base_name = split_rar_match.group(1) 409 | part_num = int(split_rar_match.group(2)) 410 | if part_num == 1: 411 | # Gather all split RAR parts 412 | split_files = sorted([ 413 | f for f in os.listdir(dirpath) 414 | if re.match(rf"{re.escape(base_name)}\.part\d+\.rar$", f, re.IGNORECASE) 415 | ]) 416 | split_paths = [os.path.join(dirpath, f) for f in split_files] 417 | if split_files: 418 | return RARHandler(file_path, dirpath, filename, codec), split_paths 419 | 420 | # Check for split ZIP or 7z files (e.g., file.zip.001, file.7z.001) 421 | split_standard_match = re.match(r"(.+)\.(zip|7z)\.(\d+)$", filename, re.IGNORECASE) 422 | if split_standard_match: 423 | base_name, ext, split_num = split_standard_match.groups() 424 | split_num = int(split_num) 425 | if split_num == 1: 426 | # Gather all split ZIP/7z parts 427 | split_files = sorted([ 428 | f for f in os.listdir(dirpath) 429 | if re.match(rf"{re.escape(base_name)}\.{re.escape(ext)}\.\d+$", f, re.IGNORECASE) 430 | ]) 431 | split_paths = [os.path.join(dirpath, f) for f in split_files] 432 | if split_files: 433 | return StandardArchiveHandler(file_path, dirpath, filename, codec), split_paths 434 | 435 | # Handle standard RAR files 436 | if filename.lower().endswith(".rar"): 437 | return RARHandler(file_path, dirpath, filename, codec), None 438 | 439 | # Handle standard ZIP and 7z files 440 | if filename.lower().endswith(('.zip', '.7z')): 441 | return StandardArchiveHandler(file_path, dirpath, filename, codec), None 442 | 443 | return None, None 444 | 445 | 446 | def remove_password_from_archive( 447 | handler: ArchiveHandler, 448 | split_files: Optional[List[str]], 449 | password_list: List[str] 450 | ) -> bool: 451 | """ 452 | Attempt to remove the password from an archive using the provided handler. 453 | 454 | Args: 455 | handler (ArchiveHandler): The archive handler instance. 456 | split_files (Optional[List[str]]): List of split file paths if applicable. 457 | password_list (List[str]): List of passwords to try. 458 | 459 | Returns: 460 | bool: True if the password was successfully removed, False otherwise. 461 | """ 462 | if isinstance(handler, RARHandler): 463 | return handler.try_passwords(password_list) 464 | elif isinstance(handler, SplitRARHandler): 465 | return handler.try_passwords(password_list, is_split=True) 466 | elif isinstance(handler, StandardArchiveHandler): 467 | return handler.try_passwords(password_list) 468 | elif isinstance(handler, SplitStandardArchiveHandler): 469 | return handler.try_passwords(password_list, is_split=True) 470 | else: 471 | return False 472 | 473 | 474 | def process_archive_file( 475 | file_info: Tuple[str, str, str], 476 | codec: str, 477 | password_list: List[str], 478 | processed_files: set, 479 | lock: threading.Lock 480 | ): 481 | """ 482 | Process a single archive file or a set of split archive files. 483 | 484 | Args: 485 | file_info (Tuple[str, str, str]): A tuple containing (dirpath, filename, file_path). 486 | codec (str): Codec used for decoding subprocess output. 487 | password_list (List[str]): List of passwords to try. 488 | processed_files (set): A thread-safe set to track already processed files. 489 | lock (threading.Lock): A lock to synchronize access to the processed_files set. 490 | """ 491 | dirpath, filename, file_path = file_info 492 | 493 | # Acquire the lock before checking and marking as processed 494 | with lock: 495 | if file_path in processed_files: 496 | return # Already processed as part of a split archive 497 | 498 | # Determine if the file is part of a split archive 499 | handler, split_files = determine_archive_handler(file_path, dirpath, filename, codec) 500 | if handler and split_files: 501 | # Mark all split files as processed to prevent other threads from handling them 502 | for split_file in split_files: 503 | processed_files.add(split_file) 504 | elif handler and not split_files: 505 | # Mark the single archive file as processed 506 | processed_files.add(file_path) 507 | else: 508 | # Not a supported archive type or already processed 509 | return 510 | 511 | # After releasing the lock, proceed with processing 512 | if handler: 513 | # Check if the archive is password protected 514 | if not is_password_protected(file_path): 515 | print(f"Skipping '{file_path}' as it is not password protected.") 516 | return 517 | 518 | try: 519 | if split_files: 520 | # Use appropriate handler for split archives 521 | if isinstance(handler, RARHandler): 522 | split_handler = SplitRARHandler(file_path, dirpath, filename, codec, split_files) 523 | else: 524 | split_handler = SplitStandardArchiveHandler(file_path, dirpath, filename, codec, split_files) 525 | success = remove_password_from_archive(split_handler, split_files, password_list) 526 | else: 527 | success = remove_password_from_archive(handler, None, password_list) 528 | 529 | if success: 530 | print(f"Successfully removed password from '{handler.output_archive_path()}'.") 531 | else: 532 | print(f"Failed to remove password from '{file_path}'.") 533 | except Exception as e: 534 | print(f"Error processing '{file_path}': {e}") 535 | else: 536 | print(f"Unsupported file type or already processed: '{file_path}'.") 537 | 538 | def remove_rar_password(directory: str, password_list: List[str], max_workers: int = 4): 539 | """ 540 | Traverse the directory and attempt to remove passwords from RAR, ZIP, and 7z archives. 541 | 542 | Args: 543 | directory (str): The root directory to start processing. 544 | password_list (List[str]): List of passwords to try. 545 | max_workers (int): The maximum number of threads to use for parallel processing. 546 | """ 547 | codec = get_codec() 548 | processed_files = set() 549 | lock = threading.Lock() 550 | 551 | # Collect all archive files first to avoid redundant os.walk during parallel processing 552 | archive_files = [] 553 | for dirpath, _, filenames in os.walk(directory): 554 | for filename in filenames: 555 | file_path = os.path.join(dirpath, filename) 556 | archive_files.append((dirpath, filename, file_path)) 557 | 558 | with ThreadPoolExecutor(max_workers=max_workers) as executor: 559 | # Submit all archive files to the executor 560 | futures = [ 561 | executor.submit(process_archive_file, file_info, codec, password_list, processed_files, lock) 562 | for file_info in archive_files 563 | ] 564 | 565 | # Optionally, monitor the progress 566 | for future in as_completed(futures): 567 | try: 568 | future.result() 569 | except Exception as e: 570 | print(f"Error processing archive: {e}") 571 | # logging.error(f"Error processing archive: {e}") 572 | 573 | 574 | if __name__ == "__main__": 575 | check_rar_installed() # Ensure 'rar' is available 576 | PASSWORD_LIST = [ 577 | "免费分享倒卖死妈", 578 | "xlxb1001" 579 | ] 580 | # TARGET_DIRECTORY = "Z:\\115\\ppasmr\\V#1 中文专辑名压缩包\\#1 DLsite" 581 | TARGET_DIRECTORY = "D:\\Tech\\rmpass-test" 582 | MAX_WORKERS = 1 # Adjust based on your CPU and I/O capabilities 583 | 584 | remove_rar_password(TARGET_DIRECTORY, PASSWORD_LIST, max_workers=MAX_WORKERS) 585 | --------------------------------------------------------------------------------