music-mdata 0.1.0__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.
mdata.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Music Metadata • copyright blasieuwu, 2026
|
|
2
|
+
# a better way of getting track metadata.
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
import sys, re
|
|
6
|
+
|
|
7
|
+
REQUIRED = {
|
|
8
|
+
"title",
|
|
9
|
+
"artist",
|
|
10
|
+
"album",
|
|
11
|
+
"explicit"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def validate_mdata(file_name: str, silent: bool = False, yields: bool = False, track_identity=None) -> dict | None:
|
|
15
|
+
directory = Path(__file__).parent.resolve()
|
|
16
|
+
file_path = Path(directory / file_name).resolve()
|
|
17
|
+
file_ext = Path(file_name).suffix
|
|
18
|
+
|
|
19
|
+
if not file_path.exists():
|
|
20
|
+
if not silent:
|
|
21
|
+
print(f"\x1b[1;31m[1/2] [-] file wasn't located.\x1b[0m")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
if file_ext != ".mdata":
|
|
25
|
+
if not silent:
|
|
26
|
+
print(f"\x1b[1;33m[2/2] [!] '{file_name}' is not a .mdata file...\x1b[0m")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
if not silent:
|
|
30
|
+
print(f"\n\x1b[1;32m--- processing {file_name} ---\x1b[0m\n")
|
|
31
|
+
data = {}
|
|
32
|
+
errors = []
|
|
33
|
+
warnings = []
|
|
34
|
+
identified = False
|
|
35
|
+
|
|
36
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
37
|
+
|
|
38
|
+
for line_num, line in enumerate(lines, 1):
|
|
39
|
+
line = line.strip()
|
|
40
|
+
|
|
41
|
+
# discard empty lines
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# syntax check // square brackets
|
|
46
|
+
if not (line.startswith("[") and line.endswith("]")) or ":" not in line:
|
|
47
|
+
errors.append(f"[{file_name}:{line_num}] invalid syntax -> '{line}'")
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
content = line[1:-1].strip()
|
|
51
|
+
key, value = content.split(":", 1)
|
|
52
|
+
key, value = key.strip().lower(), value.strip()
|
|
53
|
+
|
|
54
|
+
# syntax check // key/value args
|
|
55
|
+
if not key or not value:
|
|
56
|
+
errors.append(f"[{file_name}:{line_num}] empty key/value in '{line}'")
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# metadata checks
|
|
60
|
+
if key == "explicit":
|
|
61
|
+
if value.lower() not in ("true", "false"):
|
|
62
|
+
errors.append(f"[{file_name}:{line_num}] 'explicit' must be 'true' or 'false'.")
|
|
63
|
+
else:
|
|
64
|
+
data[key] = value.lower() == "true"
|
|
65
|
+
|
|
66
|
+
elif key in ("search-links", "playback-links"):
|
|
67
|
+
links = [url.strip() for url in value.split("|")]
|
|
68
|
+
data[key] = links
|
|
69
|
+
|
|
70
|
+
elif key in ("artist", "romanized-artist"):
|
|
71
|
+
data[key] = [a.strip() for a in value.split(",")]
|
|
72
|
+
|
|
73
|
+
elif key in ("title", "romanized-title", "album"):
|
|
74
|
+
data[key] = value
|
|
75
|
+
|
|
76
|
+
elif key == "lyrics":
|
|
77
|
+
data[key] = value
|
|
78
|
+
lrc_file = file_path.parent / value
|
|
79
|
+
if not lrc_file.exists():
|
|
80
|
+
warnings.append(f"[{file_name}] lyrics file '{value}' missing.")
|
|
81
|
+
|
|
82
|
+
else:
|
|
83
|
+
errors.append(f"[{file_name}:{line_num}] unidentified metadata found -> '{line}'")
|
|
84
|
+
|
|
85
|
+
if track_identity and not identified:
|
|
86
|
+
title = data.get("romanized-title") or data.get("title")
|
|
87
|
+
artist = data.get("romanized-artist") or data.get("artist")
|
|
88
|
+
if title and artist:
|
|
89
|
+
track_identity(title, artist)
|
|
90
|
+
identified = True
|
|
91
|
+
|
|
92
|
+
missing = REQUIRED - set(data.keys())
|
|
93
|
+
if missing:
|
|
94
|
+
errors.append(f"[{file_name}] missing required parameter(s): {', '.join(missing)}")
|
|
95
|
+
|
|
96
|
+
if errors:
|
|
97
|
+
if not silent:
|
|
98
|
+
print(f"\x1b[1;31m[-] status: FAILED\x1b[0m")
|
|
99
|
+
for error in errors:
|
|
100
|
+
print(f"\x1b[1;31m• [E] {error}\x1b[0m")
|
|
101
|
+
return None
|
|
102
|
+
if not silent:
|
|
103
|
+
print(f"\x1b[1;32m[+] status: PASSED\x1b[0m")
|
|
104
|
+
for warn in warnings:
|
|
105
|
+
print(f"\x1b[1;33m• [!] {warn}\x1b[0m")
|
|
106
|
+
|
|
107
|
+
print("\n\x1b[1;36m[+] parsed metadata:\x1b[0m")
|
|
108
|
+
print(data)
|
|
109
|
+
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
def _track_identify(file_name: str) -> Any | dict[str, str]:
|
|
113
|
+
directory = Path(__file__).parent.resolve()
|
|
114
|
+
file_path = Path(directory / file_name).resolve()
|
|
115
|
+
file_ext = Path(file_name).suffix
|
|
116
|
+
|
|
117
|
+
if not file_path.exists():
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
if file_ext != ".mdata":
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
track = {}
|
|
124
|
+
|
|
125
|
+
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
126
|
+
|
|
127
|
+
for line in lines:
|
|
128
|
+
line = line.strip()
|
|
129
|
+
|
|
130
|
+
# discard empty lines
|
|
131
|
+
if not line:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# syntax check // square brackets
|
|
135
|
+
if not (line.startswith("[") and line.endswith("]")) or ":" not in line:
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
content = line[1:-1].strip()
|
|
139
|
+
key, value = content.split(":", 1)
|
|
140
|
+
key, value = key.strip().lower(), value.strip()
|
|
141
|
+
|
|
142
|
+
if key in ("artist", "romanized-artist"):
|
|
143
|
+
track['artist'] = [a.strip() for a in value.split(",")]
|
|
144
|
+
|
|
145
|
+
elif key in ("title", "romanized-title"):
|
|
146
|
+
track['title'] = value
|
|
147
|
+
|
|
148
|
+
elif key == "explicit":
|
|
149
|
+
if value.lower() not in ("true", "false"):
|
|
150
|
+
continue
|
|
151
|
+
else:
|
|
152
|
+
track['explicit'] = value.lower()
|
|
153
|
+
|
|
154
|
+
else:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
return track
|
|
158
|
+
|
|
159
|
+
def multi_file_input():
|
|
160
|
+
files = []
|
|
161
|
+
print("\nenter file names (or hit Enter to finish)")
|
|
162
|
+
while True:
|
|
163
|
+
file = input("\x1b[1;38;2;12;59;5m[?] .mdata file to parse -> ")
|
|
164
|
+
sys.stdout.write("\x1b[0m")
|
|
165
|
+
sys.stdout.flush()
|
|
166
|
+
|
|
167
|
+
if file == "":
|
|
168
|
+
break
|
|
169
|
+
|
|
170
|
+
files.append(file)
|
|
171
|
+
|
|
172
|
+
for mdata_file in files:
|
|
173
|
+
validate_mdata(mdata_file)
|
|
174
|
+
|
|
175
|
+
def single_file_input():
|
|
176
|
+
file = input("\x1b[1;38;2;12;59;5m[?] .mdata file to parse -> ")
|
|
177
|
+
sys.stdout.write("\x1b[0m")
|
|
178
|
+
sys.stdout.flush()
|
|
179
|
+
|
|
180
|
+
validate_mdata(file)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# for systems that have to programmically pass files without user input
|
|
184
|
+
def singlefile(file: str, silent: bool = False) -> dict | None:
|
|
185
|
+
return validate_mdata(file, silent=silent)
|
|
186
|
+
|
|
187
|
+
def multifile(*files: str, silent: bool = False) -> list[dict | None]:
|
|
188
|
+
return [validate_mdata(file, silent=silent) for file in files]
|
|
189
|
+
|
|
190
|
+
def singletrack_identity(file: str) -> None:
|
|
191
|
+
identity = _track_identify(file)
|
|
192
|
+
if not identity:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
print(f"\x1b[1m[{file}] track info:\x1b[0m")
|
|
196
|
+
print(f"- title: \x1b[1m{identity['title']}\x1b[0m")
|
|
197
|
+
print(f"- artist(s): \x1b[1m{', '.join(identity['artist'])}\x1b[0m")
|
|
198
|
+
print(f"- explicit: \x1b[1m{identity['explicit']}\x1b[0m")
|
|
199
|
+
|
|
200
|
+
def multitrack_identify(*files: str):
|
|
201
|
+
for file in files:
|
|
202
|
+
singletrack_identity(file)
|
|
203
|
+
|
|
204
|
+
def singlefile_autoname(file: str):
|
|
205
|
+
directory = Path(__file__).parent.resolve()
|
|
206
|
+
file_path = Path(directory / file).resolve()
|
|
207
|
+
file_ext = Path(file).suffix
|
|
208
|
+
identity = _track_identify(file)
|
|
209
|
+
if not identity or 'title' not in identity or 'artist' not in identity:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
title = identity['title']
|
|
213
|
+
artists = ', '.join(identity['artist']) if isinstance(identity['artist'], list) else identity['artist']
|
|
214
|
+
|
|
215
|
+
_temp = re.sub(r"[^\w\s]", "", title)
|
|
216
|
+
filetitle = re.sub(r"\s+", "_", _temp.strip())
|
|
217
|
+
|
|
218
|
+
_temp = re.sub(r"[^\w\s]", "", artists)
|
|
219
|
+
fileartists = re.sub(r"\s+", "_", _temp.strip())
|
|
220
|
+
|
|
221
|
+
fullfile = f"{filetitle.lower()}-{fileartists.lower()}.mdata"
|
|
222
|
+
|
|
223
|
+
new_file = file_path.parent / fullfile
|
|
224
|
+
file_path.replace(new_file)
|
|
225
|
+
|
|
226
|
+
def multifile_autoname(*files: str):
|
|
227
|
+
for file in files:
|
|
228
|
+
singlefile_autoname(file)
|
|
229
|
+
|
|
230
|
+
def directory_mdata(dir_path: str = ".", recursive: bool = False, silent: bool = False) -> list[dict | None]:
|
|
231
|
+
target_dir = Path(__file__).parent.resolve() / dir_path
|
|
232
|
+
|
|
233
|
+
if not target_dir.is_dir():
|
|
234
|
+
if not silent:
|
|
235
|
+
print(f"\x1b[1;31m[-] directory '{dir_path}' not found.\x1b[0m")
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
# search recursively or just top-level
|
|
239
|
+
pattern = "**/*.mdata" if recursive else "*.mdata"
|
|
240
|
+
mdata_files = [f.name for f in target_dir.glob(pattern)]
|
|
241
|
+
|
|
242
|
+
if not mdata_files and not silent:
|
|
243
|
+
print(f"\x1b[1;33m[!] no .mdata files found in '{dir_path}'.\x1b[0m")
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
return [validate_mdata(file, silent=silent) for file in mdata_files]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def directory_autoname(dir_path: str = ".", recursive: bool = False):
|
|
250
|
+
target_dir = Path(__file__).parent.resolve() / dir_path
|
|
251
|
+
|
|
252
|
+
if not target_dir.is_dir():
|
|
253
|
+
print(f"\x1b[1;31m[-] directory '{dir_path}' not found.\x1b[0m")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
pattern = "**/*.mdata" if recursive else "*.mdata"
|
|
257
|
+
for file_path in target_dir.glob(pattern):
|
|
258
|
+
# pass relative path from directory root
|
|
259
|
+
singlefile_autoname(file_path.name)
|
|
260
|
+
|
|
261
|
+
def main():
|
|
262
|
+
while True:
|
|
263
|
+
print("\x1b[1mselect mode:\x1b[0m")
|
|
264
|
+
print("1. single file")
|
|
265
|
+
print("2. multiple files")
|
|
266
|
+
print("3. entire folder")
|
|
267
|
+
|
|
268
|
+
mode = input("\x1b[1;38;2;12;59;5m[?] option (1/2/3) -> \x1b[0m").strip()
|
|
269
|
+
|
|
270
|
+
if mode == "1":
|
|
271
|
+
single_file_input()
|
|
272
|
+
break
|
|
273
|
+
elif mode == "2":
|
|
274
|
+
multi_file_input()
|
|
275
|
+
break
|
|
276
|
+
elif mode == "3":
|
|
277
|
+
folder = input("\x1b[1;38;2;12;59;5m[?] folder path (leave empty for current dir) -> \x1b[0m").strip() or "."
|
|
278
|
+
rec = input("\x1b[1;38;2;12;59;5m[?] include subfolders? (y/n) -> \x1b[0m").lower().startswith("y")
|
|
279
|
+
directory_mdata(folder, recursive=rec)
|
|
280
|
+
break
|
|
281
|
+
else:
|
|
282
|
+
print("\x1b[1;33m[!] invalid option.\x1b[0m\n")
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
main()
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: music-mdata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A standard cross-platform parser and metadata builder for .mdata files.
|
|
5
|
+
Author: blasie
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/blasieuwu/music-metadata
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
mdata.py,sha256=4VMfh5z1H7cwWuPWTNNUDMUzjaUIauoFyu6WTOcXVOc,9341
|
|
2
|
+
music_mdata-0.1.0.dist-info/METADATA,sha256=BsOKXdnZovLDiIX759ofCFzy7kIwRKlRgJG3pOS4HB0,303
|
|
3
|
+
music_mdata-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
music_mdata-0.1.0.dist-info/top_level.txt,sha256=h-xO_s81o_AqhGRl5zO5W_Ti8gsxBORLWuQrkZGJ3UY,6
|
|
5
|
+
music_mdata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mdata
|