TonieToolbox 0.3.0__py3-none-any.whl → 0.4.1__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 CHANGED
@@ -2,4 +2,4 @@
2
2
  TonieToolbox - Convert audio files to Tonie box compatible format
3
3
  """
4
4
 
5
- __version__ = '0.3.0'
5
+ __version__ = '0.4.1'
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()
@@ -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
- ffmpeg_process = subprocess.Popen(
63
- [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
64
- "-ar", "48000", "-"], stdout=subprocess.PIPE)
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
- [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", temp_path],
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
- ffmpeg_process = subprocess.Popen(
103
- [ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
104
- "-ar", "48000", "-"], stdout=subprocess.PIPE)
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
- [opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", "-"],
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 list: %s", full_path)
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 list: %s", full_path)
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\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 setup_logging(level=logging.INFO):
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
- # Configure root logger
33
- logging.basicConfig(
34
- level=level,
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):
@@ -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
- id3 = audio if isinstance(audio, ID3) else audio.ID3
213
- for tag_key, tag_value in id3.items():
214
- tag_name = tag_key.split(':')[0] # Handle ID3 tags with colons
215
- if tag_name in TAG_MAPPING:
216
- tag_value_str = str(tag_value)
217
- tags[TAG_MAPPING[tag_name]] = normalize_tag_value(tag_value_str)
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():
@@ -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)