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.
- romlm-1.0.1.dist-info/LICENSE +22 -0
- romlm-1.0.1.dist-info/METADATA +217 -0
- romlm-1.0.1.dist-info/RECORD +7 -0
- romlm-1.0.1.dist-info/WHEEL +5 -0
- romlm-1.0.1.dist-info/entry_points.txt +2 -0
- romlm-1.0.1.dist-info/top_level.txt +1 -0
- romlm.py +820 -0
|
@@ -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
|
+

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

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

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

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

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

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

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