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.

Files changed (103) hide show
  1. absfuyu/__init__.py +5 -3
  2. absfuyu/__main__.py +3 -3
  3. absfuyu/cli/__init__.py +13 -2
  4. absfuyu/cli/audio_group.py +98 -0
  5. absfuyu/cli/color.py +30 -14
  6. absfuyu/cli/config_group.py +9 -2
  7. absfuyu/cli/do_group.py +23 -6
  8. absfuyu/cli/game_group.py +27 -2
  9. absfuyu/cli/tool_group.py +81 -11
  10. absfuyu/config/__init__.py +3 -3
  11. absfuyu/core/__init__.py +12 -8
  12. absfuyu/core/baseclass.py +929 -96
  13. absfuyu/core/baseclass2.py +44 -3
  14. absfuyu/core/decorator.py +70 -4
  15. absfuyu/core/docstring.py +64 -41
  16. absfuyu/core/dummy_cli.py +3 -3
  17. absfuyu/core/dummy_func.py +19 -6
  18. absfuyu/dxt/__init__.py +2 -2
  19. absfuyu/dxt/base_type.py +93 -0
  20. absfuyu/dxt/dictext.py +204 -16
  21. absfuyu/dxt/dxt_support.py +2 -2
  22. absfuyu/dxt/intext.py +151 -34
  23. absfuyu/dxt/listext.py +969 -127
  24. absfuyu/dxt/strext.py +77 -17
  25. absfuyu/extra/__init__.py +2 -2
  26. absfuyu/extra/audio/__init__.py +8 -0
  27. absfuyu/extra/audio/_util.py +57 -0
  28. absfuyu/extra/audio/convert.py +192 -0
  29. absfuyu/extra/audio/lossless.py +281 -0
  30. absfuyu/extra/beautiful.py +3 -2
  31. absfuyu/extra/da/__init__.py +72 -0
  32. absfuyu/extra/da/dadf.py +1600 -0
  33. absfuyu/extra/da/dadf_base.py +186 -0
  34. absfuyu/extra/da/df_func.py +181 -0
  35. absfuyu/extra/da/mplt.py +219 -0
  36. absfuyu/extra/ggapi/__init__.py +8 -0
  37. absfuyu/extra/ggapi/gdrive.py +223 -0
  38. absfuyu/extra/ggapi/glicense.py +148 -0
  39. absfuyu/extra/ggapi/glicense_df.py +186 -0
  40. absfuyu/extra/ggapi/gsheet.py +88 -0
  41. absfuyu/extra/img/__init__.py +30 -0
  42. absfuyu/extra/img/converter.py +402 -0
  43. absfuyu/extra/img/dup_check.py +291 -0
  44. absfuyu/extra/pdf.py +87 -0
  45. absfuyu/extra/rclone.py +253 -0
  46. absfuyu/extra/xml.py +90 -0
  47. absfuyu/fun/__init__.py +7 -20
  48. absfuyu/fun/rubik.py +442 -0
  49. absfuyu/fun/tarot.py +2 -2
  50. absfuyu/game/__init__.py +2 -2
  51. absfuyu/game/game_stat.py +2 -2
  52. absfuyu/game/schulte.py +78 -0
  53. absfuyu/game/sudoku.py +2 -2
  54. absfuyu/game/tictactoe.py +2 -3
  55. absfuyu/game/wordle.py +6 -4
  56. absfuyu/general/__init__.py +4 -4
  57. absfuyu/general/content.py +4 -4
  58. absfuyu/general/human.py +2 -2
  59. absfuyu/general/resrel.py +213 -0
  60. absfuyu/general/shape.py +3 -8
  61. absfuyu/general/tax.py +344 -0
  62. absfuyu/logger.py +806 -59
  63. absfuyu/numbers/__init__.py +13 -0
  64. absfuyu/numbers/number_to_word.py +321 -0
  65. absfuyu/numbers/shorten_number.py +303 -0
  66. absfuyu/numbers/time_duration.py +217 -0
  67. absfuyu/pkg_data/__init__.py +2 -2
  68. absfuyu/pkg_data/deprecated.py +2 -2
  69. absfuyu/pkg_data/logo.py +1462 -0
  70. absfuyu/sort.py +4 -4
  71. absfuyu/tools/__init__.py +28 -2
  72. absfuyu/tools/checksum.py +144 -9
  73. absfuyu/tools/converter.py +120 -34
  74. absfuyu/tools/generator.py +461 -0
  75. absfuyu/tools/inspector.py +752 -0
  76. absfuyu/tools/keygen.py +2 -2
  77. absfuyu/tools/obfuscator.py +47 -9
  78. absfuyu/tools/passwordlib.py +89 -25
  79. absfuyu/tools/shutdownizer.py +3 -8
  80. absfuyu/tools/sw.py +718 -0
  81. absfuyu/tools/web.py +10 -13
  82. absfuyu/typings.py +138 -0
  83. absfuyu/util/__init__.py +114 -6
  84. absfuyu/util/api.py +41 -18
  85. absfuyu/util/cli.py +119 -0
  86. absfuyu/util/gui.py +91 -0
  87. absfuyu/util/json_method.py +43 -14
  88. absfuyu/util/lunar.py +2 -2
  89. absfuyu/util/package.py +124 -0
  90. absfuyu/util/path.py +702 -82
  91. absfuyu/util/performance.py +122 -7
  92. absfuyu/util/shorten_number.py +244 -21
  93. absfuyu/util/text_table.py +481 -0
  94. absfuyu/util/zipped.py +8 -7
  95. absfuyu/version.py +79 -59
  96. {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/METADATA +52 -11
  97. absfuyu-6.1.2.dist-info/RECORD +105 -0
  98. {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/WHEEL +1 -1
  99. absfuyu/extra/data_analysis.py +0 -1078
  100. absfuyu/general/generator.py +0 -303
  101. absfuyu-5.0.0.dist-info/RECORD +0 -68
  102. {absfuyu-5.0.0.dist-info → absfuyu-6.1.2.dist-info}/entry_points.txt +0 -0
  103. {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: 5.0.0
7
- Date updated: 11/02/2025 (dd/mm/yyyy)
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 ShowAllMethodsMixin, deprecated, versionadded, versionchanged
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(ShowAllMethodsMixin, str):
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 = Generator.generate_string(
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
- temp = []
365
- for x in text:
366
- if random.randint(1, 100) <= probability:
367
- x = x.upper()
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
@@ -3,8 +3,8 @@ Absfuyu: Extra
3
3
  --------------
4
4
  Features that require additional libraries
5
5
 
6
- Version: 1.0.1
7
- Date updated: 24/11/2023 (dd/mm/yyyy)
6
+ Version: 6.1.1
7
+ Date updated: 30/12/2025 (dd/mm/yyyy)
8
8
  """
9
9
 
10
10
 
@@ -0,0 +1,8 @@
1
+ """
2
+ Absfuyu: Audio
3
+ --------------
4
+ Audio related
5
+
6
+ Version: 6.1.1
7
+ Date updated: 30/12/2025 (dd/mm/yyyy)
8
+ """
@@ -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()