openfaba 0.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.
openfaba/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ import logging
2
+
3
+ __version__ = "0.1.2"
4
+
5
+ logging.basicConfig(level=logging.INFO)
6
+ logger = logging.getLogger(__name__)
openfaba/cli.py ADDED
@@ -0,0 +1,169 @@
1
+ import logging
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from typer import Typer
7
+
8
+ from openfaba.io import collect_all_mp3_files_in_folder
9
+ from openfaba.media import (
10
+ deobfuscate_figure_mki_files,
11
+ deobfuscate_mki_library,
12
+ obfuscate_figure_mp3_files,
13
+ obfuscate_mp3_library,
14
+ )
15
+
16
+ logger = logging.getLogger(__name__)
17
+ app = Typer(help="Create/Manage FABA figures and libraries")
18
+
19
+
20
+ def normalize_figure_id(figure_id: str) -> str | None:
21
+ return f"{int(figure_id):04d}" if (figure_id.isdigit() and len(figure_id) <= 4) else None
22
+
23
+
24
+ @app.command()
25
+ def insert(
26
+ figure_id: str = typer.Option(..., "--figure-id", "-f", help="Figure ID (4 digits)"),
27
+ source: Path = typer.Option(..., "--source", "-s", exists=True, file_okay=False, dir_okay=True),
28
+ faba_library: Path = typer.Option(
29
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
30
+ ),
31
+ ) -> None:
32
+ """Create a new FABA figure from a folder of MP3 files. Fails if figure exists."""
33
+
34
+ if not (fid := normalize_figure_id(figure_id)):
35
+ typer.echo("figure-id must be a 4-digit number")
36
+ raise typer.Exit(code=1)
37
+
38
+ figure_path = faba_library / f"K{fid}"
39
+ if figure_path.exists():
40
+ if not figure_path.is_dir():
41
+ typer.echo(f"Error: Path for Figure K{fid} already exists as a file.")
42
+ raise typer.Exit(code=1)
43
+ elif len(list(figure_path.iterdir())) != 0:
44
+ typer.echo(f"Error: Figure K{fid} already exists as a not empty folder.")
45
+ raise typer.Exit(code=1)
46
+
47
+ if not (mp3_files := collect_all_mp3_files_in_folder(source)):
48
+ typer.echo("No MP3 files found in the source folder.")
49
+ raise typer.Exit(code=1)
50
+
51
+ obfuscate_figure_mp3_files(fid, mp3_files, faba_library)
52
+ typer.echo(f"Inserted figure K{fid}. Added {len(mp3_files)} tracks.")
53
+
54
+
55
+ @app.command()
56
+ def extend(
57
+ figure_id: str = typer.Option(..., "--figure-id", "-f", help="Figure ID (4 digits)"),
58
+ source: Path = typer.Option(..., "--source", "-s", exists=True, file_okay=False, dir_okay=True),
59
+ faba_library: Path = typer.Option(
60
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
61
+ ),
62
+ ) -> None:
63
+ """Append MP3 files to an existing figure. Does not overwrite existing tracks."""
64
+
65
+ if not (fid := normalize_figure_id(figure_id)):
66
+ typer.echo("figure-id must be a 4-digit number")
67
+ raise typer.Exit(code=1)
68
+
69
+ figure_path = faba_library / f"K{fid}"
70
+ if not figure_path.exists():
71
+ typer.echo(f"Error: Figure K{fid} does not exist in the library.")
72
+ raise typer.Exit(code=1)
73
+
74
+ if not (mp3_files := collect_all_mp3_files_in_folder(source)):
75
+ typer.echo("No MP3 files found in the source folder.")
76
+ raise typer.Exit(code=1)
77
+
78
+ obfuscate_figure_mp3_files(fid, mp3_files, faba_library, append=True)
79
+
80
+ typer.echo(f"Extended figure K{fid}. Appended {len(mp3_files)} tracks.")
81
+
82
+
83
+ @app.command()
84
+ def replace(
85
+ figure_id: str = typer.Option(..., "--figure-id", "-f", help="Figure ID (4 digits)"),
86
+ source: Path = typer.Option(..., "--source", "-s", exists=True, file_okay=False, dir_okay=True),
87
+ faba_library: Path = typer.Option(
88
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
89
+ ),
90
+ ) -> None:
91
+ """Delete an existing figure and recreate it from MP3 files."""
92
+
93
+ if not (fid := normalize_figure_id(figure_id)):
94
+ typer.echo("figure-id must be a 4-digit number")
95
+ raise typer.Exit(code=1)
96
+
97
+ figure_path = faba_library / f"K{fid}"
98
+ if figure_path.exists():
99
+ try:
100
+ shutil.rmtree(figure_path)
101
+ except Exception as exc:
102
+ typer.echo(f"Error removing existing figure: {exc}", err=True)
103
+ raise typer.Exit(code=1) from exc
104
+
105
+ if not (mp3_files := collect_all_mp3_files_in_folder(source)):
106
+ typer.echo("No MP3 files found in the source folder.")
107
+ raise typer.Exit(code=1)
108
+
109
+ obfuscate_figure_mp3_files(fid, mp3_files, faba_library)
110
+ typer.echo(f"Replaced figure K{fid}. Created with {len(mp3_files)} tracks.")
111
+
112
+
113
+ @app.command()
114
+ def extract(
115
+ figure_id: str = typer.Option(..., "--figure-id", "-f", help="Figure ID (4 digits)"),
116
+ faba_library: Path = typer.Option(
117
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
118
+ ),
119
+ output: Path = typer.Option(..., "--output", "-o", file_okay=False, dir_okay=True),
120
+ ) -> None:
121
+ """Extract a single figure from a FABA library into MP3 files."""
122
+
123
+ if not (fid := normalize_figure_id(figure_id)):
124
+ typer.echo("figure-id must be a 4-digit number")
125
+ raise typer.Exit(code=1)
126
+ output.mkdir(parents=True, exist_ok=True)
127
+
128
+ converted = deobfuscate_figure_mki_files(fid, faba_library, output)
129
+ if converted == 0:
130
+ typer.echo(f"No MKI files found for figure K{fid}.")
131
+ raise typer.Exit(code=1)
132
+
133
+ typer.echo(f"Extracted figure K{fid}. Converted {converted} files.")
134
+
135
+
136
+ @app.command()
137
+ def obfuscate(
138
+ mp3_library: Path = typer.Option(
139
+ ..., "--mp3-library", "-m", exists=True, file_okay=False, dir_okay=True
140
+ ),
141
+ faba_library: Path = typer.Option(
142
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
143
+ ),
144
+ ) -> None:
145
+ """Obfuscate an entire MP3 library into a FABA MKI library."""
146
+ converted = obfuscate_mp3_library(mp3_library, faba_library)
147
+ if converted == 0:
148
+ typer.echo("No MP3 files found in the source library.")
149
+ raise typer.Exit(code=1)
150
+
151
+ typer.echo(f"Obfuscated library. Converted {converted} files.")
152
+
153
+
154
+ @app.command()
155
+ def deobfuscate(
156
+ faba_library: Path = typer.Option(
157
+ ..., "--faba-library", "-b", exists=True, file_okay=False, dir_okay=True
158
+ ),
159
+ mp3_library: Path = typer.Option(..., "--mp3-library", "-m", file_okay=False, dir_okay=True),
160
+ ) -> None:
161
+ """Deobfuscate an entire FABA MKI library back into MP3 files."""
162
+ mp3_library.mkdir(parents=True, exist_ok=True)
163
+
164
+ converted = deobfuscate_mki_library(faba_library, mp3_library)
165
+ if converted == 0:
166
+ typer.echo("No MKI files found in the FABA library.")
167
+ raise typer.Exit(code=1)
168
+
169
+ typer.echo(f"Deobfuscated library. Converted {converted} files.")
openfaba/consts.py ADDED
@@ -0,0 +1,152 @@
1
+ BYTE_HIGH_NIBBLE = [
2
+ [
3
+ 0x30,
4
+ 0x30,
5
+ 0x20,
6
+ 0x20,
7
+ 0x10,
8
+ 0x10,
9
+ 0x00,
10
+ 0x00,
11
+ 0x70,
12
+ 0x70,
13
+ 0x60,
14
+ 0x60,
15
+ 0x50,
16
+ 0x50,
17
+ 0x40,
18
+ 0x40,
19
+ 0xB0,
20
+ 0xB0,
21
+ 0xA0,
22
+ 0xA0,
23
+ 0x90,
24
+ 0x90,
25
+ 0x80,
26
+ 0x80,
27
+ 0xF0,
28
+ 0xF0,
29
+ 0xE0,
30
+ 0xE0,
31
+ 0xD0,
32
+ 0xD0,
33
+ 0xC0,
34
+ 0xC0,
35
+ ],
36
+ [
37
+ 0x00,
38
+ 0x00,
39
+ 0x10,
40
+ 0x10,
41
+ 0x20,
42
+ 0x20,
43
+ 0x30,
44
+ 0x30,
45
+ 0x40,
46
+ 0x40,
47
+ 0x50,
48
+ 0x50,
49
+ 0x60,
50
+ 0x60,
51
+ 0x70,
52
+ 0x70,
53
+ 0x80,
54
+ 0x80,
55
+ 0x90,
56
+ 0x90,
57
+ 0xA0,
58
+ 0xA0,
59
+ 0xB0,
60
+ 0xB0,
61
+ 0xC0,
62
+ 0xC0,
63
+ 0xD0,
64
+ 0xD0,
65
+ 0xE0,
66
+ 0xE0,
67
+ 0xF0,
68
+ 0xF0,
69
+ ],
70
+ [
71
+ 0x10,
72
+ 0x10,
73
+ 0x00,
74
+ 0x00,
75
+ 0x30,
76
+ 0x30,
77
+ 0x20,
78
+ 0x20,
79
+ 0x50,
80
+ 0x50,
81
+ 0x40,
82
+ 0x40,
83
+ 0x70,
84
+ 0x70,
85
+ 0x60,
86
+ 0x60,
87
+ 0x90,
88
+ 0x90,
89
+ 0x80,
90
+ 0x80,
91
+ 0xB0,
92
+ 0xB0,
93
+ 0xA0,
94
+ 0xA0,
95
+ 0xD0,
96
+ 0xD0,
97
+ 0xC0,
98
+ 0xC0,
99
+ 0xF0,
100
+ 0xF0,
101
+ 0xE0,
102
+ 0xE0,
103
+ ],
104
+ [
105
+ 0x20,
106
+ 0x20,
107
+ 0x30,
108
+ 0x30,
109
+ 0x00,
110
+ 0x00,
111
+ 0x10,
112
+ 0x10,
113
+ 0x60,
114
+ 0x60,
115
+ 0x70,
116
+ 0x70,
117
+ 0x40,
118
+ 0x40,
119
+ 0x50,
120
+ 0x50,
121
+ 0xA0,
122
+ 0xA0,
123
+ 0xB0,
124
+ 0xB0,
125
+ 0x80,
126
+ 0x80,
127
+ 0x90,
128
+ 0x90,
129
+ 0xE0,
130
+ 0xE0,
131
+ 0xF0,
132
+ 0xF0,
133
+ 0xC0,
134
+ 0xC0,
135
+ 0xD0,
136
+ 0xD0,
137
+ ],
138
+ ]
139
+
140
+ BYTE_LOW_NIBBLE_EVEN = [
141
+ [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7],
142
+ [0x5, 0x4, 0x7, 0x6, 0x1, 0x0, 0x3, 0x2],
143
+ [0x9, 0x8, 0xB, 0xA, 0xD, 0xC, 0xF, 0xE],
144
+ [0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7],
145
+ ]
146
+
147
+ BYTE_LOW_NIBBLE_ODD = [
148
+ [0x8, 0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF],
149
+ [0xD, 0xC, 0xF, 0xE, 0x9, 0x8, 0xB, 0xA],
150
+ [0x1, 0x0, 0x3, 0x2, 0x5, 0x4, 0x7, 0x6],
151
+ [0x8, 0x9, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF],
152
+ ]
openfaba/io.py ADDED
@@ -0,0 +1,94 @@
1
+ import logging
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from mutagen.id3 import ID3, TIT2 # type:ignore [attr-defined]
6
+ from mutagen.mp3 import MP3
7
+
8
+ from openfaba.consts import BYTE_HIGH_NIBBLE, BYTE_LOW_NIBBLE_EVEN, BYTE_LOW_NIBBLE_ODD
9
+ from openfaba.utils import allow_invalid_synchsafe_in_mutagen
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def clear_tags_and_set_title(mp3_file: Path, new_title: str) -> None:
15
+ """Remove all MP3 tags and set a single title tag"""
16
+ try:
17
+ _clear_tags_and_set_title(mp3_file, new_title)
18
+ logger.info(f"Title updated for {mp3_file}")
19
+ except Exception as e:
20
+ logger.error(f"Error processing {mp3_file}: {e}")
21
+ sys.exit(1)
22
+
23
+
24
+ def convert_mp3_to_mki(mp3_file: Path, mki_file: Path) -> None:
25
+ """Apply custom byte transformation to an mp3 file"""
26
+ try:
27
+ _convert_mp3_to_mki(mp3_file, mki_file)
28
+ logger.info(f"Conversion complete. Output file: {mki_file}")
29
+ except IOError as e:
30
+ logger.error(f"Error processing {mp3_file}: {e}")
31
+ sys.exit(1)
32
+
33
+
34
+ def convert_mki_to_mp3(mki_file: Path, mp3_file: Path) -> None:
35
+ """Reverse the custom byte transformation to restore the original mp3 file"""
36
+ try:
37
+ _convert_mki_to_mp3(mki_file, mp3_file)
38
+ logger.info(f"Conversion complete. Output file: {mp3_file}")
39
+ except IOError as e:
40
+ logger.error(f"Error processing {mki_file}: {e}")
41
+ sys.exit(1)
42
+
43
+
44
+ def collect_all_mp3_files_in_folder(source: Path) -> list[Path]:
45
+ return sorted(p for p in source.rglob("*.mp3") if p.is_file())
46
+
47
+
48
+ def _clear_tags_and_set_title(mp3_file: Path, new_title: str) -> None:
49
+ with allow_invalid_synchsafe_in_mutagen():
50
+ tags = MP3(mp3_file, ID3=ID3)
51
+ # If title already matches, skip to preserve exact original bytes
52
+ if "TIT2" in tags and len(tags) == 1 and str(tags["TIT2"].text[0]) == new_title:
53
+ return
54
+
55
+ # Ensure we write the title as UTF-16 (Encoding 1) and as a list of text
56
+ tags.delete()
57
+ tags["TIT2"] = TIT2(encoding=1, text=[new_title])
58
+ tags.save(v2_version=3, padding=lambda x: 0)
59
+
60
+
61
+ def _convert_mp3_to_mki(mp3_file: Path, mki_file: Path) -> None:
62
+ with mp3_file.open("rb") as infile, mki_file.open("wb") as outfile:
63
+ for pos, byte in enumerate(infile.read()):
64
+ byte_pos = pos % 4
65
+
66
+ high_index = byte % 32
67
+ low_index = byte // 32
68
+
69
+ modified_byte = BYTE_HIGH_NIBBLE[byte_pos][high_index]
70
+
71
+ if byte % 2 == 0:
72
+ modified_byte += BYTE_LOW_NIBBLE_EVEN[byte_pos][low_index]
73
+ else:
74
+ modified_byte += BYTE_LOW_NIBBLE_ODD[byte_pos][low_index]
75
+
76
+ outfile.write(bytes((modified_byte,)))
77
+
78
+
79
+ def _convert_mki_to_mp3(mki_file: Path, mp3_file: Path) -> None:
80
+ with mki_file.open("rb") as infile, mp3_file.open("wb") as outfile:
81
+ for pos, byte in enumerate(infile.read()):
82
+ byte_pos = pos % 4
83
+
84
+ high_byte = byte & 0xF0
85
+ low_byte = byte & 0x0F
86
+
87
+ index_high = BYTE_HIGH_NIBBLE[byte_pos].index(high_byte)
88
+ if low_byte in BYTE_LOW_NIBBLE_EVEN[byte_pos]:
89
+ index_low = BYTE_LOW_NIBBLE_EVEN[byte_pos].index(low_byte)
90
+ else:
91
+ index_low = BYTE_LOW_NIBBLE_ODD[byte_pos].index(low_byte)
92
+ index_high += 1
93
+
94
+ outfile.write(bytes((index_low * 32 + index_high,)))
openfaba/main.py ADDED
@@ -0,0 +1,9 @@
1
+ from openfaba.cli import app
2
+
3
+
4
+ def main() -> None:
5
+ app()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
openfaba/media.py ADDED
@@ -0,0 +1,196 @@
1
+ import logging
2
+ import re
3
+ import shutil
4
+ import tempfile
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ from openfaba.io import clear_tags_and_set_title, convert_mki_to_mp3, convert_mp3_to_mki
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def obfuscate_figure_mp3_files(
14
+ figure_id: str, source_mp3_files: list[Path], faba_library: Path, append: bool = False
15
+ ) -> None:
16
+ """
17
+ Obfuscate a sequence of MP3 files for a single Faba figure.
18
+
19
+ Each MP3 file is copied to a temporary location, stripped of metadata,
20
+ assigned a deterministic title, and converted to the proprietary MKI format.
21
+ Resulting files are written into the corresponding figure folder inside
22
+ the Faba library (e.g. ``K0104``).
23
+
24
+ Parameters
25
+ ----------
26
+ figure_id:
27
+ Four-digit Faba figure identifier (e.g. ``"0104"``).
28
+ source_mp3_files:
29
+ Ordered collection of source MP3 files to obfuscate for this figure.
30
+ faba_library:
31
+ Root path of the target Faba library (typically an ``MKI01`` folder).
32
+ append:
33
+ When True, MP3 files will be appended to an existing figure. New
34
+ tracks are numbered after the highest existing ``.MKI`` file. When
35
+ False (default) the figure will be created/overwritten starting at
36
+ track 1.
37
+ """
38
+ if not source_mp3_files:
39
+ logger.warning("No MP3 files provided for figure `%s`", figure_id)
40
+ return
41
+
42
+ logger.info("Converting files for figure: `%s`", figure_id)
43
+
44
+ figure_path = faba_library / f"K{figure_id}"
45
+ if append and not figure_path.exists():
46
+ raise ValueError("You cannot append tracks to an unexisting figure")
47
+ figure_path.mkdir(parents=True, exist_ok=True)
48
+
49
+ existing_mki = sorted(p for p in figure_path.iterdir() if p.suffix.lower() == ".mki")
50
+ start_index = (len(existing_mki) + 1) if append else 1
51
+
52
+ for index, mp3_file in enumerate(sorted(source_mp3_files), start=start_index):
53
+ logger.info(
54
+ "Converting file %s [%d/%d]",
55
+ mp3_file.name,
56
+ index - start_index + 1,
57
+ len(source_mp3_files),
58
+ )
59
+
60
+ file_number = f"{index:02d}"
61
+
62
+ temp_handle = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False)
63
+ temp_mp3 = Path(temp_handle.name)
64
+ temp_handle.close() # Required for Windows compatibility
65
+
66
+ try:
67
+ shutil.copy(mp3_file, temp_mp3)
68
+
69
+ new_title = f"K{figure_id}CP{file_number}"
70
+ clear_tags_and_set_title(temp_mp3, new_title)
71
+
72
+ obfuscated_file = figure_path / f"CP{file_number}.MKI"
73
+ convert_mp3_to_mki(temp_mp3, obfuscated_file)
74
+ finally:
75
+ temp_mp3.unlink(missing_ok=True)
76
+
77
+
78
+ def obfuscate_mp3_library(
79
+ faba_library_mp3: Path, faba_library: Path, default_figure_id: str = "0000"
80
+ ) -> int:
81
+ """
82
+ Obfuscate a directory tree of MP3 files into a Faba-compatible MKI library.
83
+
84
+ MP3 files are discovered recursively. If a file resides inside a directory
85
+ named ``K####``, the digits are interpreted as the figure identifier.
86
+ Otherwise, ``default_figure_id`` is used.
87
+
88
+ Parameters
89
+ ----------
90
+ faba_library_mp3:
91
+ Path containing source MP3 files. Subfolders named ``K####`` will be
92
+ interpreted as per-figure groupings.
93
+ faba_library:
94
+ Destination Faba library root directory (typically an ``MKI01`` folder).
95
+ Per-figure subfolders (e.g. ``K0104``) will be created as needed.
96
+ default_figure_id:
97
+ Figure identifier to use when no ``K####`` directory can be inferred.
98
+
99
+ Returns
100
+ -------
101
+ int
102
+ Total number of MP3 files processed.
103
+ """
104
+ # Monkeypatch to tolerate malformed ID3 headers
105
+
106
+ all_mp3_files = sorted(p for p in faba_library_mp3.rglob("*") if p.suffix.lower() == ".mp3")
107
+ files_by_figure: defaultdict[str, list[Path]] = defaultdict(list)
108
+
109
+ for mp3_file in all_mp3_files:
110
+ match = re.search(r"K(\d{4})$", mp3_file.parent.name)
111
+ figure_id = match.group(1) if match else default_figure_id
112
+ files_by_figure[figure_id].append(mp3_file)
113
+
114
+ for figure_id, figure_mp3_files in files_by_figure.items():
115
+ obfuscate_figure_mp3_files(figure_id, figure_mp3_files, faba_library)
116
+
117
+ return len(all_mp3_files)
118
+
119
+
120
+ def deobfuscate_figure_mki_files(figure_id: str, faba_library: Path, output_folder: Path) -> int:
121
+ """
122
+ Deobfuscate all MKI files for a single Faba figure into MP3 files.
123
+
124
+ MKI files are read from the corresponding figure directory inside the
125
+ Faba library (e.g. ``K0104``) and converted back to standard MP3 files.
126
+ The resulting MP3 files are written into a mirrored figure directory
127
+ under the ``output_folder`` path.
128
+
129
+ Parameters
130
+ ----------
131
+ figure_id:
132
+ Four-digit Faba figure identifier (e.g. ``"0104"``).
133
+ faba_library:
134
+ Root path of the source Faba library (typically an ``MKI01`` folder).
135
+ output_folder:
136
+ Root path where deobfuscated MP3 files will be written.
137
+
138
+ Returns
139
+ -------
140
+ int
141
+ Number of MKI files converted for this figure.
142
+ """
143
+ figure_path = faba_library / f"K{figure_id}"
144
+ if not figure_path.exists():
145
+ logger.warning("Figure directory not found for figure `%s`", figure_id)
146
+ return 0
147
+
148
+ mki_files = sorted(p for p in figure_path.iterdir() if p.suffix.lower() == ".mki")
149
+
150
+ if not mki_files:
151
+ logger.warning("No MKI files found for figure `%s`", figure_id)
152
+ return 0
153
+
154
+ target_figure_path = output_folder / f"K{figure_id}"
155
+ target_figure_path.mkdir(parents=True, exist_ok=True)
156
+
157
+ for index, mki_file in enumerate(mki_files, start=1):
158
+ target_file = (target_figure_path / mki_file.name).with_suffix(".mp3")
159
+ logger.info("Converting file %s [%d/%d]", mki_file.name, index, len(mki_files))
160
+ convert_mki_to_mp3(mki_file, target_file)
161
+
162
+ return len(mki_files)
163
+
164
+
165
+ def deobfuscate_mki_library(faba_library: Path, faba_library_mp3: Path) -> int:
166
+ """
167
+ Deobfuscate a Faba MKI library back into standard MP3 files.
168
+
169
+ MKI files are discovered recursively inside the Faba library. The original
170
+ folder structure is preserved in the output directory, with file extensions
171
+ converted to ``.mp3``.
172
+
173
+ Parameters
174
+ ----------
175
+ faba_library:
176
+ Source Faba library root directory (typically an ``MKI01`` folder)
177
+ containing per-figure subdirectories with ``.MKI`` files.
178
+ faba_library_mp3:
179
+ Destination directory where deobfuscated MP3 files will be written.
180
+
181
+ Returns
182
+ -------
183
+ int
184
+ Total number of MKI files converted.
185
+ """
186
+ mki_files = sorted(p for p in faba_library.rglob("*") if p.suffix.lower() == ".mki")
187
+
188
+ for index, mki_file in enumerate(mki_files, start=1):
189
+ relative_path = mki_file.relative_to(faba_library)
190
+ target_file = (faba_library_mp3 / relative_path).with_suffix(".mp3")
191
+ target_file.parent.mkdir(parents=True, exist_ok=True)
192
+
193
+ logger.info("Converting file %s [%d/%d]", mki_file.name, index, len(mki_files))
194
+ convert_mki_to_mp3(mki_file, target_file)
195
+
196
+ return len(mki_files)
openfaba/py.typed ADDED
File without changes
openfaba/utils.py ADDED
@@ -0,0 +1,18 @@
1
+ from contextlib import contextmanager
2
+ from typing import Iterator
3
+
4
+ from mutagen.id3._util import BitPaddedInt
5
+
6
+
7
+ @contextmanager
8
+ def allow_invalid_synchsafe_in_mutagen() -> Iterator[None]:
9
+ """
10
+ Temporarily disable synchsafe integer validation.
11
+ Intended only for ID3 deletion paths.
12
+ """
13
+ orig = BitPaddedInt.has_valid_padding
14
+ BitPaddedInt.has_valid_padding = lambda *_: True # type: ignore[method-assign]
15
+ try:
16
+ yield
17
+ finally:
18
+ BitPaddedInt.has_valid_padding = orig # type: ignore[method-assign]
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: openfaba
3
+ Version: 0.1.2
4
+ Summary: A modern reimplementation of redele.
5
+ Author-email: Gustavo Viera López <gvieralopez@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: typer>=0.9.0
11
+ Requires-Dist: mutagen>=1.47.0
12
+ Dynamic: license-file
13
+
14
+ # openFABA
15
+
16
+ **openFABA** is a command-line tool for inspecting, extracting, and exporting audio
17
+ files for the **FABA** storytelling box. It allows you to convert standard MP3 files
18
+ into FABA-compatible files for specific figures.
19
+
20
+ This project builds upon the reverse-engineering work by
21
+ [wansors](https://github.com/wansors/myfaba-hacks/blob/main/python/redele.py).
22
+ At the moment, openFABA supports **only the original FABA device**.
23
+
24
+ ### Disclaimer
25
+
26
+ openFABA is an independent project and is **not affiliated with or endorsed by FABA**.
27
+
28
+ This software is provided *as is*, without warranties of any kind. You are responsible
29
+ for how you use it and for any consequences that may arise, including device malfunction
30
+ or account-related actions. Some features may conflict with FABA’s terms of service or
31
+ usage policies.
32
+
33
+ The goal of openFABA is to:
34
+
35
+ * simplify the creation of custom, FABA-compatible figures
36
+ * enable personalization of figures you already own
37
+
38
+ We do **not** endorse music piracy, copyright infringement, or any misuse that could
39
+ negatively impact FABA or its ecosystem. FABA is a great company, and we encourage
40
+ supporting them by purchasing their products.
41
+
42
+ Use this tool responsibly and at your own risk.
43
+
44
+
45
+ ## 🚀 Prerequisites
46
+
47
+ Before working with this project, make sure you have [uv](https://docs.astral.sh/uv/) installed. 👉 Install guide: [uv docs](https://docs.astral.sh/uv/getting-started/installation/)
48
+
49
+ If you are a developer and want to contribute to this project, you may want to have
50
+ [make](https://www.gnu.org/software/make/) installed as well.
51
+
52
+
53
+ ## ⚡ Installation
54
+
55
+ Install dependencies into a local virtual environment:
56
+
57
+ ```bash
58
+ uv sync --all-groups
59
+ ```
60
+
61
+ Then, to activate the virtual environment:
62
+
63
+ On Linux / macOS:
64
+
65
+ ```bash
66
+ source .venv/bin/activate
67
+ ```
68
+
69
+ On Windows (PowerShell):
70
+
71
+ ```powershell
72
+ .venv\Scripts\Activate.ps1
73
+ # If your PowerShell execution policy prevents running the script, run:
74
+ powershell -ExecutionPolicy Bypass -File .venv\Scripts\Activate.ps1
75
+ ```
76
+
77
+ On Windows (CMD):
78
+
79
+ ```cmd
80
+ .venv\Scripts\activate.bat
81
+ ```
82
+
83
+ ## 🛠️ Usage
84
+
85
+ ### Create your own figure
86
+
87
+ Create a new FABA figure from a folder containing MP3 files. The target figure must
88
+ not already exist in the FABA library, otherwise the command will fail to avoid
89
+ accidental overwrites.
90
+
91
+ For example, to create a new FABA figure with ID `4742` using the songs in `/home/user/songs`
92
+ and copy it to the FABA box mounted at `/mnt/faba/MKI01`, run:
93
+
94
+ ```bash
95
+ openfaba insert \
96
+ --figure-id 4742 \
97
+ --source /home/user/songs \
98
+ --faba-library /mnt/faba/MKI01
99
+ ```
100
+
101
+ Write an NFC TAG with the figure ID and enjoy it! If you need help on how todo it,
102
+ [these FAQs](https://github.com/wansors/myfaba-hacks/blob/main/FAQ.md) from the original
103
+ wansors' project have a lot of useful information.
104
+
105
+ ### Add songs to an existing figure
106
+
107
+ Append additional MP3 files to an already existing figure. New tracks are added after the
108
+ existing ones and never overwrite previously stored audio.
109
+
110
+ ```bash
111
+ openfaba extend \
112
+ --figure-id 4742 \
113
+ --source /home/user/new_songs \
114
+ --faba-library /mnt/faba/MKI01
115
+ ```
116
+
117
+
118
+ ### Replace a figure
119
+
120
+ Delete an existing figure and recreate it from scratch using a new set of MP3 files. This
121
+ operation removes the previous contents of the figure.
122
+
123
+ ```bash
124
+ openfaba replace \
125
+ --figure-id 4742 \
126
+ --source /home/user/songs \
127
+ --faba-library /mnt/faba/MKI01
128
+ ```
129
+
130
+
131
+ ### Listen to some FABA songs on your computer
132
+
133
+ Extract (deobfuscate) a single figure from a FABA box and convert it back to standard MP3
134
+ files so they can be played on a computer or mobile device.
135
+
136
+ For example, to extract the Italian red elephant **Ele** (figure ID `0010`):
137
+
138
+ ```bash
139
+ openfaba extract \
140
+ --figure-id 0010 \
141
+ --faba-library /mnt/faba/MKI01 \
142
+ --output /home/user/elephant_ele
143
+ ```
144
+
145
+
146
+ ### Obfuscate an entire MP3 library
147
+
148
+ Convert a directory tree of MP3 files into a FABA-compatible MKI library. Subfolders named
149
+ `K####` are interpreted as figure IDs; otherwise a default figure ID is used.
150
+
151
+ ```bash
152
+ openfaba obfuscate \
153
+ --mp3-library /home/user/mp3_library \
154
+ --faba-library /mnt/faba/MKI01
155
+ ```
156
+
157
+
158
+ ### Deobfuscate an entire FABA library
159
+
160
+ Convert all figures from a FABA MKI library back into MP3 files, preserving the original
161
+ folder structure.
162
+
163
+ ```bash
164
+ openfaba deobfuscate \
165
+ --faba-library /mnt/faba/MKI01 \
166
+ --mp3-library /home/user/output_mp3
167
+ ```
168
+
169
+ ## 🪙 Resources used to create this project
170
+
171
+ - [myfaba-hacks GitHub repository](https://github.com/wansors/myfaba-hacks)
172
+ - [Hacking MyFaba Medium Article](https://medium.com/@wansors/hacking-myfaba-an-educational-journey-into-storytelling-box-customization-cc6fc5db719d)
173
+ - [Myfaba Store](https://www.myfaba.it/prodotti-sonori/acquista-per-tipologia/personaggi-sonori/)
174
+
175
+
176
+ ## 📦 Tools for Developers
177
+
178
+ Common development tasks are wrapped in the `Makefile` for convenience.
179
+
180
+ ### Linting, Formatting, and Type Checking
181
+
182
+ ```bash
183
+ make qa
184
+ ```
185
+
186
+ Runs **Ruff** for linting and formatting, and **Mypy** for type checking.
187
+
188
+ ### Running Unit Tests
189
+
190
+ Before running tests, override any required environment variables in the `.env.test` file.
191
+
192
+ ```bash
193
+ make test
194
+ ```
195
+
196
+ Executes the test suite using **Pytest**.
197
+
198
+ ### Building the Project
199
+
200
+ ```bash
201
+ make build
202
+ ```
203
+
204
+ Generates a distribution package inside the `dist/` directory.
205
+
206
+ ### Cleaning Up
207
+
208
+ ```bash
209
+ make clean
210
+ ```
211
+
212
+ Removes build artifacts, caches, and temporary files to keep your project directory clean.
213
+
214
+ ### Updating project version
215
+
216
+ ```bash
217
+ make version
218
+ ```
219
+
220
+ Interactively prompts you to select the type of version update to apply (major, minor, patch, tag)
221
+ and automatically updates the project version accordingly.
222
+
223
+
224
+ ## 🤝 Contributing
225
+
226
+ Contributions are welcome!
227
+ Please ensure all QA checks and tests pass before opening a pull request.
228
+
229
+ ---
230
+
231
+ <sub>🚀 Project starter provided by [Cookie Pyrate](https://github.com/gvieralopez/cookie-pyrate)</sub>
@@ -0,0 +1,14 @@
1
+ openfaba/__init__.py,sha256=WTYoljztaWP44HsvOx0CXwehHk1h-iGXA--jZ-edxjo,116
2
+ openfaba/cli.py,sha256=C92hVIFHyFWb0kiUmhg5SbZdXehUMYNkiGRolKmFErc,6223
3
+ openfaba/consts.py,sha256=0VlaEaMw_BSH9Y80kbxxntRXLV5a66zzdossceB8ImM,2290
4
+ openfaba/io.py,sha256=kIipJDVhDbA6ZXxpn7oLT6KxMmERJVnbwi1mWzs2oK0,3400
5
+ openfaba/main.py,sha256=4945JWeFwoSJtjZ76fBxKw7PhssKhwzDKgqEQUc2wos,101
6
+ openfaba/media.py,sha256=fJj2mLN0deAqyWroP70NT1Rtrp-KZtrPjchU1XonYEw,7086
7
+ openfaba/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ openfaba/utils.py,sha256=FMYjsqnjBmy3HcNmr4hfFX7PLDO1iniDR4u84JTOiRw,539
9
+ openfaba-0.1.2.dist-info/licenses/LICENSE,sha256=zTmm46Tbk_vPK5A1X0ZwgBuXysU5fk6Sn_BRYc9nd74,1078
10
+ openfaba-0.1.2.dist-info/METADATA,sha256=vO8DujCZGAbmS-_C5oB3m12qH_6pMfPa4wqdeOQA5f4,6217
11
+ openfaba-0.1.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ openfaba-0.1.2.dist-info/entry_points.txt,sha256=9kyUBoAFyDv285_CLuoIIuOA7xGhZc2-nHs7aJxtJ6E,48
13
+ openfaba-0.1.2.dist-info/top_level.txt,sha256=F8kv6M3SNva6WV2r5pgoxGW3V2B1hai8pD9XrOfaByY,9
14
+ openfaba-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openfaba = openfaba.main:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gustavo Viera López
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ openfaba