romlm 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+
2
+ The MIT License (MIT)
3
+
4
+ Copyright (c) 2024-2025 Ilia Petrov-Komotskii (Mane Function)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.1
2
+ Name: romlm
3
+ Version: 1.0.1
4
+ Summary: A ROM library management tool.
5
+ Author-email: Ilia Petrov-Komotskii <ilia@inkedkettle.art>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ManeFunction/romlm
8
+ Project-URL: Repository, https://github.com/ManeFunction/romlm
9
+ Keywords: ROM,library,management,tools
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.6
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: py7zr==0.22.0
17
+ Requires-Dist: tqdm==4.67.1
18
+ Requires-Dist: colorama==0.4.6
19
+
20
+ # romlm (ROMs Library Manager)
21
+
22
+ A Python-based command line tool that helps you organize and manage your ROM collections effortlessly.
23
+ Whether you want to pack, unpack, sort, or remove duplicates, **romlm** has got you covered.
24
+
25
+ All features are tested on the no-intro ROM sets (which I personally recommend), but it should work with any
26
+ ROM collection that follows a similar naming convention.
27
+ If you have some issues with other ROM sets, that can't be worked-around with the current implementation,
28
+ feel free to open an issue on the [GitHub issues](https://github.com/ManeFunction/romlm/issues) page.
29
+
30
+ ---
31
+
32
+ ## Features
33
+
34
+ - **Extract (`-x, --extract`)**
35
+ Automatically unpacks all `.7z` or `.zip` files in the current directory (or a specified folder) into subfolders.
36
+ Supports nested subfolders, keeping all the structure.
37
+
38
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/extract.png)
39
+
40
+
41
+ - **Pack (`-p, --pack`)**
42
+ Compresses all uncompressed files in the current directory (or a specified folder) into `.7z` or `.zip` archives.
43
+ Supports nested subfolders, keeping all the structure.
44
+
45
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/pack.png)
46
+
47
+
48
+ - **Sort (`-s, --sort`)**
49
+ Moves files into alphabetically organized subfolders (A–Z). Optionally handles special folders for
50
+ Homebrew (`!Homebrew`), Pirates (`!Pirates`), or user-defined subfolders (`-f`).
51
+ - `h` will also sort files into homebrew subfolder.
52
+ - `p` will also sort files into pirates subfolder.
53
+ - `f` will also sort files into user-defined subfolders (`-f`).
54
+ - `a` will also sort files into homebrew, pirates and all user-defined subfolders (`-f`). Equal to `hpf`.
55
+ - You can also use any combination of `h`, `p` and `f` options, for example `hp` or `fh`.
56
+ - `-s reverse` will reverse-sort files, i.e., move them all back into the root folder.
57
+
58
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/sort.png)
59
+
60
+
61
+ - **Separate Homebrew and Pirate ROMs (`-u, --unlicensed`)**
62
+ By default, romlm detects and separates Homebrew or Pirate files into dedicated folders. Can be customized with
63
+ options:
64
+ - `none` disables separation completely,
65
+ - `h` separates Homebrew files only,
66
+ - `p` separates Pirate files only.
67
+
68
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/unlicensed.png)
69
+
70
+
71
+ - **Remove Duplicates (`-r, --remove-duplicates`)**
72
+ Automatically detects duplicate files, preserving the best candidate according
73
+ to region, beta status, revision, version, release date and other data that can be received from the filename.
74
+ Behavior for the situations when script can't decide the best file itself, can be specified:
75
+ - `ask` prompts you which file to keep, default with `-l`,
76
+ - `all` keeps all equally good files, default without `-l`,
77
+ - `one` keeps exactly one of the best ROMs, taken randomly.
78
+
79
+ I highly recommend to **use this feature with `-l` option** to see the results, and **make a backup of your ROMs before**!
80
+
81
+ In the current scenario it removes all the beta and prototype files if there is a final version of the game exists.
82
+ If not, it tries to keep only one the latest version. USA retro ROMs (not Virtual Console versions) counts as
83
+ the best version of the game. Also, Europe and EN releases have a higher priority over JP and Asia versions,
84
+ as well as NTSC counts better, than PAL. **romlm** will never remove all copies of one game!
85
+
86
+ If you want to keep a Japanese collection intact, I recommend to separate it first. See Usage Examples below.
87
+
88
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/remove.png)
89
+
90
+
91
+ - **User-defined Folders (`-f, --folders`)**
92
+ Easily map certain tags to user-defined folders. For example, specifying `-f Japan` will move ROMs tagged
93
+ as `(Japan)` into dedicated subfolder. Works only with `--sort` process as a part of it. Search only within tags,
94
+ to not be confused with a game name, so if you just want all your Mario games in one place, just search it manually,
95
+ **romlm** not for that.
96
+
97
+ - **Exclude Tags (`-e, --exclude`)**
98
+ Combine with `-f` to skip specified tags from the subfolders sorting process. For example, `-f Japan -e USA`
99
+ will move all `(Japan)` tagged ROMs into a dedicated subfolder, excluding any `(USA)` tagged ROMs
100
+ if `(Japan, USA)` combination is met.
101
+
102
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/subfolder.png)
103
+
104
+
105
+ - **Logging (`-l, --log`)**
106
+ Enables verbose output to see exactly what the script is doing. Extremely useful when `--remove-duplicates` is enabled.
107
+
108
+ ![](https://raw.githubusercontent.com/wiki/ManeFunction/romlm/remove-log.png)
109
+
110
+
111
+ - **Other Utilities**
112
+ - Cleans out unwanted system meta-files (e.g., `desktop.ini`, `.DS_Store`).
113
+ - Removes empty subdirectories after sorting.
114
+ - Provides an in-script help guide (`-h, --help`).
115
+
116
+ ---
117
+
118
+ ## Usage examples and backstory
119
+
120
+ As a user of Analogue Pocket and an Emulation Station based handheld system, there are some nuances from both worlds
121
+ that I'm trying to cover with **romlm**. Analogue Poket is a highly precise FPGA-based console, but it covers only
122
+ earlier generations and a scoop of Arcade games. Though, those platforms, like NES, SNES and Genesis have a huge
123
+ library of games, that should be sorted to have an easy access. Also, Pocket do not support archives, so all the ROMs
124
+ should be unpacked. Standard software emulators, on the other hand, can play archived ROMs, and with the support
125
+ of more later platforms, where games became bigger, it's great to have them packed, but also, it's good to have
126
+ old systems packed as well, to save some Gigs for a few more CD games. So, I've need a tool to prepare my ROMs
127
+ for both systems in a few clicks. That's how **romlm** was born.
128
+
129
+
130
+ Personally, I keep all my ROMs on Analogue Pocket, to be able to quickly launch any version of any game it supports.
131
+ But I do not need all of those for a device that made for casual play. To be honest, I have Powkidy RGB30 with
132
+ a rectangular screen, specifically for the Pico-8. Emulation capabilities of this device is just a neat bonus
133
+ for me :) So, to prepare ROMs for Emulation Station devices, like Powkiddy, Anbernic, Miyoo, etc. we can clean
134
+ our library from all the stuff we never play anyway.
135
+
136
+
137
+ There is an example how to separate and work with your Japanese library, if you need it. But it also can be a great
138
+ example of **romlm** capabilities for you to learn. Emulation Station have a separate folders for Japanese libraries
139
+ (at least for the 3 whales of retro gaming: NES, SNES and Genesis) as well as for homebrew,
140
+ so it's good to separate them first, if you are interested in them. Here is the way:
141
+ 1) `-i ./your-roms -s a -f Japan -e USA,EN` command first to separate non-en Japanese releases
142
+ (they ends up in `!Japan` folder). Alternatively, you can use `-s f` if your collection do not have a lot of homebrew
143
+ and pirates, and you do not need to sort them in (A-Z) folders.
144
+ 1) Put all sorted folders (A-Z) from `your-roms` folder to `your-roms/sorted` for example, to be able to treat them
145
+ separately later.
146
+ 1) Now you should have `sorted`, `!Japan`, `!Homebrew` (if there was any) and `!Pirates` (if there was any)
147
+ folders in your `your-roms` folder. `!` is used here to keep them on top of the list, as Emulation Station sorts
148
+ folders alphabetically, but remember, that you should escape it with `\!` in the command line, so path
149
+ for the next commands should look like `-i ./your-roms/\!Japan` for example.
150
+ 1) Do `-i ./your-roms/sorted -r -l` for all the subfolders separately to remove duplicates, but keep them in
151
+ sorted (mostly USA for now) and Japanese folders.
152
+ 1) After that you can pack all the ROMs in one command `-i ./your-roms -p`. This command keeps our new folders structure.
153
+
154
+ This way you will keep all the Japanese versions of the games, separated into a dedicated folder, and anyways
155
+ all other duplicates will be removed, easing the access to the games you want to play in the future and
156
+ saving the space on your device for more great games.
157
+
158
+
159
+ ---
160
+
161
+ ## Installation
162
+
163
+ **romlm** is available to use in a variety of ways.
164
+ `pip` or `brew` is recommended ways, because they can handle dependencies and updates automatically.
165
+ 1) **brew (Recommended for Mac users)**
166
+ - You can install **romlm** through the [Homebrew](https://brew.sh/) formula for macOS users (if you have **brew** installed),
167
+ typing `brew install romlm` in the Terminal.
168
+ - Verify the installation with `romlm -h` command.
169
+ - You are perfect, you can use the app with `romlm [parameters]` command from any folder in your system.
170
+ 1) **Ready to use binaries (Mac and Windows)**
171
+ - Download ready-to-use binaries from the [GitHub Releases](https://github.com/ManeFunction/romlm/releases) and run it with `./romlm`.
172
+ - Unpack the downloaded archive.
173
+ - On Mac:
174
+ - Open Terminal or any other CL app you prefer to use.
175
+ - Run `cd path/to/unpacked/folder` to go to the folder (you can just drag'n'drop a folder to the Terminal
176
+ to copy-paste its path).
177
+ - Run `xattr -r -d com.apple.quarantine ./` to remove the quarantine attribute from binaries
178
+ (I do not have an Apple Developer status at the moment and cannot sign binaries properly,
179
+ this, hopefully, will be fixed in the future).
180
+ - Run `./romlm -h` to verify the script is working
181
+ (be aware, that macOS still running security checks, so the first start of the app will be longer for about 10-15 sec.).
182
+ - You are perfect, you can use the app now with `./romlm [parameters]` command.
183
+ - On Windows (cmd):
184
+ - Open Command Prompt (cmd) with `Win+R` and typing `cmd`, or just searching `cmd` in a Start menu.
185
+ - Run `cd path\to\folder` to go to the folder with `romlm.exe`.
186
+ Don't forget to use `cd` with `/d` option to change the drive, if needed (`cd /d path\to\folder`).
187
+ - Run `romlm -h` to verify the script is working.
188
+ - You are perfect, you can use the app now with `romlm [parameters]` command.
189
+ - On Windows (PowerShell):
190
+ - Open PowerShell searching it in a Start menu.
191
+ - Run `cd path\to\folder` to go to the folder with `romlm.exe`.
192
+ - Run `./romlm -h` to verify the script is working.
193
+ - You are perfect, you can use the app now with `./romlm [parameters]` command.
194
+ 1) **Python package (manual installation)**
195
+ - Clone the repository or download the source code from the GitHub.
196
+ - Go to the folder with the script in your Terminal.
197
+ - Run `pip install .` to install `romlm` to your system.
198
+ - Run `romlm -h` to verify the script is working.
199
+ - You are perfect, you can use `romlm [parameters]` command from anywhere in your system.
200
+ 1) **Python script (manual usage, for advanced users)**
201
+ - if you know how to work with Python scripts, venv, and dependencies, you can simply clone the repository
202
+ and run `romlm.py`. In that way, feel free to modify the script for yourself as you want.
203
+
204
+ ---
205
+
206
+ ## Credits
207
+
208
+ Created and maintained by ManeFunction.
209
+
210
+ Huge thanks to the [no-intro](http://www.no-intro.org/) team for their amazing work on the ROM sets and retro games
211
+ preservation.
212
+
213
+ Also, big shoutout to the [Nuitka](https://github.com/Nuitka/Nuitka) project for the great Python compiler.
214
+
215
+ And of course, thanks for your contributions and feedback!
216
+ If you have any questions, suggestions, or issues, feel free to open an issue on the [GitHub issues](https://github.com/ManeFunction/romlm/issues) page
217
+ or create a or pull request.
@@ -0,0 +1,7 @@
1
+ romlm.py,sha256=H2kO5L2VinZebPz1Elk3RrhTvHmdbD2W98EXAoDBSgw,29040
2
+ romlm-1.0.1.dist-info/LICENSE,sha256=dUZoH9bnH42wYZjubCnqbxhGhe6nLIIrCe_QuMXkxP4,1110
3
+ romlm-1.0.1.dist-info/METADATA,sha256=JPWxQA0ChyFHk7l6HM01wl44dSe771j8IDDduPtdDVQ,12213
4
+ romlm-1.0.1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
5
+ romlm-1.0.1.dist-info/entry_points.txt,sha256=MdkUwQXpkvv903lE4hyw3HBPS9T8pZYHIBXT1ZM4mvw,37
6
+ romlm-1.0.1.dist-info/top_level.txt,sha256=a4a-r2xK1ooyTkj-PVk6U7497i5rkmmWKVl8XfxPzDs,6
7
+ romlm-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.6.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ romlm = romlm:mane
@@ -0,0 +1 @@
1
+ romlm
romlm.py ADDED
@@ -0,0 +1,820 @@
1
+ import sys
2
+ import os
3
+ import re
4
+ import shutil
5
+ import glob
6
+ from enum import Enum, Flag, auto
7
+ import py7zr
8
+ import zipfile
9
+ import colorama
10
+ from colorama import Fore, Style
11
+ from tqdm import tqdm
12
+ from multiprocessing import Pool, freeze_support
13
+
14
+ version = "1.0.1"
15
+
16
+ def print_help():
17
+ print("Usage: \033[1mromlm\033[0m [parameters]")
18
+ print("Parameters:\n")
19
+ print("-i, --input [folder] Specify the input folder.")
20
+ print(" Default is the current folder.\n")
21
+ print("-s, --sort [options] Sort files into lettered subfolders (A-Z).")
22
+ print(" Options indicates is special folders should be")
23
+ print(" also sorted. Can be:")
24
+ print(" 'h' - homebrew")
25
+ print(" 'p' - pirates")
26
+ print(" 'f' - user-defined folders")
27
+ print(" 'a' - all")
28
+ print(" or use any combinations, like 'hp' or 'ps'.")
29
+ print(" 'reverse' - un-sort ROMs, placing all in the root.\n")
30
+ print("-x, --extract Extract all 7z/zip files in the folder.\n")
31
+ print("-p, --pack [format] Pack all files in the folder to 7z/zip format.")
32
+ print(" [format] can be '7z' or 'zip'. Default is '7z'.\n")
33
+ print("-u, --unlicensed [options] Disable Homebrew and Pirate stuff separation")
34
+ print(" to the different folders. [options] can be:")
35
+ print(" 'h' - separate homebrew only")
36
+ print(" 'p' - separate pirates only")
37
+ print(" 'none' - do not separate anything")
38
+ print(" Separation enabled by default.\n")
39
+ print("-r, --remove-duplicates Remove duplicate files.")
40
+ print(" When program stuck with more than one 'best' ROMs:")
41
+ print(" 'ask' - will ask which file to keep.")
42
+ print(" 'all' - will keep all best files.")
43
+ print(" 'one' - will keep only one best file (at random).")
44
+ print(" If --log, default is 'ask', otherwise 'all'.")
45
+ print(" --log is recommended for this process.\n")
46
+ print("-f, --folders [list] Define subfolders to place files, based on tags.\n")
47
+ print("-e, --exclude [list] Exclude files with specified tags from -f process.\n")
48
+ print("-h, --help Show this help message.\n")
49
+ print("-l, --log Enable full logging instead of progressbars.\n")
50
+ print("For more details and examples or to support an author,")
51
+ print("please check the README file or visit the GitHub repository:")
52
+ print(f"{Fore.CYAN}https://github.com/ManeFunction/romlm{Style.RESET_ALL}")
53
+
54
+ class Action(Enum):
55
+ NOT_DEFINED = 0
56
+ ASK = 1
57
+ KEEP_ALL = 2
58
+ KEEP_ONE = 3
59
+
60
+ class CategoryOption(Flag):
61
+ HOMEBREW = auto()
62
+ PIRATES = auto()
63
+ SUBFOLDERS = auto()
64
+
65
+ def create_if_not_exist(folder):
66
+ if not os.path.exists(folder):
67
+ os.makedirs(folder)
68
+
69
+ def get_lettered_folder_name(filename) -> str:
70
+ folder_name = filename[0].upper() if filename else ''
71
+ if folder_name == "[":
72
+ folder_name = "!!BIOS"
73
+ elif not folder_name.isalpha():
74
+ folder_name = "1-9"
75
+ return folder_name
76
+
77
+ def try_add_subfolder(is_sort_subfolders, folder_name, filename) -> str:
78
+ return (folder_name + "/" + get_lettered_folder_name(filename)) if is_sort_subfolders else folder_name
79
+
80
+ def get_tags_from_filename(filename: str) -> list[str]:
81
+ name_no_ext = os.path.splitext(filename)[0]
82
+ groups = re.findall(r'[(\[](.*?)[)\]]', name_no_ext)
83
+ all_tags = []
84
+ for g in groups:
85
+ split_tags = [t.strip() for t in g.split(',')]
86
+ all_tags.extend(split_tags)
87
+ return [tag.lower() for tag in all_tags]
88
+
89
+ def is_homebrew(file_tags) -> bool:
90
+ return "homebrew" in file_tags or "aftermarket" in file_tags
91
+
92
+ def is_pirate(file_tags) -> bool:
93
+ return "pirate" in file_tags or "unl" in file_tags
94
+
95
+ def get_new_folder(filename, separation_options, sorting_options, subfolders, excludes) -> str:
96
+ file_tags = get_tags_from_filename(filename)
97
+ excludes = {exclude.lower() for exclude in excludes} if excludes is not None else None
98
+
99
+ # Check for 'homebrew' or 'aftermarket' tags
100
+ if separation_options & CategoryOption.HOMEBREW and is_homebrew(file_tags):
101
+ folder_name = try_add_subfolder(sorting_options & CategoryOption.HOMEBREW, "!Homebrew", filename)
102
+
103
+ # Check for 'pirate' or 'unl' tags
104
+ elif separation_options & CategoryOption.PIRATES and is_pirate(file_tags):
105
+ folder_name = try_add_subfolder(sorting_options & CategoryOption.PIRATES, "!Pirates", filename)
106
+
107
+ # Check for any user-defined 'subfolders' value matches any tag.
108
+ elif subfolders is not None:
109
+ found_subfolder = ""
110
+ for subfolder in subfolders:
111
+ if subfolder.lower() in file_tags and (excludes is None or not bool(excludes.intersection(file_tags))):
112
+ found_subfolder = "!" + subfolder
113
+ break
114
+ folder_name = try_add_subfolder(sorting_options & CategoryOption.SUBFOLDERS, found_subfolder, filename) \
115
+ if found_subfolder != "" else get_lettered_folder_name(filename)
116
+
117
+ # Default to alphabetical folder
118
+ else:
119
+ folder_name = get_lettered_folder_name(filename)
120
+
121
+ create_if_not_exist(folder_name)
122
+ return folder_name
123
+
124
+ def remove_meta_files(path, is_log_enabled):
125
+ for dirpath, dirnames, filenames in os.walk(path):
126
+ for f in filenames:
127
+ if f.lower() in ("desktop.ini", "thumbs.db", ".ds_store"):
128
+ file_path = os.path.join(dirpath, f)
129
+ os.remove(file_path)
130
+ if is_log_enabled:
131
+ print(f"Removed meta file: {Fore.RED}{file_path}{Style.RESET_ALL}")
132
+
133
+ def remove_empty_subfolders(path, is_log_enabled):
134
+ for dirpath, dirnames, filenames in os.walk(path, topdown=False):
135
+ if not dirnames and not filenames:
136
+ if dirpath != path:
137
+ os.rmdir(dirpath)
138
+ if is_log_enabled:
139
+ print(f"Removed empty folder: {Fore.RED}{dirpath}{Style.RESET_ALL}")
140
+ # Kinda cludgy, but keep removing empty parent folders until we hit the root, to clear empty trees
141
+ parent_dir = os.path.dirname(dirpath)
142
+ while parent_dir != path and not os.listdir(parent_dir):
143
+ os.rmdir(parent_dir)
144
+ if is_log_enabled:
145
+ print(f"Removed empty folder: {Fore.RED}{parent_dir}{Style.RESET_ALL}")
146
+ parent_dir = os.path.dirname(parent_dir)
147
+
148
+ def get_base_name(filename: str) -> str:
149
+ name_no_ext = os.path.splitext(filename)[0]
150
+ idx_1 = name_no_ext.find('(')
151
+ idx_2 = name_no_ext.find('[')
152
+ idx = min(idx_1, idx_2) if idx_1 != -1 and idx_2 != -1 else max(idx_1, idx_2)
153
+ if idx != -1:
154
+ base = name_no_ext[:idx].strip()
155
+ else:
156
+ base = name_no_ext.strip()
157
+ return base
158
+
159
+ def clean_duplicates(file_list, action, is_log_enabled, is_debug_log):
160
+ """
161
+ For each distinct base name (game):
162
+ 1) Partition into normal vs beta/proto.
163
+ - If normal exists => remove all beta/proto.
164
+ 2) Among normal => pick exactly one best-scored normal file:
165
+ - Region coverage (more is better)
166
+ - Fewer non-region tags
167
+ - Better (lower) region priority index
168
+ - Higher revision
169
+ 3) If no normal => pick exactly one best-scored beta/proto:
170
+ - Region coverage (more is better)
171
+ - No date is better than having a date
172
+ - Higher numeric suffix is better
173
+ - Fewer non-region tags
174
+ 4) Remove the rest. Never remove all for a given game; if end up with none, keep them all.
175
+ """
176
+
177
+ top_region_priority = "world"
178
+ region_priority = ["usa", "europe"]
179
+ asian_regions = ["japan", "asia", "china", "korea"]
180
+
181
+ # Is ROM a part of multi-disc set?
182
+ def get_disc_number(tags_list) -> int:
183
+ """
184
+ Returns '-1' if the ROM is not a part of a multi-disc set, or disc number otherwise.
185
+ """
186
+ for t in tags_list:
187
+ m = re.match(r"(disc|disk)\s*(\d+)", t)
188
+ if m:
189
+ return int(m.group(2))
190
+ return -1
191
+
192
+ # Helper: parse date like (1993-07-09)
193
+ def parse_date_yyyy_mm_dd(tag: str) -> bool:
194
+ """
195
+ Return True if tag looks like YYYY-MM-DD, else False.
196
+ We won't parse the integer, we just treat "has date" as a boolean.
197
+ """
198
+ return bool(re.match(r"^\d{4}-\d{2}-\d{2}$", tag))
199
+
200
+ # Identify if file is Beta/Proto/Sample
201
+ def is_beta_file(fpath):
202
+ tags_list = get_tags_from_filename(os.path.basename(fpath))
203
+ for t in tags_list:
204
+ # If it starts with "beta", "proto", or "sample", we treat it as Beta/Proto
205
+ if (t.startswith("beta")
206
+ or t.startswith("alpha")
207
+ or t.startswith("proto")
208
+ or t.startswith("sample")
209
+ or t.startswith("demo")):
210
+ return True
211
+ return False
212
+
213
+ # Region coverage + min region index
214
+ def get_region_coverage_and_min_index(tags_list):
215
+ """
216
+ Returns (coverage, min_index) where:
217
+ coverage = number of recognized region tags
218
+ min_index = the lowest index among recognized tags (or len(region_priority) if none)
219
+ Example: if tags_list has ["usa", "europe"], coverage=2, min_index=1 ("usa")
220
+ """
221
+ # Check if it's a top region priority
222
+ for t in tags_list:
223
+ if t == top_region_priority:
224
+ return len(region_priority), 0
225
+ # Check if it's a recognized prioritized region
226
+ recognized = [r for r in tags_list if r in region_priority]
227
+ coverage = len(recognized)
228
+ if coverage == 0:
229
+ return 0, len(region_priority)
230
+ # find the lowest region index among them
231
+ min_i = min(region_priority.index(r) for r in recognized)
232
+ return coverage, min_i
233
+
234
+ # Is ROM from an Asian region?
235
+ def is_asian_region_and_not_en(tags_list):
236
+ """
237
+ Returns True if the ROM is from an Asian region and not in English.
238
+ """
239
+ is_asian = False
240
+ is_en = False
241
+ for t in tags_list:
242
+ if t in asian_regions:
243
+ is_asian = True
244
+ elif t == "en":
245
+ is_en = True
246
+ return is_asian and not is_en
247
+
248
+ # Count how many unknown tags ROM have (excluding rev, beta/proto, sample, known region)
249
+ def count_unknown_tags(tags_list):
250
+ count = 0
251
+ for t in tags_list:
252
+ # Skip known regions tags
253
+ if t == top_region_priority:
254
+ continue
255
+ if t in region_priority:
256
+ continue
257
+ if t in asian_regions:
258
+ continue
259
+ # Revisions and versions tags
260
+ if t.startswith("rev ") or re.match(r"^\d+(?:\.\d+){0,3}$", t):
261
+ continue
262
+ # Beta and other in-development tags
263
+ if t.startswith("beta") or t.startswith("alpha") or t.startswith("proto") or t.startswith("sample"):
264
+ continue
265
+ # Language tags
266
+ if t in ["en", "fr", "de", "es", "it", "nl", "pt", "sv", "no", "da", "fi"]:
267
+ continue
268
+ # anything else is "extra"
269
+ count += 1
270
+ return count
271
+
272
+ # Get video format score for comparison (NTSC > none > PAL)
273
+ def get_video_format_score(tags_list) -> int:
274
+ """
275
+ Convert a video format tag into a single integer for comparison.
276
+ """
277
+ if "ntsc" in tags_list:
278
+ return 2
279
+ if "pal" in tags_list:
280
+ return 0
281
+ return 1
282
+
283
+ # Try to get version from tags list
284
+ def try_get_version_score(tags_list) -> tuple[int, bool]:
285
+ for t in tags_list:
286
+ # Catch numeric revisions
287
+ m = re.match(r"^(rev|proto|alpha|beta|sample|demo)\s+([a-z])$", t)
288
+ if m:
289
+ rev = m.group(2)[0]
290
+ rev_score = ord(rev) - ord('a') + 1
291
+ return rev_score, True
292
+ # Catch version numbers
293
+ m = re.match(r"^(?:(rev|beta|alpha|proto|sample|demo)\s+)?v?(\d+(?:\.\d+){0,3})?$", t)
294
+ if m:
295
+ version_str = m.group(2)
296
+ parts = version_str.split(".")
297
+ version_tuple = tuple(map(int, parts)) + (0,) * (4 - len(parts))
298
+ version_score = int("".join(f"{v:03}" for v in version_tuple))
299
+ return version_score, True
300
+ return 0, False
301
+
302
+ # Get the date score from tags list
303
+ def get_date_score(tags_list) -> int:
304
+ for t in tags_list:
305
+ if parse_date_yyyy_mm_dd(t):
306
+ date_score = int(t.replace("-", ""))
307
+ return date_score
308
+ return 0
309
+
310
+ # Normal-file scoring: ( -region_coverage, min_region_index, non_region_tags, -video_format, -revision, -date )
311
+ def score_normal_file(fpath):
312
+ tags_list = get_tags_from_filename(os.path.basename(fpath))
313
+
314
+ coverage, min_idx = get_region_coverage_and_min_index(tags_list)
315
+ video_format_score = get_video_format_score(tags_list)
316
+ non_region = count_unknown_tags(tags_list)
317
+ date_score = get_date_score(tags_list)
318
+
319
+ version_score, version_found = try_get_version_score(tags_list)
320
+
321
+ # Set a slight tags penalty for Asian regions to prioritize english versions even it's new (from Virtual Consoles, etc.)
322
+ if is_asian_region_and_not_en(tags_list):
323
+ non_region += 2
324
+
325
+ # For Homebrew, assume that no explicit revision version is 1.0.0.0 (initial release)
326
+ # ...for retro ROMs, it's more likely "Rev 0" (initial release)
327
+ # also compensate missed initial revision for Homebrew
328
+ if (is_homebrew(tags_list) or is_pirate(tags_list)) and not version_found:
329
+ version_score = 100000000000
330
+ non_region += 1
331
+
332
+ if is_debug_log:
333
+ print(f" >>> {fpath} (RELEASE): {Fore.MAGENTA}tags({non_region}), reg({coverage}: {min_idx}), "
334
+ f"format({video_format_score}), v({version_score}:{date_score}){Style.RESET_ALL}")
335
+
336
+ return (
337
+ non_region,
338
+ -coverage,
339
+ min_idx,
340
+ -video_format_score,
341
+ -version_score,
342
+ -date_score
343
+ )
344
+
345
+ # Beta/Proto scoring: ( -latest_date, -region_coverage, -beta_number, non_region_tags )
346
+ def score_beta_file(fpath):
347
+ tags_list = get_tags_from_filename(os.path.basename(fpath))
348
+
349
+ coverage, _ = get_region_coverage_and_min_index(tags_list)
350
+ best_date_score = get_date_score(tags_list)
351
+ non_region = count_unknown_tags(tags_list)
352
+
353
+ version_score, _ = try_get_version_score(tags_list)
354
+
355
+ if is_debug_log:
356
+ print(f" >>> {fpath} (BETA): {Fore.MAGENTA}date({best_date_score}), reg({coverage}), v({version_score}), "
357
+ f"tags({non_region}){Style.RESET_ALL}")
358
+
359
+ return (
360
+ -best_date_score,
361
+ -coverage,
362
+ -version_score,
363
+ non_region
364
+ )
365
+
366
+ # Print list of files in a color
367
+ def print_files_list(files_list, color):
368
+ for file_name in files_list:
369
+ print(f" - {color}{os.path.basename(file_name)}{Style.RESET_ALL}")
370
+
371
+ # Get a log iteration string formatted as "(N/M)"
372
+ def n(index) -> str:
373
+ return f"({index}/{len(groups_iter)}) "
374
+
375
+ # Left only one file in the set
376
+ def keep_one(files_set, selected) -> set:
377
+ if is_log_enabled:
378
+ print(f"{n(i)}Removing duplicate(s):")
379
+ for file_name in files_set:
380
+ if file_name != selected:
381
+ if is_log_enabled:
382
+ print(f"- {Fore.RED}{os.path.basename(file_name)}{Style.RESET_ALL}")
383
+ os.remove(file_name)
384
+ if is_log_enabled:
385
+ print(f"| >> Keeping one: {Fore.GREEN}{os.path.basename(selected)}{Style.RESET_ALL}")
386
+ return {selected}
387
+
388
+ # Group files by base name
389
+ file_list = list(file_list)
390
+ by_basename = {}
391
+
392
+ for f in file_list:
393
+ fname = os.path.basename(f)
394
+ base = get_base_name(fname)
395
+ disc = get_disc_number(get_tags_from_filename(fname))
396
+ if disc > -1:
397
+ base += f" (Disc {disc})"
398
+ by_basename.setdefault(base, []).append(f)
399
+
400
+ print(
401
+ ">> Removing duplicates safely... \nTotal ROMs:",
402
+ len(file_list),
403
+ "\nActual games:",
404
+ len(by_basename),
405
+ )
406
+
407
+ files_to_keep = set()
408
+
409
+ # MAIN LOOP of removing duplicates
410
+ groups_iter = by_basename.items() if is_log_enabled \
411
+ else tqdm(by_basename.items(), desc="Cleaning Duplicates", total=len(by_basename))
412
+
413
+ i = 0
414
+ for base, paths in groups_iter:
415
+ i += 1
416
+
417
+ # Only one file => trivially keep it
418
+ if len(paths) == 1:
419
+ files_to_keep.add(paths[0])
420
+ if is_log_enabled:
421
+ print(f"{n(i)}Single ROM: {Fore.GREEN}{os.path.basename(paths[0])}{Style.RESET_ALL}")
422
+ continue
423
+
424
+ # Partition into normal vs. beta/proto
425
+ normal_files = []
426
+ beta_proto_files = []
427
+ for p in paths:
428
+ if is_beta_file(p):
429
+ beta_proto_files.append(p)
430
+ else:
431
+ normal_files.append(p)
432
+
433
+ if normal_files:
434
+ # Remove all Beta/Proto
435
+ chosen_set = normal_files
436
+ if is_log_enabled and beta_proto_files:
437
+ print(f"{n(i)}Removing all Betas: ")
438
+ print_files_list(beta_proto_files, Fore.RED)
439
+ print(f" | >> Has {len(normal_files)} release(s):")
440
+ print_files_list(normal_files, Fore.GREEN)
441
+ for bp in beta_proto_files:
442
+ os.remove(bp)
443
+ else:
444
+ # No normal => only Beta/Proto
445
+ # Pick exactly one best-scored
446
+ if len(beta_proto_files) == 1:
447
+ files_to_keep.add(beta_proto_files[0])
448
+ if is_log_enabled:
449
+ print(f"{n(i)}Single Beta: {Fore.GREEN}{os.path.basename(beta_proto_files[0])}{Style.RESET_ALL}")
450
+ continue
451
+
452
+ best_bp = None
453
+ best_score = None
454
+ for bp in beta_proto_files:
455
+ sc = score_beta_file(bp)
456
+ if (best_score is None) or (sc < best_score):
457
+ best_score = sc
458
+ best_bp = bp
459
+
460
+ keep_set = {best_bp}
461
+ for bp in beta_proto_files:
462
+ if bp != best_bp:
463
+ if is_log_enabled:
464
+ print(f"{n(i)}Removing earlier Beta: {Fore.RED}{os.path.basename(bp)}{Style.RESET_ALL}")
465
+ os.remove(bp)
466
+
467
+ # safety check: never remove all
468
+ if not keep_set:
469
+ # fallback => keep them all
470
+ for p in beta_proto_files:
471
+ files_to_keep.add(p)
472
+ if is_log_enabled:
473
+ print(f"{n(i)}No latest Beta, keeping all:")
474
+ print_files_list(beta_proto_files, Fore.GREEN)
475
+ else:
476
+ for f in keep_set:
477
+ files_to_keep.add(f)
478
+ if is_log_enabled:
479
+ print(f"{n(i)}Latest Beta: {Fore.GREEN}{os.path.basename(best_bp)}{Style.RESET_ALL}")
480
+ continue
481
+
482
+ # If chosen_set is empty for some reason, fallback => keep them all
483
+ if not chosen_set:
484
+ for p in paths:
485
+ files_to_keep.add(p)
486
+ if is_log_enabled:
487
+ print(f"{n(i)}No release ROMs, keeping all these:")
488
+ print_files_list(paths, Fore.GREEN)
489
+ continue
490
+
491
+ # Among the chosen normal set, pick best scored files
492
+ if len(chosen_set) == 1:
493
+ files_to_keep.add(chosen_set[0])
494
+ if is_log_enabled:
495
+ print(f"{n(i)}Single release ROM: {Fore.GREEN}{os.path.basename(chosen_set[0])}{Style.RESET_ALL}")
496
+ continue
497
+
498
+ best_normal = set()
499
+ best_score = None
500
+ for nf in chosen_set:
501
+ sc = score_normal_file(nf)
502
+ if (best_score is None) or (sc < best_score):
503
+ best_score = sc
504
+ best_normal = {nf}
505
+ elif sc == best_score:
506
+ best_normal.add(nf)
507
+
508
+ # remove all others
509
+ keep_set = best_normal
510
+ for nf in chosen_set:
511
+ if nf not in best_normal:
512
+ if is_log_enabled:
513
+ print(f"{n(i)}Removing duplicate: {Fore.RED}{os.path.basename(nf)}{Style.RESET_ALL}")
514
+ os.remove(nf)
515
+
516
+ # safety check
517
+ if not keep_set:
518
+ # fallback => keep them all
519
+ for f in chosen_set:
520
+ files_to_keep.add(f)
521
+ if is_log_enabled:
522
+ print(f"{n(i)}No best ROM, keeping all:")
523
+ print_files_list(chosen_set, Fore.GREEN)
524
+ else:
525
+ # check what we need to do with the rest of the best
526
+ if len(keep_set) > 1:
527
+ if action == Action.KEEP_ALL:
528
+ if is_log_enabled:
529
+ print(f"{n(i)}Keeping all best ROMs:")
530
+ print_files_list(keep_set, Fore.GREEN)
531
+
532
+ elif action == Action.KEEP_ONE:
533
+ best_kept = min(keep_set, key=lambda x: x)
534
+ keep_set = keep_one(keep_set, best_kept)
535
+
536
+ elif action == Action.ASK:
537
+ print(f"{n(i)}Can't decide which one is the best. Please select one to keep:")
538
+ keep_list = list(keep_set)
539
+ for idx, file in enumerate(keep_list, start=1):
540
+ print(f" {idx}. {Fore.YELLOW}{os.path.basename(file)}{Style.RESET_ALL}")
541
+
542
+ selected_index = -1
543
+ while selected_index < 0 or selected_index > len(keep_list):
544
+ try:
545
+ selected_index = int(input("Enter the number of the file to keep (0 to keep all): "))
546
+ except ValueError:
547
+ print("Invalid input. Please enter a number.")
548
+
549
+ if selected_index == 0:
550
+ if is_log_enabled:
551
+ print(f"{n(i)}Keeping all best ROMs:")
552
+ print_files_list(keep_set, Fore.GREEN)
553
+ else:
554
+ user_selected = keep_list[selected_index - 1]
555
+ keep_set = keep_one(keep_set, user_selected)
556
+
557
+ if len(keep_set) == 1:
558
+ if is_log_enabled:
559
+ print(f"{n(i)}Best ROM: {Fore.GREEN}{os.path.basename(next(iter(keep_set)))}{Style.RESET_ALL}")
560
+
561
+ for f in keep_set:
562
+ files_to_keep.add(f)
563
+
564
+ # Return only files we decided to keep
565
+ return [f for f in file_list if f in files_to_keep]
566
+
567
+ def process_file(args) -> tuple[str, str]:
568
+ """Processes a single file for packing or unpacking."""
569
+ file_name, target_folder, is_unpacking_enabled, is_packing_enabled, packing_format = args
570
+ result_log = None
571
+ if is_unpacking_enabled and file_name.endswith((".7z", ".zip")):
572
+ result_log = unpack_file(file_name, target_folder)
573
+ elif is_packing_enabled and not file_name.endswith((".7z", ".zip")) and not file_name.startswith("[BIOS]"):
574
+ result_log = pack_file(file_name, target_folder, packing_format)
575
+ return file_name, result_log
576
+
577
+ def unpack_file(file_name, target_folder) -> str:
578
+ """Handles unpacking of a single file."""
579
+ if file_name.endswith(".7z"):
580
+ with py7zr.SevenZipFile(file_name, 'r') as archive:
581
+ archive.extractall(target_folder)
582
+ elif file_name.endswith(".zip"):
583
+ with zipfile.ZipFile(file_name, 'r') as archive:
584
+ archive.extractall(target_folder)
585
+ os.remove(file_name)
586
+ return f" >> Unpacked to: {Fore.BLUE}{target_folder}{Style.RESET_ALL}"
587
+
588
+ def pack_file(file_name, target_folder, packing_format) -> str:
589
+ """Handles packing of a single file."""
590
+ archive_path = os.path.join(target_folder, os.path.basename(file_name))
591
+ if packing_format == "7z":
592
+ with py7zr.SevenZipFile(str(archive_path) + ".7z", 'w') as archive:
593
+ archive.write(file_name, arcname=os.path.basename(file_name))
594
+ elif packing_format == "zip":
595
+ with zipfile.ZipFile(str(archive_path + ".zip"), 'w', zipfile.ZIP_DEFLATED) as archive:
596
+ archive.write(file_name, arcname=os.path.basename(file_name))
597
+ os.remove(file_name)
598
+ return f" >> Packed to: {Fore.BLUE}{archive_path}.{packing_format}{Style.RESET_ALL}"
599
+
600
+ def is_next_optional_parameter(args, i) -> bool:
601
+ return i+1 < len(args) and not args[i+1].startswith("-")
602
+
603
+ def mane():
604
+ # check for -v or --version argument
605
+ if len(sys.argv) == 2 and sys.argv[1] in ("-v", "--version"):
606
+ print(f"ROMs Library Manager v{version}")
607
+ sys.exit()
608
+
609
+ # Print welcome message and init variables
610
+ print(f">> Welcome to ROMs Library Manager (v{version})")
611
+
612
+ separation_options = CategoryOption.HOMEBREW | CategoryOption.PIRATES
613
+ is_sort_enabled = False
614
+ sort_options = CategoryOption(0)
615
+ is_reverse_sort = False
616
+ is_unpacking_enabled = False
617
+ is_packing_enabled = False
618
+ packing_format = "7z"
619
+ is_log_enabled = False
620
+ is_debug_log = False
621
+ is_remove_duplicates = False
622
+ remove_duplicates_action = Action.NOT_DEFINED
623
+ subfolders = None
624
+ exclude_tags = None
625
+ input_folder = "."
626
+
627
+ colorama.init()
628
+
629
+ # Parse command line arguments
630
+ args = sys.argv[1:]
631
+ skip_next = False
632
+ for i, arg in enumerate(args):
633
+ if skip_next:
634
+ skip_next = False
635
+ continue
636
+ if arg in ("-h", "--help"):
637
+ print_help()
638
+ sys.exit()
639
+ if arg in ("-x", "--extract"):
640
+ is_unpacking_enabled = True
641
+ elif arg in ("-p", "--pack"):
642
+ is_packing_enabled = True
643
+ if is_next_optional_parameter(args, i):
644
+ pack_param = args[i+1]
645
+ if pack_param == "7z":
646
+ packing_format = "7z"
647
+ elif pack_param == "zip":
648
+ packing_format = "zip"
649
+ else:
650
+ print(f"{Fore.RED}Error: Unknown format '{pack_param}'! --pack only supports '7z' or 'zip'.{Style.RESET_ALL}")
651
+ sys.exit(1)
652
+ skip_next = True
653
+ elif arg in ("-u", "--unlicensed"):
654
+ if is_next_optional_parameter(args, i):
655
+ keep_params = args[i+1]
656
+ if keep_params == "none":
657
+ separation_options = CategoryOption(0)
658
+ elif 'a' in keep_params:
659
+ separation_options = CategoryOption.HOMEBREW | CategoryOption.PIRATES
660
+ else:
661
+ if 'h' in keep_params:
662
+ separation_options |= CategoryOption.HOMEBREW
663
+ if 'p' in keep_params:
664
+ separation_options |= CategoryOption.PIRATES
665
+ skip_next = True
666
+ elif arg in ("-s", "--sort"):
667
+ is_sort_enabled = True
668
+ if is_next_optional_parameter(args, i):
669
+ sort_params = args[i+1]
670
+ if sort_params == "reverse":
671
+ is_reverse_sort = True
672
+ else:
673
+ if sort_params == "none":
674
+ sort_options = CategoryOption(0)
675
+ elif 'a' in sort_params:
676
+ sort_options = CategoryOption.HOMEBREW | CategoryOption.PIRATES | CategoryOption.SUBFOLDERS
677
+ else:
678
+ if 'h' in sort_params:
679
+ sort_options |= CategoryOption.HOMEBREW
680
+ if 'p' in sort_params:
681
+ sort_options |= CategoryOption.PIRATES
682
+ if 'f' in sort_params:
683
+ sort_options |= CategoryOption.SUBFOLDERS
684
+ skip_next = True
685
+ elif arg in ("-l", "--log"):
686
+ is_log_enabled = True
687
+ elif arg == "--debug":
688
+ is_debug_log = True
689
+ elif arg in ("-r", "--remove-duplicates"):
690
+ is_remove_duplicates = True
691
+ if is_next_optional_parameter(args, i):
692
+ remove_duplicates_param = args[i+1]
693
+ if remove_duplicates_param == "ask":
694
+ remove_duplicates_action = Action.ASK
695
+ elif remove_duplicates_param == "all":
696
+ remove_duplicates_action = Action.KEEP_ALL
697
+ elif remove_duplicates_param == "one":
698
+ remove_duplicates_action = Action.KEEP_ONE
699
+ else:
700
+ print(f"{Fore.RED}Error: Unknown action '{remove_duplicates_param}'! --remove-duplicates only supports 'ask', 'all' or 'one'.{Style.RESET_ALL}")
701
+ sys.exit(1)
702
+ skip_next = True
703
+ elif arg in ("-f", "--folders"):
704
+ if i+1 < len(args):
705
+ subfolders = args[i+1].split(",")
706
+ if is_log_enabled:
707
+ print("Subfolders:", subfolders)
708
+ skip_next = True
709
+ else:
710
+ print(f"{Fore.RED}Error: --subfolders requires a comma-separated list of subfolders.{Style.RESET_ALL}")
711
+ sys.exit(1)
712
+ elif arg in ("-e", "--exclude"):
713
+ if i+1 < len(args):
714
+ exclude_tags = args[i+1].split(",")
715
+ if is_log_enabled:
716
+ print("Exclude tags:", exclude_tags)
717
+ skip_next = True
718
+ else:
719
+ print(f"{Fore.RED}Error: --exclude requires a comma-separated list of tags.{Style.RESET_ALL}")
720
+ sys.exit(1)
721
+ elif arg in ("-i", "--input"):
722
+ if i+1 < len(args):
723
+ input_folder = args[i+1]
724
+ skip_next = True
725
+ else:
726
+ print(f"{Fore.RED}Error: --input requires a folder path.{Style.RESET_ALL}")
727
+ sys.exit(1)
728
+
729
+ # Set default action for duplicates removal
730
+ if remove_duplicates_action == Action.NOT_DEFINED:
731
+ remove_duplicates_action = Action.ASK if is_log_enabled else Action.KEEP_ALL
732
+
733
+ # Check for conflicting options
734
+ if is_unpacking_enabled is True and is_packing_enabled is True:
735
+ print(f"{Fore.RED}Error: You cannot --extract and --pack at the same time.{Style.RESET_ALL}")
736
+ sys.exit(1)
737
+
738
+ if (is_sort_enabled is False
739
+ and is_unpacking_enabled is False
740
+ and is_packing_enabled is False
741
+ and is_remove_duplicates is False):
742
+ print(f"{Fore.YELLOW}Nothing to do...{Style.RESET_ALL}")
743
+ sys.exit()
744
+
745
+ if not os.path.exists(input_folder):
746
+ print(f"{Fore.RED}Error: The specified input folder '{input_folder}' does not exist.{Style.RESET_ALL}")
747
+ sys.exit(1)
748
+ os.chdir(input_folder)
749
+ print(f"Current working directory set to: {os.getcwd()}")
750
+
751
+ if exclude_tags is not None and subfolders is None:
752
+ print(f"{Fore.YELLOW}Warning: You cannot use --exclude without --subfolders. Option ignored.{Style.RESET_ALL}")
753
+
754
+ # Get files list
755
+ files_list = glob.glob("**/*.*", recursive=True)
756
+
757
+ # If duplicates removal is enabled, do it first
758
+ if is_remove_duplicates:
759
+ files_was = len(files_list)
760
+ files_list = clean_duplicates(files_list, remove_duplicates_action, is_log_enabled, is_debug_log)
761
+ if files_was != len(files_list):
762
+ print(f"Total ROMs left after duplicates removal: {Fore.GREEN}{len(files_list)}{Style.RESET_ALL} "
763
+ f"out of {Fore.RED}{files_was}{Style.RESET_ALL}")
764
+ else:
765
+ print("No duplicates found...")
766
+
767
+ def get_target_folder() -> str:
768
+ if is_sort_enabled:
769
+ if is_reverse_sort:
770
+ return '.'
771
+ return get_new_folder(os.path.basename(file_name), separation_options, sort_options,
772
+ subfolders, exclude_tags)
773
+ return os.path.dirname(file_name)
774
+
775
+ # Process files
776
+ if is_sort_enabled is True or is_unpacking_enabled is True or is_packing_enabled is True:
777
+ # Single-threaded processing for just a move operation
778
+ if not is_unpacking_enabled and not is_packing_enabled:
779
+ print(">> Processing files...")
780
+ progress = files_list if is_log_enabled else tqdm(files_list, desc="Processing")
781
+
782
+ for file_name in progress:
783
+ target_folder = get_target_folder()
784
+ shutil.move(file_name, os.path.join(target_folder, os.path.basename(str(file_name))))
785
+ if is_log_enabled:
786
+ print(f" >> Moved to: {Fore.BLUE}{target_folder}{Style.RESET_ALL}")
787
+ else:
788
+ # Multithreaded processing for packing/unpacking
789
+ print(">> Preparing processing...")
790
+ tasks = []
791
+ for file_name in files_list:
792
+ target_folder = get_target_folder()
793
+ tasks.append((file_name, target_folder, is_unpacking_enabled, is_packing_enabled, packing_format))
794
+
795
+ print(">> Processing files...")
796
+ i = 0
797
+ if is_log_enabled:
798
+ with Pool(processes=os.cpu_count()) as pool:
799
+ for task in pool.imap_unordered(process_file, tasks):
800
+ i += 1
801
+ f_name, result_log = task
802
+ print(f"({i}/{len(tasks)}) Processed: {Fore.GREEN}{f_name}{Style.RESET_ALL}")
803
+ if result_log is not None:
804
+ print(result_log)
805
+ else:
806
+ with Pool(processes=os.cpu_count()) as pool:
807
+ with tqdm(total=len(tasks), desc="Processing") as progress:
808
+ for _ in pool.imap_unordered(process_file, tasks):
809
+ progress.update(1)
810
+
811
+
812
+ remove_meta_files(".", is_log_enabled)
813
+ remove_empty_subfolders(".", is_log_enabled)
814
+
815
+ print(">> DONE!")
816
+ sys.exit()
817
+
818
+ if __name__ == "__main__":
819
+ freeze_support()
820
+ mane()