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 +6 -0
- openfaba/cli.py +169 -0
- openfaba/consts.py +152 -0
- openfaba/io.py +94 -0
- openfaba/main.py +9 -0
- openfaba/media.py +196 -0
- openfaba/py.typed +0 -0
- openfaba/utils.py +18 -0
- openfaba-0.1.2.dist-info/METADATA +231 -0
- openfaba-0.1.2.dist-info/RECORD +14 -0
- openfaba-0.1.2.dist-info/WHEEL +5 -0
- openfaba-0.1.2.dist-info/entry_points.txt +2 -0
- openfaba-0.1.2.dist-info/licenses/LICENSE +21 -0
- openfaba-0.1.2.dist-info/top_level.txt +1 -0
openfaba/__init__.py
ADDED
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
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,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
|