@@ -229,13 +229,63 @@ def parse_track_info(self, path: str) -> tuple:
229229 if music_idx >= 0 :
230230 adjusted_sections = path_sections [music_idx + 1 :]
231231
232- # need at least Artist/Album/Track
233- if len (adjusted_sections ) < 3 :
232+ # supported folder structures
233+ # - /Music/Artist/Album/Track.ext
234+ # - /Music/Artist - Album/Track.ext
235+ # - /Music/Track.ext
236+ if len (adjusted_sections ) < 1 :
234237 raise ValueError (f"Not enough sections after Music: { path } " )
235238
236- artist = adjusted_sections [0 ].strip ()
237- album = self .fix_explicit_label (adjusted_sections [1 ].strip ())
238- track_filename = adjusted_sections [2 ]
239+ if len (adjusted_sections ) == 1 :
240+ # files directly in /Music/
241+ track_filename = adjusted_sections [0 ]
242+ # extract metadata from filename
243+ if ' - ' in track_filename :
244+ parts = track_filename .split (' - ' )
245+ if len (parts ) >= 3 :
246+ # "Artist - Album - Song.ext" format
247+ artist = parts [0 ].strip ()
248+ album = self .fix_explicit_label (parts [1 ].strip ())
249+ elif len (parts ) == 2 :
250+ # "Artist - Song.ext" format
251+ artist = parts [0 ].strip ()
252+ album = "Unknown Album"
253+ else :
254+ artist = "Unknown Artist"
255+ album = "Unknown Album"
256+ else :
257+ artist = "Unknown Artist"
258+ album = "Unknown Album"
259+ elif len (adjusted_sections ) == 2 :
260+ # files in /Music/Artist - Album/ or /Music/Various Artists/Album/
261+ folder_name = adjusted_sections [0 ].strip ()
262+ track_filename = adjusted_sections [1 ]
263+
264+ # edge cases: folders like "Various Artists" or "Soundtracks" (from google lol)
265+ # cannot guarantee the below at all :sob:
266+ if folder_name in ["Various Artists" , "Soundtracks" , "Compilations" ]:
267+ artist_from_file = None
268+ if ' - ' in track_filename :
269+ parts = track_filename .split (' - ' , 1 )
270+ if len (parts ) >= 2 :
271+ artist_from_file = parts [0 ].strip ()
272+
273+ artist = artist_from_file if artist_from_file else folder_name
274+ album = folder_name
275+ # try to split by ' - ' to separate artist and album
276+ elif ' - ' in folder_name :
277+ parts = folder_name .split (' - ' , 1 )
278+ artist = parts [0 ].strip ()
279+ album = self .fix_explicit_label (parts [1 ].strip ())
280+ else :
281+ # no separator, use folder as both artist and album
282+ artist = folder_name
283+ album = folder_name
284+ else :
285+ # standard /Music/Artist/Album/Track.ext structure
286+ artist = adjusted_sections [0 ].strip ()
287+ album = self .fix_explicit_label (adjusted_sections [1 ].strip ())
288+ track_filename = adjusted_sections [2 ]
239289
240290 # check track extension
241291 if not self .is_valid_track_filename (track_filename ):
@@ -248,7 +298,7 @@ def parse_track_info(self, path: str) -> tuple:
248298 song = self .extract_song_from_filename (track_filename )
249299
250300 # ensure song title is valid
251- if not song or song .lower () in [ 'flac' , 'mp3' , 'ogg' , 'wav' , 'm4a' ] :
301+ if not song or song .lower () in SONG_EXTENSIONS :
252302 raise ValueError (f"Invalid song title: { song } " )
253303
254304 return album , artist , song
@@ -366,7 +416,11 @@ def logs_to_df(self) -> pd.DataFrame:
366416 })
367417 except Exception as e :
368418 failed .append (log_entry )
369- print (log_entry , e )
419+ # handle encoding errors on Windows (charmap codec)
420+ try :
421+ print (log_entry , e )
422+ except UnicodeEncodeError :
423+ print (log_entry .encode ('ascii' , 'replace' ).decode ('ascii' ), e )
370424
371425 df = pd .DataFrame (entries )
372426 if len (failed ) > 0 :
@@ -802,11 +856,49 @@ def run(self) -> dict:
802856
803857
804858 try :
805- # copy log to local storage
806- shutil .copy2 (log_location , STORAGE_DIR )
859+ # merge iPod playback.log with local storage copy
860+ local_log_path = os .path .join (STORAGE_DIR , 'playback.log' )
861+
862+ # read existing local log entries (if exists)
863+ existing_entries = set ()
864+ if os .path .exists (local_log_path ):
865+ with open (local_log_path , 'r' , encoding = 'utf-8' , errors = 'replace' ) as f :
866+ for line in f :
867+ if not line .startswith ('#' ):
868+ existing_entries .add (line .strip ())
869+
870+ # read iPod log and collect new entries
871+ new_entries = []
872+ new_headers = []
873+ with open (log_location , 'r' , encoding = 'utf-8' , errors = 'replace' ) as f :
874+ for line in f :
875+ line = line .strip ()
876+ if line .startswith ('#' ):
877+ # collect header comments from iPod log
878+ new_headers .append (line )
879+ elif line and line not in existing_entries :
880+ new_entries .append (line )
881+
882+ # append new entries to local log
883+ if new_entries :
884+ with open (local_log_path , 'a' , encoding = 'utf-8' ) as f :
885+ # write headers if any
886+ for header in new_headers :
887+ f .write (header + '\n ' )
888+ # write new play entries
889+ for entry in new_entries :
890+ f .write (entry + '\n ' )
891+ print (f"Appended { len (new_entries )} new plays to local playback.log" )
892+ else :
893+ # if no local log exists yet, just copy the iPod log
894+ if not os .path .exists (local_log_path ):
895+ shutil .copy2 (log_location , STORAGE_DIR )
896+ print ("Created initial local playback.log from iPod" )
897+ else :
898+ print ("No new plays to append to local playback.log" )
807899
808900 # read and analyse logs
809- self .log_data = self .load_logs (log_location )
901+ self .log_data = self .load_logs (local_log_path )
810902 self .log_df = self .logs_to_df ()
811903 print (f"Loaded { len (self .log_df )} log entries" )
812904
@@ -831,7 +923,11 @@ def run(self) -> dict:
831923 # run stats
832924 self .stats = self .calc_all_stats ()
833925 except Exception as e :
834- print (f"ERROR: { e } " )
926+ # handle encoding errors on Windows (charmap codec)
927+ try :
928+ print (f"ERROR: { e } " )
929+ except UnicodeEncodeError :
930+ print (f"ERROR: { str (e ).encode ('ascii' , 'replace' ).decode ('ascii' )} " )
835931 return {"error" : "Something went wrong. Please try again later" }
836932
837933 # cleanup
0 commit comments