absfuyu 5.0.0__py3-none-any.whl → 6.1.2__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.
Potentially problematic release.
This version of absfuyu might be problematic. Click here for more details.
- absfuyu/__init__.py +5 -3
- absfuyu/__main__.py +3 -3
- absfuyu/cli/__init__.py +13 -2
- absfuyu/cli/audio_group.py +98 -0
- absfuyu/cli/color.py +30 -14
- absfuyu/cli/config_group.py +9 -2
- absfuyu/cli/do_group.py +23 -6
- absfuyu/cli/game_group.py +27 -2
- absfuyu/cli/tool_group.py +81 -11
- absfuyu/config/__init__.py +3 -3
- absfuyu/core/__init__.py +12 -8
- absfuyu/core/baseclass.py +929 -96
- absfuyu/core/baseclass2.py +44 -3
- absfuyu/core/decorator.py +70 -4
- absfuyu/core/docstring.py +64 -41
- absfuyu/core/dummy_cli.py +3 -3
- absfuyu/core/dummy_func.py +19 -6
- absfuyu/dxt/__init__.py +2 -2
- absfuyu/dxt/base_type.py +93 -0
- absfuyu/dxt/dictext.py +204 -16
- absfuyu/dxt/dxt_support.py +2 -2
- absfuyu/dxt/intext.py +151 -34
- absfuyu/dxt/listext.py +969 -127
- absfuyu/dxt/strext.py +77 -17
- absfuyu/extra/__init__.py +2 -2
- absfuyu/extra/audio/__init__.py +8 -0
- absfuyu/extra/audio/_util.py +57 -0
- absfuyu/extra/audio/convert.py +192 -0
- absfuyu/extra/audio/lossless.py +281 -0
- absfuyu/extra/beautiful.py +3 -2
- absfuyu/extra/da/__init__.py +72 -0
- absfuyu/extra/da/dadf.py +1600 -0
- absfuyu/extra/da/dadf_base.py +186 -0
- absfuyu/extra/da/df_func.py +181 -0
- absfuyu/extra/da/mplt.py +219 -0
- absfuyu/extra/ggapi/__init__.py +8 -0
- absfuyu/extra/ggapi/gdrive.py +223 -0
- absfuyu/extra/ggapi/glicense.py +148 -0
- absfuyu/extra/ggapi/glicense_df.py +186 -0
- absfuyu/extra/ggapi/gsheet.py +88 -0
- absfuyu/extra/img/__init__.py +30 -0
- absfuyu/extra/img/converter.py +402 -0
- absfuyu/extra/img/dup_check.py +291 -0
- absfuyu/extra/pdf.py +87 -0
- absfuyu/extra/rclone.py +253 -0
- absfuyu/extra/xml.py +90 -0
- absfuyu/fun/__init__.py +7 -20
- absfuyu/fun/rubik.py +442 -0
- absfuyu/fun/tarot.py +2 -2
- absfuyu/game/__init__.py +2 -2
- absfuyu/game/game_stat.py +2 -2
- absfuyu/game/schulte.py +78 -0
- absfuyu/game/sudoku.py +2 -2
- absfuyu/game/tictactoe.py +2 -3
- absfuyu/game/wordle.py +6 -4
- absfuyu/general/__init__.py +4 -4
- absfuyu/general/content.py +4 -4
- absfuyu/general/human.py +2 -2
- absfuyu/general/resrel.py +213 -0
- absfuyu/general/shape.py +3 -8
- absfuyu/general/tax.py +344 -0
- absfuyu/logger.py +806 -59
- absfuyu/numbers/__init__.py +13 -0
- absfuyu/numbers/number_to_word.py +321 -0
- absfuyu/numbers/shorten_number.py +303 -0
- absfuyu/numbers/time_duration.py +217 -0
- absfuyu/pkg_data/__init__.py +2 -2
- absfuyu/pkg_data/deprecated.py +2 -2
- absfuyu/pkg_data/logo.py +1462 -0
- absfuyu/sort.py +4 -4
- absfuyu/tools/__init__.py +28 -2
- absfuyu/tools/checksum.py +144 -9
- absfuyu/tools/converter.py +120 -34
- absfuyu/tools/generator.py +461 -0
- absfuyu/tools/inspector.py +752 -0
- absfuyu/tools/keygen.py +2 -2
- absfuyu/tools/obfuscator.py +47 -9
- absfuyu/tools/passwordlib.py +89 -25
- absfuyu/tools/shutdownizer.py +3 -8
- absfuyu/tools/sw.py +718 -0
- absfuyu/tools/web.py +10 -13
- absfuyu/typings.py +138 -0
- absfuyu/util/__init__.py +114 -6
- absfuyu/util/api.py +41 -18
- absfuyu/util/cli.py +119 -0
- absfuyu/util/gui.py +91 -0
- absfuyu/util/json_method.py +43 -14
- absfuyu/util/lunar.py +2 -2
- absfuyu/util/package.py +124 -0
- absfuyu/util/path.py +702 -82
- absfuyu/util/performance.py +122 -7
- absfuyu/util/shorten_number.py +244 -21
- absfuyu/util/text_table.py +481 -0
- absfuyu/util/zipped.py +8 -7
- absfuyu/version.py +79 -59
- {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/METADATA +52 -11
- absfuyu-6.1.2.dist-info/RECORD +105 -0
- {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/WHEEL +1 -1
- absfuyu/extra/data_analysis.py +0 -1078
- absfuyu/general/generator.py +0 -303
- absfuyu-5.0.0.dist-info/RECORD +0 -68
- {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/entry_points.txt +0 -0
- {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/licenses/LICENSE +0 -0
absfuyu/dxt/strext.py
CHANGED
|
@@ -3,8 +3,8 @@ Absfuyu: Data Extension
|
|
|
3
3
|
-----------------------
|
|
4
4
|
str extension
|
|
5
5
|
|
|
6
|
-
Version:
|
|
7
|
-
Date updated:
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
# Module Package
|
|
@@ -15,14 +15,72 @@ __all__ = ["Text", "TextAnalyzeDictFormat"]
|
|
|
15
15
|
# Library
|
|
16
16
|
# ---------------------------------------------------------------------------
|
|
17
17
|
import random
|
|
18
|
+
from string import ascii_letters as _ascii_letters
|
|
18
19
|
from typing import NotRequired, Self, TypedDict
|
|
19
20
|
|
|
20
|
-
from absfuyu.core import
|
|
21
|
-
from absfuyu.general.generator import Charset, Generator
|
|
22
|
-
from absfuyu.logger import logger
|
|
21
|
+
from absfuyu.core import GetClassMembersMixin, deprecated, versionadded, versionchanged
|
|
23
22
|
from absfuyu.util import set_min_max
|
|
24
23
|
|
|
25
24
|
|
|
25
|
+
# Support function
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
def _generate_string(
|
|
28
|
+
charset: str | None = None,
|
|
29
|
+
size: int = 8,
|
|
30
|
+
times: int = 1,
|
|
31
|
+
unique: bool = True,
|
|
32
|
+
) -> list[str]:
|
|
33
|
+
"""
|
|
34
|
+
Generate a list of random string from character set
|
|
35
|
+
(Random string generator)
|
|
36
|
+
|
|
37
|
+
This is a lesser version of
|
|
38
|
+
``absfuyu.tools.generator.Generator.generate_string()``
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
charset : str, optional
|
|
43
|
+
Custom character set, by default ``None``
|
|
44
|
+
([a-zA-Z] - string.ascii_letters)
|
|
45
|
+
|
|
46
|
+
size : int, optional
|
|
47
|
+
Length of each string in list, by default ``8``
|
|
48
|
+
|
|
49
|
+
times : int, optional
|
|
50
|
+
How many random string generated, by default ``1``
|
|
51
|
+
|
|
52
|
+
unique : bool, optional
|
|
53
|
+
Each generated text is unique, by default ``True``
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
list[str]
|
|
58
|
+
List of random string generated
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
charset = _ascii_letters or charset
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
char_lst = list(charset) # type: ignore[arg-type]
|
|
65
|
+
except Exception:
|
|
66
|
+
char_lst = charset # type: ignore[assignment]
|
|
67
|
+
|
|
68
|
+
unique_string = []
|
|
69
|
+
count = 0
|
|
70
|
+
|
|
71
|
+
while count < times:
|
|
72
|
+
gen_string = "".join(random.choice(char_lst) for _ in range(size)) # type: ignore
|
|
73
|
+
if not unique:
|
|
74
|
+
unique_string.append(gen_string)
|
|
75
|
+
count += 1
|
|
76
|
+
else:
|
|
77
|
+
if gen_string not in unique_string:
|
|
78
|
+
unique_string.append(gen_string)
|
|
79
|
+
count += 1
|
|
80
|
+
|
|
81
|
+
return unique_string
|
|
82
|
+
|
|
83
|
+
|
|
26
84
|
# Class
|
|
27
85
|
# ---------------------------------------------------------------------------
|
|
28
86
|
class TextAnalyzeDictFormat(TypedDict):
|
|
@@ -58,9 +116,12 @@ class TextAnalyzeDictFormat(TypedDict):
|
|
|
58
116
|
is_palindrome: NotRequired[bool]
|
|
59
117
|
|
|
60
118
|
|
|
61
|
-
class Text(
|
|
119
|
+
class Text(GetClassMembersMixin, str):
|
|
62
120
|
"""
|
|
63
121
|
``str`` extension
|
|
122
|
+
|
|
123
|
+
>>> # For a list of new methods
|
|
124
|
+
>>> Text.show_all_methods()
|
|
64
125
|
"""
|
|
65
126
|
|
|
66
127
|
def divide(self, string_split_size: int = 60) -> list[str]:
|
|
@@ -154,9 +215,7 @@ class Text(ShowAllMethodsMixin, str):
|
|
|
154
215
|
splt_len = len(temp)
|
|
155
216
|
|
|
156
217
|
if custom_var_name is None:
|
|
157
|
-
splt_name =
|
|
158
|
-
charset=Charset.ALPHABET, size=split_var_len, times=splt_len + 1
|
|
159
|
-
)
|
|
218
|
+
splt_name = _generate_string(size=split_var_len, times=splt_len + 1)
|
|
160
219
|
for i in range(splt_len):
|
|
161
220
|
output.append(f"{splt_name[i]}='{temp[i]}'")
|
|
162
221
|
else:
|
|
@@ -361,14 +420,15 @@ class Text(ShowAllMethodsMixin, str):
|
|
|
361
420
|
probability = int(set_min_max(probability))
|
|
362
421
|
text = self.lower()
|
|
363
422
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
temp.append(x)
|
|
369
|
-
logger.debug(temp)
|
|
370
|
-
return self.__class__("".join(temp))
|
|
423
|
+
random_caps = (
|
|
424
|
+
x.upper() if random.randint(1, 100) <= probability else x for x in text
|
|
425
|
+
)
|
|
426
|
+
return self.__class__("".join(random_caps))
|
|
371
427
|
|
|
428
|
+
@deprecated(
|
|
429
|
+
"5.2.0",
|
|
430
|
+
reason="str already has swapcase() method, will be removed in version 5.3.0",
|
|
431
|
+
)
|
|
372
432
|
@versionchanged("5.0.0", reason="Use ``str.swapcase()``")
|
|
373
433
|
def reverse_capslock(self) -> Self:
|
|
374
434
|
"""
|
|
@@ -406,7 +466,7 @@ class Text(ShowAllMethodsMixin, str):
|
|
|
406
466
|
"""
|
|
407
467
|
return list(self)
|
|
408
468
|
|
|
409
|
-
@deprecated("5.0.0", reason="Unused")
|
|
469
|
+
@deprecated("5.0.0", reason="Unused, will be removed in version 5.3.0")
|
|
410
470
|
def to_listext(self) -> None:
|
|
411
471
|
"""Deprecated, will be removed soon"""
|
|
412
472
|
raise NotImplementedError("Deprecated, will be removed soon")
|
absfuyu/extra/__init__.py
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: Audio
|
|
3
|
+
--------------
|
|
4
|
+
Audio convert, lossless checker
|
|
5
|
+
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Module level
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
__all__ = ["StatusCode", "ResultStatus"]
|
|
13
|
+
|
|
14
|
+
# Library
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# Class Enum, Result
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
try:
|
|
22
|
+
from rich import print
|
|
23
|
+
|
|
24
|
+
class StatusCode(StrEnum):
|
|
25
|
+
OK = "[bold green]OK[/]"
|
|
26
|
+
SKIP = "[bold yellow]SKIP[/]"
|
|
27
|
+
ERROR = "[bold red]ERROR[/]"
|
|
28
|
+
LOSSLESS = "[bold green]LOSSLESS[/]"
|
|
29
|
+
NOT_LOSSLESS = "[bold red]NOT LOSSLESS[/]"
|
|
30
|
+
HIRES = "[bold blue]HIRES[/]"
|
|
31
|
+
|
|
32
|
+
except ImportError:
|
|
33
|
+
|
|
34
|
+
class StatusCode(StrEnum):
|
|
35
|
+
OK = "OK"
|
|
36
|
+
SKIP = "SKIP"
|
|
37
|
+
ERROR = "ERROR"
|
|
38
|
+
LOSSLESS = "LOSSLESS"
|
|
39
|
+
NOT_LOSSLESS = "NOT LOSSLESS"
|
|
40
|
+
HIRES = "HIRES"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ResultStatus:
|
|
44
|
+
"""
|
|
45
|
+
Result status
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, status: StatusCode, path: Path) -> None:
|
|
49
|
+
self.status = status
|
|
50
|
+
self.path = path
|
|
51
|
+
|
|
52
|
+
def __repr__(self) -> str:
|
|
53
|
+
return f"{self.status} : {self.path.name}"
|
|
54
|
+
|
|
55
|
+
def print(self) -> None:
|
|
56
|
+
"""Print repr (for rich package)"""
|
|
57
|
+
print(self.status, ": ", self.path.name)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: Audio
|
|
3
|
+
--------------
|
|
4
|
+
Audio convert
|
|
5
|
+
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Module level
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
__all__ = ["DirectoryAudioConvertMixin"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Library
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Literal
|
|
22
|
+
|
|
23
|
+
from absfuyu.core.dummy_func import tqdm
|
|
24
|
+
from absfuyu.extra.audio._util import ResultStatus as ConvertStatus
|
|
25
|
+
from absfuyu.extra.audio._util import StatusCode
|
|
26
|
+
from absfuyu.util import is_command_available
|
|
27
|
+
from absfuyu.util.path import DirectoryBase
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Class
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
class DirectoryAudioConvertMixin(DirectoryBase):
|
|
33
|
+
"""
|
|
34
|
+
Directory - Audio convert to mp3
|
|
35
|
+
|
|
36
|
+
- convert_to_mp3
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, source_path, create_if_not_exist=False):
|
|
40
|
+
super().__init__(source_path, create_if_not_exist)
|
|
41
|
+
is_command_available(["ffmpeg"], "ERROR: ffmpeg not installed to PATH")
|
|
42
|
+
|
|
43
|
+
def _read_metadata(self, audio_path: Path, /) -> dict:
|
|
44
|
+
"""
|
|
45
|
+
Read audio metadata
|
|
46
|
+
|
|
47
|
+
Parameters
|
|
48
|
+
----------
|
|
49
|
+
audio_path : Path
|
|
50
|
+
Path to audio
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
dict
|
|
55
|
+
Audio's metadata
|
|
56
|
+
"""
|
|
57
|
+
cmd = [
|
|
58
|
+
"ffprobe",
|
|
59
|
+
"-v",
|
|
60
|
+
"quiet",
|
|
61
|
+
"-print_format",
|
|
62
|
+
"json",
|
|
63
|
+
"-show_format",
|
|
64
|
+
str(audio_path.resolve()),
|
|
65
|
+
]
|
|
66
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
67
|
+
data = json.loads(result.stdout)
|
|
68
|
+
|
|
69
|
+
if "format" in data and "tags" in data["format"]:
|
|
70
|
+
return data["format"]["tags"]
|
|
71
|
+
return {}
|
|
72
|
+
|
|
73
|
+
def convert_one(self, audio_path: Path | str, bitrate: Literal["128k", "320k"] = "320k") -> ConvertStatus:
|
|
74
|
+
"""
|
|
75
|
+
Convert audio to mp3
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
audio_path : Path | str
|
|
80
|
+
Path to audio
|
|
81
|
+
|
|
82
|
+
bitrate : Literal["128k", "320k"], optional
|
|
83
|
+
Bitrate, by default "320k"
|
|
84
|
+
|
|
85
|
+
Returns
|
|
86
|
+
-------
|
|
87
|
+
ConvertStatus
|
|
88
|
+
Result
|
|
89
|
+
"""
|
|
90
|
+
audio_path = Path(audio_path)
|
|
91
|
+
mp3_path = audio_path.with_suffix(".mp3")
|
|
92
|
+
|
|
93
|
+
if mp3_path.exists():
|
|
94
|
+
return ConvertStatus(StatusCode.SKIP, mp3_path)
|
|
95
|
+
|
|
96
|
+
metadata = self._read_metadata(audio_path)
|
|
97
|
+
|
|
98
|
+
ffmpeg_cmd = ["ffmpeg", "-y", "-i", str(audio_path.resolve())]
|
|
99
|
+
|
|
100
|
+
# Add metadata tags
|
|
101
|
+
for key, value in metadata.items():
|
|
102
|
+
ffmpeg_cmd.extend(["-metadata", f"{key}={value}"])
|
|
103
|
+
|
|
104
|
+
ffmpeg_cmd.extend(["-vn", "-codec:a", "libmp3lame", "-b:a", bitrate, str(mp3_path.resolve())])
|
|
105
|
+
|
|
106
|
+
subprocess.run(ffmpeg_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
107
|
+
return ConvertStatus(StatusCode.OK, mp3_path)
|
|
108
|
+
|
|
109
|
+
def convert_to_mp3_single_thread(
|
|
110
|
+
self,
|
|
111
|
+
from_format: str = ".flac",
|
|
112
|
+
recursive: bool = True,
|
|
113
|
+
bitrate: Literal["128k", "320k"] = "320k",
|
|
114
|
+
) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Convert audios to .mp3 - Single thread
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
from_format : str, optional
|
|
121
|
+
Audio format, by default ".flac"
|
|
122
|
+
|
|
123
|
+
recursive : bool, optional
|
|
124
|
+
Include audio in child folder, by default True
|
|
125
|
+
|
|
126
|
+
bitrate : Literal["128k", "320k"], optional
|
|
127
|
+
Bitrate, by default "320k"
|
|
128
|
+
"""
|
|
129
|
+
audios = list(self.source_path.rglob(f"{'**/*'if recursive else '*'}{from_format}"))
|
|
130
|
+
|
|
131
|
+
if not audios:
|
|
132
|
+
print(f"No {from_format.upper()} files found.")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
print(f"Found {len(audios)} {from_format.upper()} files.")
|
|
136
|
+
|
|
137
|
+
results: list[ConvertStatus] = []
|
|
138
|
+
for x in tqdm(audios, desc="Converting", unit="file", unit_scale=True):
|
|
139
|
+
results.append(self.convert_one(x, bitrate=bitrate))
|
|
140
|
+
|
|
141
|
+
print("\n--- Summary ---")
|
|
142
|
+
for line in results:
|
|
143
|
+
line.print()
|
|
144
|
+
|
|
145
|
+
def convert_to_mp3(
|
|
146
|
+
self,
|
|
147
|
+
from_format: str = ".flac",
|
|
148
|
+
workers: int | None = None,
|
|
149
|
+
recursive: bool = True,
|
|
150
|
+
bitrate: Literal["128k", "320k"] = "320k",
|
|
151
|
+
) -> None:
|
|
152
|
+
"""
|
|
153
|
+
Convert audios to .mp3
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
from_format : str, optional
|
|
158
|
+
Audio format, by default ".flac"
|
|
159
|
+
|
|
160
|
+
workers : int | None, optional
|
|
161
|
+
Number of parallel processing threads, by default None
|
|
162
|
+
|
|
163
|
+
recursive : bool, optional
|
|
164
|
+
Include audio in child folder, by default True
|
|
165
|
+
|
|
166
|
+
bitrate : Literal["128k", "320k"], optional
|
|
167
|
+
Bitrate, by default "320k"
|
|
168
|
+
"""
|
|
169
|
+
audios = list(self.source_path.rglob(f"{'**/*'if recursive else '*'}{from_format}"))
|
|
170
|
+
|
|
171
|
+
if not audios:
|
|
172
|
+
print(f"No {from_format.upper()} files found.")
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
print(f"Found {len(audios)} {from_format.upper()} files.")
|
|
176
|
+
|
|
177
|
+
results: list[ConvertStatus] = []
|
|
178
|
+
with ProcessPoolExecutor(max_workers=workers) as executor:
|
|
179
|
+
futures = {executor.submit(self.convert_one, x, bitrate): x for x in audios}
|
|
180
|
+
|
|
181
|
+
for fut in tqdm(
|
|
182
|
+
as_completed(futures),
|
|
183
|
+
total=len(futures),
|
|
184
|
+
desc="Converting",
|
|
185
|
+
unit="file",
|
|
186
|
+
unit_scale=True,
|
|
187
|
+
):
|
|
188
|
+
results.append(fut.result())
|
|
189
|
+
|
|
190
|
+
print("\n--- Summary ---")
|
|
191
|
+
for line in results:
|
|
192
|
+
line.print()
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Absfuyu: Audio
|
|
3
|
+
--------------
|
|
4
|
+
Audio lossless checker
|
|
5
|
+
|
|
6
|
+
Version: 6.1.1
|
|
7
|
+
Date updated: 30/12/2025 (dd/mm/yyyy)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Module level
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
__all__ = ["AudioInfo", "DirectoryAudioLosslessCheckMixin"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Library
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, NamedTuple
|
|
23
|
+
|
|
24
|
+
from absfuyu.core.dummy_func import tqdm
|
|
25
|
+
from absfuyu.extra.audio._util import ResultStatus, StatusCode
|
|
26
|
+
from absfuyu.util import is_command_available
|
|
27
|
+
from absfuyu.util.path import DirectoryBase
|
|
28
|
+
from absfuyu.util.shorten_number import Duration
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import numpy as np
|
|
32
|
+
except ImportError:
|
|
33
|
+
from subprocess import run
|
|
34
|
+
|
|
35
|
+
from absfuyu.config import ABSFUYU_CONFIG
|
|
36
|
+
|
|
37
|
+
if ABSFUYU_CONFIG._get_setting("auto-install-extra").value: # type: ignore
|
|
38
|
+
cmd = "python -m pip install -U absfuyu[extra]".split()
|
|
39
|
+
run(cmd)
|
|
40
|
+
else:
|
|
41
|
+
raise SystemExit("This feature is in absfuyu[extra] package") # noqa: B904
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Class
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
class FrequencyRange(NamedTuple):
|
|
47
|
+
"""Audio frequency range"""
|
|
48
|
+
|
|
49
|
+
min: int | float
|
|
50
|
+
max: int | float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AudioInfo:
|
|
55
|
+
"""
|
|
56
|
+
Audio infomation
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
sample_rate : int | None, optional
|
|
61
|
+
Sample rate (Hz)
|
|
62
|
+
|
|
63
|
+
bit_depth : int, optional
|
|
64
|
+
Bit depth
|
|
65
|
+
|
|
66
|
+
channels : int | None, optional
|
|
67
|
+
Number of channels
|
|
68
|
+
|
|
69
|
+
codec : str | None, optional
|
|
70
|
+
Audio codec
|
|
71
|
+
|
|
72
|
+
duration_raw : float, optional
|
|
73
|
+
Duration of audio (second)
|
|
74
|
+
|
|
75
|
+
bitrate : Any | None, optional
|
|
76
|
+
Bitrate
|
|
77
|
+
|
|
78
|
+
audio_path : Path | None, optional
|
|
79
|
+
Path to audio
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
AudioInfo
|
|
84
|
+
Audio infomation
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
sample_rate: int | None = field(default=None, metadata={"unit": "Hz"})
|
|
88
|
+
bit_depth: int = field(default=0, metadata={"unit": "bit"})
|
|
89
|
+
channels: int | None = field(default=None)
|
|
90
|
+
codec: str | None = field(default=None)
|
|
91
|
+
duration_raw: float = field(default=0.0, repr=False, metadata={"unit": "second"})
|
|
92
|
+
bitrate: int | None = field(default=None)
|
|
93
|
+
audio_path: Path | None = field(default=None, repr=False) # hide path in repr
|
|
94
|
+
|
|
95
|
+
# computed fields
|
|
96
|
+
duration: float = field(init=False)
|
|
97
|
+
freq_range: FrequencyRange = field(init=False, metadata={"unit": "Hz"})
|
|
98
|
+
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
self.duration = Duration(self.duration_raw)
|
|
101
|
+
|
|
102
|
+
# Resource intensive method
|
|
103
|
+
# _, sampling_rate = librosa.load(audio_path, sr=None)
|
|
104
|
+
# freqs = librosa.fft_frequencies(sr=sampling_rate)
|
|
105
|
+
freqs = np.fft.rfftfreq(n=2048, d=1.0 / self.sample_rate)
|
|
106
|
+
self.freq_range = FrequencyRange(freqs[0], freqs[-1])
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_lossless(self) -> bool:
|
|
110
|
+
"""
|
|
111
|
+
Audio is lossless when frequencies above 20,000 are not cut off
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
bool
|
|
116
|
+
If audio is lossless
|
|
117
|
+
"""
|
|
118
|
+
return self.freq_range[1] >= 20000
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def is_hi_res(self) -> bool:
|
|
122
|
+
"""
|
|
123
|
+
Audio is HiRes when lossless and have sample rate >= 48,000Hz or bit rate >= 16 bit
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
bool
|
|
128
|
+
If audio is lossless
|
|
129
|
+
"""
|
|
130
|
+
bd = 0 if self.bit_depth is None else self.bit_depth
|
|
131
|
+
return all([self.sample_rate >= 48000, bd >= 24, self.is_lossless])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DirectoryAudioLosslessCheckMixin(DirectoryBase):
|
|
135
|
+
"""
|
|
136
|
+
Directory - Audio lossless checker
|
|
137
|
+
|
|
138
|
+
- lossless_check
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def get_audio_info(self, audio_path: Path, /) -> AudioInfo:
|
|
142
|
+
"""
|
|
143
|
+
Return audio info using ffprobe.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
audio_path : Path
|
|
148
|
+
Path to audio
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
AudioInfo
|
|
153
|
+
Audio infomation
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
is_command_available(["ffmpeg"], "ERROR: ffmpeg not installed to PATH")
|
|
157
|
+
|
|
158
|
+
# Probe
|
|
159
|
+
cmd = [
|
|
160
|
+
"ffprobe",
|
|
161
|
+
"-v",
|
|
162
|
+
"error",
|
|
163
|
+
"-print_format",
|
|
164
|
+
"json",
|
|
165
|
+
"-show_streams",
|
|
166
|
+
"-select_streams",
|
|
167
|
+
"a:0",
|
|
168
|
+
str(audio_path.resolve()),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
172
|
+
data: dict[str, Any] = json.loads(result.stdout)["streams"][0]
|
|
173
|
+
|
|
174
|
+
bit_depth = data.get("bits_per_sample") or data.get("bits_per_raw_sample")
|
|
175
|
+
if bit_depth is not None:
|
|
176
|
+
bit_depth = int(bit_depth)
|
|
177
|
+
|
|
178
|
+
ai = AudioInfo(
|
|
179
|
+
sample_rate=int(data.get("sample_rate", 0)),
|
|
180
|
+
bit_depth=bit_depth,
|
|
181
|
+
channels=data.get("channels"),
|
|
182
|
+
codec=data.get("codec_name"),
|
|
183
|
+
duration_raw=float(data["duration"]) if "duration" in data else None,
|
|
184
|
+
bitrate=int(data["bit_rate"]) if "bit_rate" in data else None,
|
|
185
|
+
audio_path=audio_path,
|
|
186
|
+
)
|
|
187
|
+
return ai
|
|
188
|
+
|
|
189
|
+
def lossless_check_one(self, audio_path: Path) -> ResultStatus:
|
|
190
|
+
"""
|
|
191
|
+
Check if audio is lossless
|
|
192
|
+
|
|
193
|
+
Parameters
|
|
194
|
+
----------
|
|
195
|
+
audio_path : Path
|
|
196
|
+
Path to audio
|
|
197
|
+
|
|
198
|
+
Returns
|
|
199
|
+
-------
|
|
200
|
+
ResultStatus
|
|
201
|
+
If audio is lossless
|
|
202
|
+
"""
|
|
203
|
+
res = self.get_audio_info(audio_path)
|
|
204
|
+
if res.is_hi_res:
|
|
205
|
+
return ResultStatus(StatusCode.HIRES, audio_path)
|
|
206
|
+
elif res.is_lossless:
|
|
207
|
+
return ResultStatus(StatusCode.LOSSLESS, audio_path)
|
|
208
|
+
return ResultStatus(StatusCode.NOT_LOSSLESS, audio_path)
|
|
209
|
+
|
|
210
|
+
def lossless_check_single_thread(self, from_format: str = ".flac", recursive: bool = True) -> None:
|
|
211
|
+
"""
|
|
212
|
+
Check if audios in directory are lossless - single thread version
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
from_format : str, optional
|
|
217
|
+
Audio format, by default ".flac"
|
|
218
|
+
|
|
219
|
+
recursive : bool, optional
|
|
220
|
+
Include audio in child folder, by default True
|
|
221
|
+
"""
|
|
222
|
+
audios = list(self.source_path.rglob(f"{'**/*'if recursive else '*'}{from_format}"))
|
|
223
|
+
|
|
224
|
+
if not audios:
|
|
225
|
+
print(f"No {from_format.upper()} files found.")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
print(f"Found {len(audios)} {from_format.upper()} files.")
|
|
229
|
+
|
|
230
|
+
results: list[ResultStatus] = []
|
|
231
|
+
for x in tqdm(audios, desc="Checking", unit="file", unit_scale=True):
|
|
232
|
+
results.append(self.lossless_check_one(x))
|
|
233
|
+
|
|
234
|
+
print("\n--- Summary ---")
|
|
235
|
+
for line in results:
|
|
236
|
+
line.print()
|
|
237
|
+
|
|
238
|
+
def lossless_check(
|
|
239
|
+
self,
|
|
240
|
+
from_format: str = ".flac",
|
|
241
|
+
recursive: bool = True,
|
|
242
|
+
workers: int | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""
|
|
245
|
+
Check if audios in directory are lossless
|
|
246
|
+
|
|
247
|
+
Parameters
|
|
248
|
+
----------
|
|
249
|
+
from_format : str, optional
|
|
250
|
+
Audio format, by default ".flac"
|
|
251
|
+
|
|
252
|
+
recursive : bool, optional
|
|
253
|
+
Include audio in child folder, by default True
|
|
254
|
+
|
|
255
|
+
workers : int | None, optional
|
|
256
|
+
Number of parallel processing threads, by default None
|
|
257
|
+
"""
|
|
258
|
+
audios = list(self.source_path.rglob(f"{'**/*'if recursive else '*'}{from_format}"))
|
|
259
|
+
|
|
260
|
+
if not audios:
|
|
261
|
+
print(f"No {from_format.upper()} files found.")
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
print(f"Found {len(audios)} {from_format.upper()} files.")
|
|
265
|
+
|
|
266
|
+
results: list[ResultStatus] = []
|
|
267
|
+
with ProcessPoolExecutor(max_workers=workers) as executor:
|
|
268
|
+
futures = {executor.submit(self.lossless_check_one, x): x for x in audios}
|
|
269
|
+
|
|
270
|
+
for fut in tqdm(
|
|
271
|
+
as_completed(futures),
|
|
272
|
+
total=len(futures),
|
|
273
|
+
desc="Checking",
|
|
274
|
+
unit="file",
|
|
275
|
+
unit_scale=True,
|
|
276
|
+
):
|
|
277
|
+
results.append(fut.result())
|
|
278
|
+
|
|
279
|
+
print("\n--- Summary ---")
|
|
280
|
+
for line in results:
|
|
281
|
+
line.print()
|