TonieToolbox 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- TonieToolbox/__init__.py +1 -1
- TonieToolbox/__main__.py +64 -2
- TonieToolbox/audio_conversion.py +36 -14
- TonieToolbox/constants.py +77 -2
- TonieToolbox/logger.py +51 -5
- TonieToolbox/media_tags.py +22 -6
- TonieToolbox/teddycloud.py +101 -2
- TonieToolbox/tonie_file.py +32 -12
- TonieToolbox/tonies_json.py +502 -0
- {tonietoolbox-0.3.0.dist-info → tonietoolbox-0.4.0.dist-info}/METADATA +199 -6
- tonietoolbox-0.4.0.dist-info/RECORD +24 -0
- tonietoolbox-0.3.0.dist-info/RECORD +0 -23
- {tonietoolbox-0.3.0.dist-info → tonietoolbox-0.4.0.dist-info}/WHEEL +0 -0
- {tonietoolbox-0.3.0.dist-info → tonietoolbox-0.4.0.dist-info}/entry_points.txt +0 -0
- {tonietoolbox-0.3.0.dist-info → tonietoolbox-0.4.0.dist-info}/licenses/LICENSE.md +0 -0
- {tonietoolbox-0.3.0.dist-info → tonietoolbox-0.4.0.dist-info}/top_level.txt +0 -0
TonieToolbox/__init__.py
CHANGED
TonieToolbox/__main__.py
CHANGED
@@ -19,6 +19,7 @@ from .version_handler import check_for_updates, clear_version_cache
|
|
19
19
|
from .recursive_processor import process_recursive_folders
|
20
20
|
from .media_tags import is_available as is_media_tags_available, ensure_mutagen
|
21
21
|
from .teddycloud import upload_to_teddycloud, get_tags_from_teddycloud, get_file_paths
|
22
|
+
from .tonies_json import fetch_and_update_tonies_json
|
22
23
|
|
23
24
|
def main():
|
24
25
|
"""Entry point for the TonieToolbox application."""
|
@@ -50,7 +51,9 @@ def main():
|
|
50
51
|
help='Maximum number of retry attempts (default: 3)')
|
51
52
|
teddycloud_group.add_argument('--retry-delay', type=int, metavar='SECONDS', default=5,
|
52
53
|
help='Delay between retry attempts in seconds (default: 5)')
|
53
|
-
|
54
|
+
teddycloud_group.add_argument('--create-custom-json', action='store_true',
|
55
|
+
help='Fetch and update custom Tonies JSON data')
|
56
|
+
|
54
57
|
parser.add_argument('input_filename', metavar='SOURCE', type=str, nargs='?',
|
55
58
|
help='input file or directory or a file list (.lst)')
|
56
59
|
parser.add_argument('output_filename', metavar='TARGET', nargs='?', type=str,
|
@@ -104,6 +107,8 @@ def main():
|
|
104
107
|
log_level_group.add_argument('-T', '--trace', action='store_true', help='Enable trace logging (very verbose)')
|
105
108
|
log_level_group.add_argument('-q', '--quiet', action='store_true', help='Show only warnings and errors')
|
106
109
|
log_level_group.add_argument('-Q', '--silent', action='store_true', help='Show only errors')
|
110
|
+
log_group.add_argument('--log-file', action='store_true', default=False,
|
111
|
+
help='Save logs to a timestamped file in .tonietoolbox folder')
|
107
112
|
|
108
113
|
args = parser.parse_args()
|
109
114
|
|
@@ -111,6 +116,7 @@ def main():
|
|
111
116
|
if args.input_filename is None and not (args.get_tags or args.upload):
|
112
117
|
parser.error("the following arguments are required: SOURCE")
|
113
118
|
|
119
|
+
# Set up the logging level
|
114
120
|
if args.trace:
|
115
121
|
from .logger import TRACE
|
116
122
|
log_level = TRACE
|
@@ -123,12 +129,15 @@ def main():
|
|
123
129
|
else:
|
124
130
|
log_level = logging.INFO
|
125
131
|
|
126
|
-
setup_logging(log_level)
|
132
|
+
setup_logging(log_level, log_to_file=args.log_file)
|
127
133
|
logger = get_logger('main')
|
128
134
|
logger.debug("Starting TonieToolbox v%s with log level: %s", __version__, logging.getLevelName(log_level))
|
129
135
|
|
136
|
+
# Log the command-line arguments at trace level for debugging purposes
|
137
|
+
logger.log(logging.DEBUG - 1, "Command-line arguments: %s", vars(args))
|
130
138
|
|
131
139
|
if args.clear_version_cache:
|
140
|
+
logger.log(logging.DEBUG - 1, "Clearing version cache")
|
132
141
|
if clear_version_cache():
|
133
142
|
logger.info("Version cache cleared successfully")
|
134
143
|
else:
|
@@ -141,18 +150,24 @@ def main():
|
|
141
150
|
force_refresh=args.force_refresh_cache
|
142
151
|
)
|
143
152
|
|
153
|
+
logger.log(logging.DEBUG - 1, "Update check results: is_latest=%s, latest_version=%s, update_confirmed=%s",
|
154
|
+
is_latest, latest_version, update_confirmed)
|
155
|
+
|
144
156
|
if not is_latest and not update_confirmed and not (args.silent or args.quiet):
|
145
157
|
logger.info("Update available but user chose to continue without updating.")
|
146
158
|
|
147
159
|
# Handle get-tags from TeddyCloud if requested
|
148
160
|
if args.get_tags:
|
161
|
+
logger.debug("Getting tags from TeddyCloud: %s", args.get_tags)
|
149
162
|
teddycloud_url = args.get_tags
|
150
163
|
success = get_tags_from_teddycloud(teddycloud_url, args.ignore_ssl_verify)
|
164
|
+
logger.log(logging.DEBUG - 1, "Exiting with code %d", 0 if success else 1)
|
151
165
|
sys.exit(0 if success else 1)
|
152
166
|
|
153
167
|
# Handle upload to TeddyCloud if requested
|
154
168
|
if args.upload:
|
155
169
|
teddycloud_url = args.upload
|
170
|
+
logger.debug("Upload to TeddyCloud requested: %s", teddycloud_url)
|
156
171
|
|
157
172
|
if not args.input_filename:
|
158
173
|
logger.error("Missing input file for --upload. Provide a file path as SOURCE argument.")
|
@@ -162,6 +177,7 @@ def main():
|
|
162
177
|
if os.path.exists(args.input_filename) and (args.input_filename.lower().endswith('.taf') or
|
163
178
|
args.input_filename.lower().endswith(('.jpg', '.jpeg', '.png'))):
|
164
179
|
# Direct upload of existing TAF or image file
|
180
|
+
logger.debug("Direct upload of existing TAF or image file detected")
|
165
181
|
# Use get_file_paths to handle Windows backslashes and resolve the paths correctly
|
166
182
|
file_paths = get_file_paths(args.input_filename)
|
167
183
|
|
@@ -191,10 +207,12 @@ def main():
|
|
191
207
|
else:
|
192
208
|
logger.info("Successfully uploaded %s to TeddyCloud", file_path)
|
193
209
|
|
210
|
+
logger.log(logging.DEBUG - 1, "Exiting after direct upload with code 0")
|
194
211
|
sys.exit(0)
|
195
212
|
|
196
213
|
# If we get here, it's not a TAF or image file, so continue with normal processing
|
197
214
|
# which will convert the input files and upload the result later
|
215
|
+
logger.debug("Input is not a direct upload file, continuing with conversion workflow")
|
198
216
|
pass
|
199
217
|
|
200
218
|
ffmpeg_binary = args.ffmpeg
|
@@ -614,5 +632,49 @@ def main():
|
|
614
632
|
else:
|
615
633
|
logger.warning("No artwork found to upload")
|
616
634
|
|
635
|
+
# Handle create-custom-json option
|
636
|
+
if args.create_custom_json and args.upload:
|
637
|
+
teddycloud_url = args.upload
|
638
|
+
artwork_url = None
|
639
|
+
|
640
|
+
# If artwork was uploaded, construct its URL for the JSON
|
641
|
+
if args.include_artwork:
|
642
|
+
taf_basename = os.path.splitext(os.path.basename(out_filename))[0]
|
643
|
+
artwork_ext = None
|
644
|
+
|
645
|
+
# Try to determine the artwork extension by checking what was uploaded
|
646
|
+
source_dir = os.path.dirname(files[0]) if files else None
|
647
|
+
if source_dir:
|
648
|
+
from .media_tags import find_cover_image
|
649
|
+
artwork_path = find_cover_image(source_dir)
|
650
|
+
if artwork_path:
|
651
|
+
artwork_ext = os.path.splitext(artwork_path)[1]
|
652
|
+
|
653
|
+
# If we couldn't determine extension from a found image, default to .jpg
|
654
|
+
if not artwork_ext:
|
655
|
+
artwork_ext = ".jpg"
|
656
|
+
|
657
|
+
# Construct the URL for the artwork based on TeddyCloud structure
|
658
|
+
artwork_path = args.path or "/custom_img"
|
659
|
+
if not artwork_path.endswith('/'):
|
660
|
+
artwork_path += '/'
|
661
|
+
|
662
|
+
artwork_url = f"{teddycloud_url}{artwork_path}{taf_basename}{artwork_ext}"
|
663
|
+
logger.debug("Using artwork URL: %s", artwork_url)
|
664
|
+
|
665
|
+
logger.info("Fetching and updating custom Tonies JSON data")
|
666
|
+
success = fetch_and_update_tonies_json(
|
667
|
+
teddycloud_url,
|
668
|
+
args.ignore_ssl_verify,
|
669
|
+
out_filename,
|
670
|
+
files,
|
671
|
+
artwork_url
|
672
|
+
)
|
673
|
+
|
674
|
+
if success:
|
675
|
+
logger.info("Successfully updated custom Tonies JSON data")
|
676
|
+
else:
|
677
|
+
logger.warning("Failed to update custom Tonies JSON data")
|
678
|
+
|
617
679
|
if __name__ == "__main__":
|
618
680
|
main()
|
TonieToolbox/audio_conversion.py
CHANGED
@@ -28,6 +28,9 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
28
28
|
Returns:
|
29
29
|
tuple: (file handle, temp_file_path) or (file handle, None) if keep_temp is False
|
30
30
|
"""
|
31
|
+
logger.trace("Entering get_opus_tempfile(ffmpeg_binary=%s, opus_binary=%s, filename=%s, bitrate=%d, vbr=%s, keep_temp=%s, auto_download=%s)",
|
32
|
+
ffmpeg_binary, opus_binary, filename, bitrate, vbr, keep_temp, auto_download)
|
33
|
+
|
31
34
|
logger.debug("Converting %s to Opus format (bitrate: %d kbps, vbr: %s)", filename, bitrate, vbr)
|
32
35
|
|
33
36
|
if ffmpeg_binary is None:
|
@@ -59,18 +62,19 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
59
62
|
|
60
63
|
logger.debug("Starting FFmpeg process")
|
61
64
|
try:
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
+
ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
|
66
|
+
logger.trace("FFmpeg command: %s", ffmpeg_cmd)
|
67
|
+
ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
|
65
68
|
except FileNotFoundError:
|
66
69
|
logger.error("Error opening input file %s", filename)
|
67
70
|
raise RuntimeError(f"Error opening input file {filename}")
|
68
71
|
|
69
72
|
logger.debug("Starting opusenc process")
|
70
73
|
try:
|
74
|
+
opusenc_cmd = [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", temp_path]
|
75
|
+
logger.trace("Opusenc command: %s", opusenc_cmd)
|
71
76
|
opusenc_process = subprocess.Popen(
|
72
|
-
|
73
|
-
stdin=ffmpeg_process.stdout, stderr=subprocess.DEVNULL)
|
77
|
+
opusenc_cmd, stdin=ffmpeg_process.stdout, stderr=subprocess.DEVNULL)
|
74
78
|
except Exception as e:
|
75
79
|
logger.error("Opus encoding failed: %s", str(e))
|
76
80
|
raise RuntimeError(f"Opus encoding failed: {str(e)}")
|
@@ -79,6 +83,8 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
79
83
|
opusenc_return = opusenc_process.wait()
|
80
84
|
ffmpeg_return = ffmpeg_process.wait()
|
81
85
|
|
86
|
+
logger.debug("Process return codes - FFmpeg: %d, Opus: %d", ffmpeg_return, opusenc_return)
|
87
|
+
|
82
88
|
if ffmpeg_return != 0:
|
83
89
|
logger.error("FFmpeg processing failed with return code %d", ffmpeg_return)
|
84
90
|
raise RuntimeError(f"FFmpeg processing failed with return code {ffmpeg_return}")
|
@@ -87,9 +93,10 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
87
93
|
logger.error("Opus encoding failed with return code %d", opusenc_return)
|
88
94
|
raise RuntimeError(f"Opus encoding failed with return code {opusenc_return}")
|
89
95
|
|
90
|
-
logger.debug("Opening temporary file for reading")
|
96
|
+
logger.debug("Opening temporary file for reading: %s", temp_path)
|
91
97
|
try:
|
92
98
|
tmp_file = open(temp_path, "rb")
|
99
|
+
logger.trace("Exiting get_opus_tempfile() with persistent temporary file")
|
93
100
|
return tmp_file, temp_path
|
94
101
|
except Exception as e:
|
95
102
|
logger.error("Failed to open temporary file: %s", str(e))
|
@@ -99,18 +106,19 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
99
106
|
|
100
107
|
logger.debug("Starting FFmpeg process")
|
101
108
|
try:
|
102
|
-
|
103
|
-
|
104
|
-
|
109
|
+
ffmpeg_cmd = [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav", "-ar", "48000", "-"]
|
110
|
+
logger.trace("FFmpeg command: %s", ffmpeg_cmd)
|
111
|
+
ffmpeg_process = subprocess.Popen(ffmpeg_cmd, stdout=subprocess.PIPE)
|
105
112
|
except FileNotFoundError:
|
106
113
|
logger.error("Error opening input file %s", filename)
|
107
114
|
raise RuntimeError(f"Error opening input file {filename}")
|
108
115
|
|
109
116
|
logger.debug("Starting opusenc process")
|
110
117
|
try:
|
118
|
+
opusenc_cmd = [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", "-"]
|
119
|
+
logger.trace("Opusenc command: %s", opusenc_cmd)
|
111
120
|
opusenc_process = subprocess.Popen(
|
112
|
-
|
113
|
-
stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
121
|
+
opusenc_cmd, stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
114
122
|
except Exception as e:
|
115
123
|
logger.error("Opus encoding failed: %s", str(e))
|
116
124
|
raise RuntimeError(f"Opus encoding failed: {str(e)}")
|
@@ -124,10 +132,14 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
124
132
|
for chunk in iter(lambda: opusenc_process.stdout.read(4096), b""):
|
125
133
|
tmp_file.write(chunk)
|
126
134
|
bytes_written += len(chunk)
|
135
|
+
if bytes_written % (1024 * 1024) == 0: # Log every 1MB
|
136
|
+
logger.trace("Written %d bytes so far", bytes_written)
|
127
137
|
|
128
138
|
opusenc_return = opusenc_process.wait()
|
129
139
|
ffmpeg_return = ffmpeg_process.wait()
|
130
140
|
|
141
|
+
logger.debug("Process return codes - FFmpeg: %d, Opus: %d", ffmpeg_return, opusenc_return)
|
142
|
+
|
131
143
|
if ffmpeg_return != 0:
|
132
144
|
logger.error("FFmpeg processing failed with return code %d", ffmpeg_return)
|
133
145
|
raise RuntimeError(f"FFmpeg processing failed with return code {ffmpeg_return}")
|
@@ -139,6 +151,7 @@ def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitra
|
|
139
151
|
logger.debug("Wrote %d bytes to temporary file", bytes_written)
|
140
152
|
tmp_file.seek(0)
|
141
153
|
|
154
|
+
logger.trace("Exiting get_opus_tempfile() with in-memory temporary file")
|
142
155
|
return tmp_file, None
|
143
156
|
|
144
157
|
|
@@ -152,6 +165,7 @@ def filter_directories(glob_list):
|
|
152
165
|
Returns:
|
153
166
|
list: Filtered list containing only supported audio files
|
154
167
|
"""
|
168
|
+
logger.trace("Entering filter_directories() with %d items", len(glob_list))
|
155
169
|
logger.debug("Filtering %d glob results for supported audio files", len(glob_list))
|
156
170
|
|
157
171
|
# Common audio file extensions supported by ffmpeg
|
@@ -171,6 +185,7 @@ def filter_directories(glob_list):
|
|
171
185
|
logger.trace("Skipping unsupported file: %s", name)
|
172
186
|
|
173
187
|
logger.debug("Found %d supported audio files after filtering", len(filtered))
|
188
|
+
logger.trace("Exiting filter_directories() with %d files", len(filtered))
|
174
189
|
return filtered
|
175
190
|
|
176
191
|
|
@@ -186,6 +201,7 @@ def get_input_files(input_filename):
|
|
186
201
|
Returns:
|
187
202
|
list: List of input file paths
|
188
203
|
"""
|
204
|
+
logger.trace("Entering get_input_files(input_filename=%s)", input_filename)
|
189
205
|
logger.debug("Getting input files for pattern: %s", input_filename)
|
190
206
|
|
191
207
|
if input_filename.endswith(".lst"):
|
@@ -196,6 +212,7 @@ def get_input_files(input_filename):
|
|
196
212
|
for line_num, line in enumerate(file_list, 1):
|
197
213
|
fname = line.strip()
|
198
214
|
if not fname or fname.startswith('#'): # Skip empty lines and comments
|
215
|
+
logger.trace("Skipping empty line or comment at line %d", line_num)
|
199
216
|
continue
|
200
217
|
|
201
218
|
# Remove any quote characters from path
|
@@ -204,10 +221,10 @@ def get_input_files(input_filename):
|
|
204
221
|
# Check if the path is absolute or has a drive letter (Windows)
|
205
222
|
if os.path.isabs(fname) or (len(fname) > 1 and fname[1] == ':'):
|
206
223
|
full_path = fname # Use as is if it's an absolute path
|
207
|
-
logger.trace("Using absolute path from
|
224
|
+
logger.trace("Using absolute path from line %d: %s", line_num, full_path)
|
208
225
|
else:
|
209
226
|
full_path = os.path.join(list_dir, fname)
|
210
|
-
logger.trace("Using relative path from
|
227
|
+
logger.trace("Using relative path from line %d: %s -> %s", line_num, fname, full_path)
|
211
228
|
|
212
229
|
# Handle directory paths by finding all audio files in the directory
|
213
230
|
if os.path.isdir(full_path):
|
@@ -216,11 +233,12 @@ def get_input_files(input_filename):
|
|
216
233
|
dir_files = sorted(filter_directories(glob.glob(dir_glob)))
|
217
234
|
if dir_files:
|
218
235
|
input_files.extend(dir_files)
|
219
|
-
logger.debug("Found %d audio files in directory", len(dir_files))
|
236
|
+
logger.debug("Found %d audio files in directory from line %d", len(dir_files), line_num)
|
220
237
|
else:
|
221
238
|
logger.warning("No audio files found in directory at line %d: %s", line_num, full_path)
|
222
239
|
elif os.path.isfile(full_path):
|
223
240
|
input_files.append(full_path)
|
241
|
+
logger.trace("Added file from line %d: %s", line_num, full_path)
|
224
242
|
else:
|
225
243
|
logger.warning("File not found at line %d: %s", line_num, full_path)
|
226
244
|
|
@@ -230,6 +248,7 @@ def get_input_files(input_filename):
|
|
230
248
|
input_files = sorted(filter_directories(glob.glob(input_filename)))
|
231
249
|
logger.debug("Found %d files matching pattern", len(input_files))
|
232
250
|
|
251
|
+
logger.trace("Exiting get_input_files() with %d files", len(input_files))
|
233
252
|
return input_files
|
234
253
|
|
235
254
|
|
@@ -244,13 +263,16 @@ def append_to_filename(output_filename, tag):
|
|
244
263
|
Returns:
|
245
264
|
str: Modified filename with tag
|
246
265
|
"""
|
266
|
+
logger.trace("Entering append_to_filename(output_filename=%s, tag=%s)", output_filename, tag)
|
247
267
|
logger.debug("Appending tag '%s' to filename: %s", tag, output_filename)
|
248
268
|
pos = output_filename.rfind('.')
|
249
269
|
if pos == -1:
|
250
270
|
result = f"{output_filename}_{tag}"
|
251
271
|
logger.debug("No extension found, result: %s", result)
|
272
|
+
logger.trace("Exiting append_to_filename() with result=%s", result)
|
252
273
|
return result
|
253
274
|
else:
|
254
275
|
result = f"{output_filename[:pos]}_{tag}{output_filename[pos:]}"
|
255
276
|
logger.debug("Extension found, result: %s", result)
|
277
|
+
logger.trace("Exiting append_to_filename() with result=%s", result)
|
256
278
|
return result
|
TonieToolbox/constants.py
CHANGED
@@ -9,7 +9,82 @@ TOO_MANY_SEGMENTS = -4
|
|
9
9
|
TIMESTAMP_DEDUCT = 0x50000000
|
10
10
|
OPUS_TAGS = [
|
11
11
|
bytearray(
|
12
|
-
b"\x4F\x70\x75\x73\x54\x61\x67\x73\x0D\x00\x00\x00\x4C\x61\x76\x66\x35\x38\x2E\x32\x30\x2E\x31\x30\x30\x03\x00\x00\x00\x26\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x3D\x6F\x70\x75\x73\x65\x6E\x63\x20\x66\x72\x6F\x6D\x20\x6F\x70\x75\x73\x2D\x74\x6F\x6F\x6C\x73\x20\x30\x2E\x31\x2E\x31\x30\x2A\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x5F\x6F\x70\x74\x69\x6F\x6E\x73\x3D\x2D\x2D\x71\x75\x69\x65\x74\x20\x2D\x2D\x62\x69\x74\x72\x61\x74\x65\x20\x39\x36\x20\x2D\x2D\x76\x62\x72\x3B\x01\x00\x00\x70\x61\x64\x3D\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30
|
12
|
+
b"\x4F\x70\x75\x73\x54\x61\x67\x73\x0D\x00\x00\x00\x4C\x61\x76\x66\x35\x38\x2E\x32\x30\x2E\x31\x30\x30\x03\x00\x00\x00\x26\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x3D\x6F\x70\x75\x73\x65\x6E\x63\x20\x66\x72\x6F\x6D\x20\x6F\x70\x75\x73\x2D\x74\x6F\x6F\x6C\x73\x20\x30\x2E\x31\x2E\x31\x30\x2A\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x5F\x6F\x70\x74\x69\x6F\x6E\x73\x3D\x2D\x2D\x71\x75\x69\x65\x74\x20\x2D\x2D\x62\x69\x74\x72\x61\x74\x65\x20\x39\x36\x20\x2D\x2D\x76\x62\x72\x3B\x01\x00\x00\x70\x61\x64\x3D\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30"),
|
13
13
|
bytearray(
|
14
14
|
b"\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30")
|
15
|
-
]
|
15
|
+
]
|
16
|
+
|
17
|
+
# Mapping of language tags to ISO codes
|
18
|
+
LANGUAGE_MAPPING = {
|
19
|
+
# Common language names to ISO codes
|
20
|
+
'deutsch': 'de-de',
|
21
|
+
'german': 'de-de',
|
22
|
+
'english': 'en-us',
|
23
|
+
'englisch': 'en-us',
|
24
|
+
'français': 'fr-fr',
|
25
|
+
'french': 'fr-fr',
|
26
|
+
'franzosisch': 'fr-fr',
|
27
|
+
'italiano': 'it-it',
|
28
|
+
'italian': 'it-it',
|
29
|
+
'italienisch': 'it-it',
|
30
|
+
'español': 'es-es',
|
31
|
+
'spanish': 'es-es',
|
32
|
+
'spanisch': 'es-es',
|
33
|
+
# Two-letter codes
|
34
|
+
'de': 'de-de',
|
35
|
+
'en': 'en-us',
|
36
|
+
'fr': 'fr-fr',
|
37
|
+
'it': 'it-it',
|
38
|
+
'es': 'es-es',
|
39
|
+
}
|
40
|
+
|
41
|
+
# Mapping of genre tags to tonie categories
|
42
|
+
GENRE_MAPPING = {
|
43
|
+
# Standard Tonie category names from tonies.json
|
44
|
+
'hörspiel': 'Hörspiele & Hörbücher',
|
45
|
+
'hörbuch': 'Hörspiele & Hörbücher',
|
46
|
+
'hörbücher': 'Hörspiele & Hörbücher',
|
47
|
+
'hörspiele': 'Hörspiele & Hörbücher',
|
48
|
+
'audiobook': 'Hörspiele & Hörbücher',
|
49
|
+
'audio book': 'Hörspiele & Hörbücher',
|
50
|
+
'audio play': 'Hörspiele & Hörbücher',
|
51
|
+
'audio-play': 'Hörspiele & Hörbücher',
|
52
|
+
'audiospiel': 'Hörspiele & Hörbücher',
|
53
|
+
'geschichte': 'Hörspiele & Hörbücher',
|
54
|
+
'geschichten': 'Hörspiele & Hörbücher',
|
55
|
+
'erzählung': 'Hörspiele & Hörbücher',
|
56
|
+
|
57
|
+
# Music related genres
|
58
|
+
'musik': 'music',
|
59
|
+
'lieder': 'music',
|
60
|
+
'songs': 'music',
|
61
|
+
'music': 'music',
|
62
|
+
'lied': 'music',
|
63
|
+
'song': 'music',
|
64
|
+
|
65
|
+
# More specific categories
|
66
|
+
'kinder': 'Hörspiele & Hörbücher',
|
67
|
+
'children': 'Hörspiele & Hörbücher',
|
68
|
+
'märchen': 'Hörspiele & Hörbücher',
|
69
|
+
'fairy tale': 'Hörspiele & Hörbücher',
|
70
|
+
'märche': 'Hörspiele & Hörbücher',
|
71
|
+
|
72
|
+
'wissen': 'Wissen & Hörmagazine',
|
73
|
+
'knowledge': 'Wissen & Hörmagazine',
|
74
|
+
'sachbuch': 'Wissen & Hörmagazine',
|
75
|
+
'learning': 'Wissen & Hörmagazine',
|
76
|
+
'educational': 'Wissen & Hörmagazine',
|
77
|
+
'bildung': 'Wissen & Hörmagazine',
|
78
|
+
'information': 'Wissen & Hörmagazine',
|
79
|
+
|
80
|
+
'schlaf': 'Schlaflieder & Entspannung',
|
81
|
+
'sleep': 'Schlaflieder & Entspannung',
|
82
|
+
'meditation': 'Schlaflieder & Entspannung',
|
83
|
+
'entspannung': 'Schlaflieder & Entspannung',
|
84
|
+
'relaxation': 'Schlaflieder & Entspannung',
|
85
|
+
'schlaflied': 'Schlaflieder & Entspannung',
|
86
|
+
'einschlafhilfe': 'Schlaflieder & Entspannung',
|
87
|
+
|
88
|
+
# Default to standard format for custom
|
89
|
+
'custom': 'Hörspiele & Hörbücher',
|
90
|
+
}
|
TonieToolbox/logger.py
CHANGED
@@ -5,6 +5,8 @@ Logging configuration for the TonieToolbox package.
|
|
5
5
|
import logging
|
6
6
|
import os
|
7
7
|
import sys
|
8
|
+
from datetime import datetime
|
9
|
+
from pathlib import Path
|
8
10
|
|
9
11
|
# Define log levels and their names
|
10
12
|
TRACE = 5 # Custom level for ultra-verbose debugging
|
@@ -19,20 +21,39 @@ def trace(self, message, *args, **kwargs):
|
|
19
21
|
# Add trace method to the Logger class
|
20
22
|
logging.Logger.trace = trace
|
21
23
|
|
22
|
-
def
|
24
|
+
def get_log_file_path():
|
25
|
+
"""
|
26
|
+
Get the path to the log file in the .tonietoolbox folder with timestamp.
|
27
|
+
|
28
|
+
Returns:
|
29
|
+
Path: Path to the log file
|
30
|
+
"""
|
31
|
+
# Create .tonietoolbox folder in user's home directory if it doesn't exist
|
32
|
+
log_dir = Path.home() / '.tonietoolbox'
|
33
|
+
log_dir.mkdir(exist_ok=True)
|
34
|
+
|
35
|
+
# Create timestamp string for the filename
|
36
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
37
|
+
|
38
|
+
# Define log file path with timestamp
|
39
|
+
log_file = log_dir / f'tonietoolbox_{timestamp}.log'
|
40
|
+
|
41
|
+
return log_file
|
42
|
+
|
43
|
+
def setup_logging(level=logging.INFO, log_to_file=False):
|
23
44
|
"""
|
24
45
|
Set up logging configuration for the entire application.
|
25
46
|
|
26
47
|
Args:
|
27
48
|
level: Logging level (default: logging.INFO)
|
49
|
+
log_to_file: Whether to log to a file (default: False)
|
28
50
|
|
29
51
|
Returns:
|
30
52
|
logging.Logger: Root logger instance
|
31
53
|
"""
|
32
|
-
#
|
33
|
-
logging.
|
34
|
-
|
35
|
-
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
54
|
+
# Create formatter
|
55
|
+
formatter = logging.Formatter(
|
56
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
36
57
|
datefmt='%Y-%m-%d %H:%M:%S'
|
37
58
|
)
|
38
59
|
|
@@ -40,6 +61,31 @@ def setup_logging(level=logging.INFO):
|
|
40
61
|
root_logger = logging.getLogger('TonieToolbox')
|
41
62
|
root_logger.setLevel(level)
|
42
63
|
|
64
|
+
# Remove any existing handlers to avoid duplicate logs
|
65
|
+
for handler in root_logger.handlers[:]:
|
66
|
+
root_logger.removeHandler(handler)
|
67
|
+
|
68
|
+
# Console handler
|
69
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
70
|
+
console_handler.setFormatter(formatter)
|
71
|
+
console_handler.setLevel(level)
|
72
|
+
root_logger.addHandler(console_handler)
|
73
|
+
|
74
|
+
# File handler (if enabled)
|
75
|
+
if log_to_file:
|
76
|
+
try:
|
77
|
+
log_file = get_log_file_path()
|
78
|
+
file_handler = logging.FileHandler(
|
79
|
+
log_file,
|
80
|
+
encoding='utf-8'
|
81
|
+
)
|
82
|
+
file_handler.setFormatter(formatter)
|
83
|
+
file_handler.setLevel(level)
|
84
|
+
root_logger.addHandler(file_handler)
|
85
|
+
root_logger.info(f"Log file created at: {log_file}")
|
86
|
+
except Exception as e:
|
87
|
+
root_logger.error(f"Failed to set up file logging: {e}")
|
88
|
+
|
43
89
|
return root_logger
|
44
90
|
|
45
91
|
def get_logger(name):
|
TonieToolbox/media_tags.py
CHANGED
@@ -209,12 +209,28 @@ def get_file_tags(file_path: str) -> Dict[str, Any]:
|
|
209
209
|
# Process different file types
|
210
210
|
if isinstance(audio, ID3) or hasattr(audio, 'ID3'):
|
211
211
|
# MP3 files
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
212
|
+
try:
|
213
|
+
id3 = audio if isinstance(audio, ID3) else audio.ID3
|
214
|
+
for tag_key, tag_value in id3.items():
|
215
|
+
tag_name = tag_key.split(':')[0] # Handle ID3 tags with colons
|
216
|
+
if tag_name in TAG_MAPPING:
|
217
|
+
tag_value_str = str(tag_value)
|
218
|
+
tags[TAG_MAPPING[tag_name]] = normalize_tag_value(tag_value_str)
|
219
|
+
except (AttributeError, TypeError) as e:
|
220
|
+
logger.debug("Error accessing ID3 tags: %s", e)
|
221
|
+
# Try alternative approach for ID3 tags
|
222
|
+
try:
|
223
|
+
if hasattr(audio, 'tags') and audio.tags:
|
224
|
+
for tag_key in audio.tags.keys():
|
225
|
+
if tag_key in TAG_MAPPING:
|
226
|
+
tag_value = audio.tags[tag_key]
|
227
|
+
if hasattr(tag_value, 'text'):
|
228
|
+
tag_value_str = str(tag_value.text[0]) if tag_value.text else ''
|
229
|
+
else:
|
230
|
+
tag_value_str = str(tag_value)
|
231
|
+
tags[TAG_MAPPING[tag_key]] = normalize_tag_value(tag_value_str)
|
232
|
+
except Exception as e:
|
233
|
+
logger.debug("Alternative ID3 tag reading failed: %s", e)
|
218
234
|
elif isinstance(audio, (FLAC, OggOpus, OggVorbis)):
|
219
235
|
# FLAC and OGG files
|
220
236
|
for tag_key, tag_values in audio.items():
|
TonieToolbox/teddycloud.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
#!/usr/bin/python3
|
2
2
|
"""
|
3
3
|
TeddyCloud API client for TonieToolbox.
|
4
|
-
Handles uploading .taf files to a TeddyCloud instance.
|
4
|
+
Handles uploading .taf files to a TeddyCloud instance and interacting with the TeddyCloud API.
|
5
5
|
"""
|
6
6
|
|
7
7
|
import os
|
@@ -453,6 +453,67 @@ class TeddyCloudClient:
|
|
453
453
|
logger.error("Error preparing file for upload: %s", e)
|
454
454
|
return False
|
455
455
|
|
456
|
+
def get_tonies_custom_json(self) -> Optional[list]:
|
457
|
+
"""
|
458
|
+
Get tonies.custom.json from the TeddyCloud server.
|
459
|
+
|
460
|
+
Returns:
|
461
|
+
List of custom tonie entries or None if request failed
|
462
|
+
"""
|
463
|
+
try:
|
464
|
+
url = f"{self.base_url}/api/toniesCustomJson"
|
465
|
+
logger.info("Loading tonies.custom.json from %s", url)
|
466
|
+
|
467
|
+
req = urllib.request.Request(url)
|
468
|
+
|
469
|
+
with self._urlopen(req) as response:
|
470
|
+
data = json.loads(response.read().decode('utf-8'))
|
471
|
+
if isinstance(data, list):
|
472
|
+
logger.info("Successfully loaded tonies.custom.json with %d entries", len(data))
|
473
|
+
return data
|
474
|
+
else:
|
475
|
+
logger.error("Invalid tonies.custom.json format, expected list")
|
476
|
+
return None
|
477
|
+
|
478
|
+
except urllib.error.HTTPError as e:
|
479
|
+
if e.code == 404:
|
480
|
+
logger.info("tonies.custom.json not found on server, starting with empty list")
|
481
|
+
return []
|
482
|
+
else:
|
483
|
+
logger.error("HTTP error loading tonies.custom.json: %s", e)
|
484
|
+
return None
|
485
|
+
except Exception as e:
|
486
|
+
logger.error("Error loading tonies.custom.json: %s", e)
|
487
|
+
return None
|
488
|
+
|
489
|
+
def put_tonies_custom_json(self, custom_json_data: List[Dict[str, Any]]) -> bool:
|
490
|
+
"""
|
491
|
+
Save tonies.custom.json to the TeddyCloud server.
|
492
|
+
|
493
|
+
Args:
|
494
|
+
custom_json_data: List of custom tonie entries to save
|
495
|
+
|
496
|
+
Returns:
|
497
|
+
True if successful, False otherwise
|
498
|
+
"""
|
499
|
+
try:
|
500
|
+
url = f"{self.base_url}/api/toniesCustomJson"
|
501
|
+
logger.info("Saving tonies.custom.json to %s", url)
|
502
|
+
|
503
|
+
data = json.dumps(custom_json_data, indent=2).encode('utf-8')
|
504
|
+
headers = {'Content-Type': 'application/json'}
|
505
|
+
|
506
|
+
req = urllib.request.Request(url, data=data, headers=headers, method='PUT')
|
507
|
+
|
508
|
+
with self._urlopen(req) as response:
|
509
|
+
result = response.read().decode('utf-8')
|
510
|
+
logger.info("Successfully saved tonies.custom.json to server")
|
511
|
+
return True
|
512
|
+
|
513
|
+
except Exception as e:
|
514
|
+
logger.error("Error saving tonies.custom.json to server: %s", e)
|
515
|
+
return False
|
516
|
+
|
456
517
|
def upload_to_teddycloud(file_path: str, teddycloud_url: str, ignore_ssl_verify: bool = False,
|
457
518
|
special_folder: str = None, path: str = None, show_progress: bool = True,
|
458
519
|
connection_timeout: int = DEFAULT_CONNECTION_TIMEOUT,
|
@@ -577,4 +638,42 @@ def get_tags_from_teddycloud(teddycloud_url: str, ignore_ssl_verify: bool = Fals
|
|
577
638
|
|
578
639
|
print("-" * 60)
|
579
640
|
|
580
|
-
return True
|
641
|
+
return True
|
642
|
+
|
643
|
+
def get_tonies_custom_json_from_server(teddycloud_url: str, ignore_ssl_verify: bool = False) -> Optional[list]:
|
644
|
+
"""
|
645
|
+
Get tonies.custom.json from the TeddyCloud server.
|
646
|
+
|
647
|
+
Args:
|
648
|
+
teddycloud_url: URL of the TeddyCloud instance
|
649
|
+
ignore_ssl_verify: If True, SSL certificate verification will be disabled
|
650
|
+
|
651
|
+
Returns:
|
652
|
+
List of custom tonie entries or None if request failed
|
653
|
+
"""
|
654
|
+
if not teddycloud_url:
|
655
|
+
logger.error("Cannot load from server: No TeddyCloud URL provided")
|
656
|
+
return None
|
657
|
+
|
658
|
+
client = TeddyCloudClient(teddycloud_url, ignore_ssl_verify)
|
659
|
+
return client.get_tonies_custom_json()
|
660
|
+
|
661
|
+
def put_tonies_custom_json_to_server(teddycloud_url: str, custom_json_data: List[Dict[str, Any]],
|
662
|
+
ignore_ssl_verify: bool = False) -> bool:
|
663
|
+
"""
|
664
|
+
Save tonies.custom.json to the TeddyCloud server.
|
665
|
+
|
666
|
+
Args:
|
667
|
+
teddycloud_url: URL of the TeddyCloud instance
|
668
|
+
custom_json_data: List of custom tonie entries to save
|
669
|
+
ignore_ssl_verify: If True, SSL certificate verification will be disabled
|
670
|
+
|
671
|
+
Returns:
|
672
|
+
True if successful, False otherwise
|
673
|
+
"""
|
674
|
+
if not teddycloud_url:
|
675
|
+
logger.error("Cannot save to server: No TeddyCloud URL provided")
|
676
|
+
return False
|
677
|
+
|
678
|
+
client = TeddyCloudClient(teddycloud_url, ignore_ssl_verify)
|
679
|
+
return client.put_tonies_custom_json(custom_json_data)
|